From 9b7c46cf6223df73f09f292967a45440309a2754 Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Sun, 14 Jun 2026 21:40:55 +0200 Subject: [PATCH 1/8] Fix HEIC conversion returning an empty ZIP HEIC uploads converted to an empty ZIP with a "complete" status and no error. Two causes: 1. The Docker image lacked libheif, so libvips had no HEIF loader and every HEIC preset failed to decode. Move builder and runtime to Debian trixie (libheif 1.19 -- bookworm's 1.15 rejects iPhone HEICs with "Metadata not correctly assigned to image") and install libheif1 + libde265-0 in the runtime stage. The runtime libvips package is renamed libvips42t64 on trixie. 2. When every preset for a file failed, runJob still called Finish() and served a valid-but-empty ZIP. Fail the job loudly when it produced zero outputs so the frontend surfaces the terminal error instead. --- Dockerfile | 20 +++++++++++++++----- handlers/store.go | 12 ++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index f251cfa..1c89d43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,15 @@ COPY frontend/ ./ RUN npm run build # ---- Stage 2: build the Go binary (cgo + libvips) ---- -FROM golang:1.26-bookworm AS builder +# Trixie (not bookworm) for its newer libheif: bookworm's libheif 1.15 rejects +# iPhone HEICs during decode ("Metadata not correctly assigned to image"); +# trixie's 1.19 decodes them fine. Builder and runtime bases must match. +FROM golang:1.26-trixie AS builder +# libheif-dev provides the HEIF/HEIC loader headers libvips' heifload links +# against — libvips-dev only recommends it, so install it explicitly or HEIC +# decoding is silently absent from the build. RUN apt-get update && apt-get install -y --no-install-recommends \ - libvips-dev pkg-config curl ca-certificates \ + libvips-dev libheif-dev pkg-config curl ca-certificates \ && rm -rf /var/lib/apt/lists/* # VERSION is the release string shown in the UI footer (via /version). It is @@ -35,10 +41,14 @@ RUN CGO_ENABLED=1 GOOS=linux go build -tags "vips" \ -o /app/image-optimizer . # ---- Stage 3: minimal runtime ---- -FROM debian:bookworm-slim AS runtime -# libvips42 is required by the image pipeline. +FROM debian:trixie-slim AS runtime +# libvips42t64 is required by the image pipeline (the package was renamed from +# libvips42 in trixie's time_t-64 transition). libheif1 + libde265-0 add HEIC/HEIF +# decoding: libvips does not depend on libheif, and libheif itself needs an HEVC +# decoder (libde265) or it loads the container but fails the bitstream with +# "Unsupported codec". Without these, HEIC uploads decode to nothing → empty ZIP. RUN apt-get update && apt-get install -y --no-install-recommends \ - libvips42 ca-certificates \ + libvips42t64 libheif1 libde265-0 ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && useradd --create-home --uid 10001 app COPY --from=builder /app/image-optimizer /usr/local/bin/image-optimizer diff --git a/handlers/store.go b/handlers/store.go index 96f2e2d..6bd097e 100644 --- a/handlers/store.go +++ b/handlers/store.go @@ -429,5 +429,17 @@ func runJob(job *Job, files []srcFile, imagePresets, bundlePresets []processor.P } } + // If every preset failed (e.g. an input format libvips can't decode, like + // HEIC when libheif is missing), job.outputs is empty. Finishing here would + // emit "complete" and hand the user a valid-but-empty ZIP with no error. + // Fail loudly instead — the frontend already surfaces the terminal "error". + job.mu.Lock() + empty := len(job.outputs) == 0 + job.mu.Unlock() + if empty { + job.Fail() + return + } + job.Finish("/download/" + job.ID) } From 4b033f6abde1579540854effc147094ef0e61ded Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Sun, 14 Jun 2026 21:41:06 +0200 Subject: [PATCH 2/8] Flatten ZIP layout to source_preset filenames Multi-image jobs namespaced each source into its own folder (IMG_0274/convert_jpeg.jpg), which made files awkward to handle. Write plain image outputs flat at the ZIP root as "_." (e.g. IMG_0274_convert_jpeg.jpg) -- source plus preset keeps every entry unique without folders, so the per-source namespacing is gone. Favicon packs stay grouped in a "_favicon/" folder since a favicon set is a drop-in unit; PDF bundles are unchanged. --- handlers/download.go | 79 ++++++++++++++++++--------------------- handlers/download_test.go | 58 +++++++++++++++------------- 2 files changed, 70 insertions(+), 67 deletions(-) diff --git a/handlers/download.go b/handlers/download.go index 583fbee..7d8b10f 100644 --- a/handlers/download.go +++ b/handlers/download.go @@ -23,12 +23,20 @@ func Download(store *Store) fiber.Handler { outputs := job.Outputs() + // A finished job with no outputs means every preset failed (e.g. an + // undecodable input). runJob fails such jobs, but the endpoint is public, + // so guard here too: serve a 404 rather than a valid-but-empty ZIP, which + // would look like a successful download of nothing. + if len(outputs) == 0 { + return fiber.NewError(fiber.StatusNotFound, "job produced no output") + } + // Single-file fast path: when the job produced exactly one output file, // return it raw (with its real content-type and filename) instead of a // one-entry ZIP — the common "one image, one preset" case. A pack (favicon // has many members) or bundle, or any job with 2+ outputs, still ZIPs. if of, ok := soleImageOutput(outputs); ok { - c.Attachment(of.preset + extFor(of.format)) + c.Attachment(entryName(of)) c.Type(extFor(of.format)) // sets Content-Type from the extension freeIfRedownloaded(store, job, jobID) return c.Send(of.data) @@ -37,10 +45,6 @@ func Download(store *Store) fiber.Handler { // Content-Disposition: attachment; filename="optimized.zip". c.Attachment("optimized.zip") - // Whether to namespace entries by source filename depends on how many - // distinct sources contributed outputs. - multiSource := hasMultipleSources(outputs) - return c.SendStreamWriter(func(w *bufio.Writer) { zw := zip.NewWriter(w) for _, of := range outputs { @@ -53,19 +57,18 @@ func Download(store *Store) fiber.Handler { } continue } - // Pack presets (e.g. favicon) expand into a folder of members; - // normal presets are a single named entry. + // Pack presets (e.g. favicon) expand into a folder of members — + // a favicon set is a drop-in unit, so it stays grouped. if len(of.pack) > 0 { - if err := writePack(zw, of, multiSource); err != nil { + if err := writePack(zw, of); err != nil { break } continue } - name := of.preset + extFor(of.format) - if multiSource { - name = path.Join(of.srcBase, name) - } - fw, err := zw.Create(name) + // Plain image outputs are flat at the ZIP root, named + // "_." (e.g. IMG_0274_convert_jpeg.jpg). + // Source name + preset keeps every entry unique without folders. + fw, err := zw.Create(entryName(of)) if err != nil { break } @@ -95,15 +98,13 @@ func freeIfRedownloaded(store *Store, job *Job, jobID string) { } } -// writePack writes every member of a pack preset into the ZIP under a folder -// named after the preset (e.g. "favicon/favicon.ico"). When the job has multiple -// source files the folder is further namespaced by source base, mirroring the -// single-file path. Any write error aborts the pack. -func writePack(zw *zip.Writer, of outFile, multiSource bool) error { - dir := of.preset - if multiSource { - dir = path.Join(of.srcBase, of.preset) - } +// writePack writes every member of a pack preset into the ZIP under a single +// folder (e.g. "IMG_0274_favicon/favicon.ico"). A favicon set is a drop-in unit, +// so its members stay grouped rather than flattened. The folder is named with the +// same "_" convention as flat entries, which keeps packs from +// different sources from colliding. Any write error aborts the pack. +func writePack(zw *zip.Writer, of outFile) error { + dir := entryBase(of) for _, member := range of.pack { fw, err := zw.Create(path.Join(dir, member.Name)) if err != nil { @@ -133,27 +134,21 @@ func writeBundle(zw *zip.Writer, of outFile) error { return nil } -// hasMultipleSources reports whether the outputs came from more than one source -// file, which determines whether ZIP entries are namespaced by source. Bundle -// outputs are job-wide (empty srcBase) and are excluded — otherwise their empty -// source would always register as a distinct source and force namespacing. -func hasMultipleSources(outputs []outFile) bool { - var first string - seen := false - for _, of := range outputs { - if of.bundle { - continue - } - if !seen { - first = of.srcBase - seen = true - continue - } - if of.srcBase != first { - return true - } +// entryBase returns the "_" stem for an output's ZIP entry, +// e.g. "IMG_0274_convert_jpeg". Source name and preset together keep every entry +// unique without namespacing folders. If the source base is unknown (e.g. an +// older job), it falls back to just the preset so the name is still valid. +func entryBase(of outFile) string { + if of.srcBase == "" { + return of.preset } - return false + return of.srcBase + "_" + of.preset +} + +// entryName is entryBase plus the output's file extension, used for flat image +// entries and the single-file fast path (e.g. "IMG_0274_convert_jpeg.jpg"). +func entryName(of outFile) string { + return entryBase(of) + extFor(of.format) } // soleImageOutput returns the single output and true when the job produced diff --git a/handlers/download_test.go b/handlers/download_test.go index f6d6e3f..91cc8fb 100644 --- a/handlers/download_test.go +++ b/handlers/download_test.go @@ -8,33 +8,40 @@ import ( "github.com/hra42/image-optimizer/processor" ) -func TestHasMultipleSources(t *testing.T) { +// TestEntryName locks the flat "_." naming for image +// entries: no namespacing folders, and source+preset together keep entries from +// different sources unique even for the same preset. +func TestEntryName(t *testing.T) { tests := []struct { - name string - outputs []outFile - want bool + name string + of outFile + want string }{ - {"empty", nil, false}, - {"single source", []outFile{{srcBase: "a"}, {srcBase: "a"}}, false}, - {"two sources", []outFile{{srcBase: "a"}, {srcBase: "b"}}, true}, { - // A bundle output has an empty srcBase; it must NOT be counted as a - // distinct source, or it would force every per-file output into a - // namespaced folder. - name: "bundle ignored among single source", - outputs: []outFile{{srcBase: "a"}, {bundle: true}}, - want: false, + name: "source and preset", + of: outFile{srcBase: "IMG_0274", preset: "convert_jpeg", format: processor.FormatJPEG}, + want: "IMG_0274_convert_jpeg.jpg", }, { - name: "bundle ignored, real multi still detected", - outputs: []outFile{{srcBase: "a"}, {bundle: true}, {srcBase: "b"}}, - want: true, + name: "different source, same preset does not collide", + of: outFile{srcBase: "IMG_0275", preset: "convert_jpeg", format: processor.FormatJPEG}, + want: "IMG_0275_convert_jpeg.jpg", + }, + { + name: "webp extension", + of: outFile{srcBase: "photo", preset: "convert_webp", format: processor.FormatWebP}, + want: "photo_convert_webp.webp", + }, + { + // No source base (defensive fallback) → preset-only, still valid. + name: "missing source falls back to preset", + of: outFile{preset: "convert_png", format: processor.FormatPNG}, + want: "convert_png.png", }, - {"only a bundle", []outFile{{bundle: true}}, false}, } for _, tt := range tests { - if got := hasMultipleSources(tt.outputs); got != tt.want { - t.Errorf("%s: hasMultipleSources = %v, want %v", tt.name, got, tt.want) + if got := entryName(tt.of); got != tt.want { + t.Errorf("%s: entryName = %q, want %q", tt.name, got, tt.want) } } } @@ -108,11 +115,12 @@ func TestWriteBundleTopLevel(t *testing.T) { } } -// TestWritePackVsBundleNaming contrasts a pack (folder-namespaced) with a bundle -// (top-level) so the divergence is locked in. +// TestWritePackVsBundleNaming contrasts a pack (kept in a source-prefixed folder) +// with a bundle (top-level) so the divergence is locked in. func TestWritePackVsBundleNaming(t *testing.T) { pack := outFile{ - preset: "favicon", + srcBase: "logo", + preset: "favicon", pack: []processor.OutputFile{ {Name: "favicon.ico", Data: []byte("ico")}, }, @@ -126,7 +134,7 @@ func TestWritePackVsBundleNaming(t *testing.T) { } names := writeToZip(t, func(zw *zip.Writer) { - if err := writePack(zw, pack, false); err != nil { + if err := writePack(zw, pack); err != nil { t.Fatalf("writePack: %v", err) } if err := writeBundle(zw, bundle); err != nil { @@ -135,8 +143,8 @@ func TestWritePackVsBundleNaming(t *testing.T) { }) want := map[string]bool{ - "favicon/favicon.ico": true, // pack: under a preset folder - "linkedin_doc_square.pdf": true, // bundle: at the root + "logo_favicon/favicon.ico": true, // pack: under a "_" folder + "linkedin_doc_square.pdf": true, // bundle: at the root } if len(names) != len(want) { t.Fatalf("ZIP entries = %v, want keys %v", names, want) From 08c92898adeed48ad5db5f4dda96e5b56fe0ba4d Mon Sep 17 00:00:00 2001 From: Henry Rausch Date: Sun, 14 Jun 2026 21:41:06 +0200 Subject: [PATCH 3/8] Show a placeholder for HEIC/HEIF in the file preview Browsers can't render HEIC/HEIF in an , so the pre-upload preview showed a broken image. Detect those formats client-side and render a placeholder icon tile (with the filename and size) instead of a blob URL; the file still uploads and converts server-side. Hide the focal "Adjust crop" affordance for them since it needs a visible image, and key the preview list on a stable id now that url can be null. --- frontend/src/components/Dropzone.svelte | 46 +++++++++++++++++++------ 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Dropzone.svelte b/frontend/src/components/Dropzone.svelte index d725c98..e1b2982 100644 --- a/frontend/src/components/Dropzone.svelte +++ b/frontend/src/components/Dropzone.svelte @@ -50,6 +50,15 @@ return byExt || byMime; } + // HEIC/HEIF can't be rendered by an in any current browser, so a blob + // URL would just show a broken image. Detect those and show a placeholder + // instead — the file still uploads and converts server-side. Other accepted + // formats (incl. SVG) preview fine. + function previewable(file) { + const name = file.name.toLowerCase(); + return !name.endsWith('.heic') && !name.endsWith('.heif'); + } + function fileKey(file) { return `${file.name}:${file.size}`; } @@ -57,13 +66,15 @@ function addFiles(fileList) { const incoming = Array.from(fileList).filter(isAccepted); if (incoming.length === 0) return; - const seen = new Set(files.map(fileKey)); + const seen = new Set(files.map((entry) => entry.key)); const additions = []; for (const f of incoming) { const key = fileKey(f); if (seen.has(key)) continue; seen.add(key); - additions.push({ file: f, url: URL.createObjectURL(f) }); + // url is null for non-previewable formats (HEIC/HEIF); the template + // renders a placeholder in that case. key is stable for {#each} keying. + additions.push({ file: f, key, url: previewable(f) ? URL.createObjectURL(f) : null }); } if (additions.length > 0) { files = [...files, ...additions]; @@ -80,14 +91,15 @@ function removeAt(i) { const entry = files[i]; - if (entry) URL.revokeObjectURL(entry.url); + if (entry?.url) URL.revokeObjectURL(entry.url); files = files.filter((_, idx) => idx !== i); } // Revoke any outstanding object URLs when the component is torn down. + // (HEIC/HEIF entries have a null url and nothing to revoke.) $effect(() => { return () => { - for (const entry of files) URL.revokeObjectURL(entry.url); + for (const entry of files) if (entry.url) URL.revokeObjectURL(entry.url); }; }); @@ -188,22 +200,34 @@ {#if files.length > 0}
    - {#each files as entry, i (entry.url)} + {#each files as entry, i (entry.key)}
  • - {entry.file.name} + {#if entry.url} + {entry.file.name} + {:else} + +
    + +
    + {/if}

    {entry.file.name}

    {formatSize(entry.file.size)}

    - {#if anyCrops} + {#if anyCrops && entry.url}