Both the compression worker and the HTML export ran the same invocation:
ffmpeg -i <src> -vframes 1 -ss 00:00:01 -vf scale=… -y <out>
`-ss` AFTER `-i` is an output-side seek. Against a clip of a second or less ffmpeg
exits 0 and writes nothing, and both call sites gated on the exit status:
- the worker wrote `thumbnail_path` and logged "thumbnail generated" for a file that
was never created, so GET /upload/{id}/thumbnail 404s in the live feed;
- the export listed media/<id>_thumb.jpg in data.json while the ZIP writer skipped the
unopenable file, so the keepsake drew a broken image tile.
Any clip at or under a second, which phones produce constantly — mis-taps, Live
Photos, boomerangs. Not data loss; the .mp4 is in both archives and plays. Every
server-side signal stayed green.
New `services/video.rs` owns the extraction for both callers, mirroring the imaging.rs
precedent (created for the same duplication, and it paid off when the max_alloc fix
landed in both workers at once). Three changes in it:
- `-ss` before `-i`, an input-side seek. NOT sufficient alone: verified against the
production image, seeking to 1s in a 1.000s clip is still past the last frame and
still exits 0 with no file. The 0s fallback is what actually fixes this, and 1s is
tried first only because an opening frame makes a poor poster.
- Verify the artifact, not the exit status. This is the check both sites were missing.
- Carry compression.rs's 120s timeout. export.rs had NONE — a hung ffmpeg there would
strand the job at `running` and the keepsake would never complete.
The worker's call used `?`. Tightening the check without also making a missing poster
non-fatal would have been far worse than the bug: every sub-second clip would fail
compression, exhaust its retries and be soft-deleted. It now logs a warning and leaves
`thumbnail_path` NULL, which FeedListCard, VirtualFeed and LightboxModal already
handle.
The export now sets `thumb: ""` and skips the manifest entry when there is no poster —
and does the same for the IMAGE branch, whose decode failure left the identical
dangling reference. No viewer change was needed: +page.svelte already guards
`{#if post.media.thumb}` and falls back to a video tile with a play glyph. The comment
claiming "viewer handles missing thumbs gracefully" was true about the viewer and false
about what the backend sent — the guard never fired because the string was never empty.
e2e/specs/06-export/export-video.spec.ts had DOCUMENTED this as intended behaviour
("the fixture clip is <1s, so ffmpeg extracts no thumbnail frame — but exits 0 …
that's the intended shape here"). sample.mp4 is exactly 1.000s, so every video test in
the suite ran at that boundary and none ever fetched the poster. That comment is now
corrected to say what it actually was.
Tests: 2 unit; a new sample-5s.mp4 fixture so the ordinary first-seek path is covered
at all; video-playback now FETCHES the poster rather than asserting the attribute (the
one extra request that nine rounds of green never made); a new spec covering both
fixtures plus the mirror that a posterless video still uploads and plays; and a
keepsake spec asserting every <img> in the opened viewer resolves — naturalWidth === 0
is exactly the broken-tile case, whatever produced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
4.1 KiB
TypeScript
85 lines
4.1 KiB
TypeScript
/**
|
||
* Covers the HTML export's VIDEO path (perf/stability fix P4). The export used to
|
||
* copy every original — including full-size videos — into a temp dir and then re-read
|
||
* them into the ZIP (transient 2× disk). It now streams video originals straight from
|
||
* disk into the archive. The image-only export specs never exercised that branch, so
|
||
* this drives a real video upload → export → and proves the video entry lands in
|
||
* Memories.zip (i.e. the streamed-from-original path works and the video isn't dropped).
|
||
*
|
||
* NOTE ON A PREVIOUS VERSION OF THIS COMMENT. It used to read: "The fixture clip is <1s, so ffmpeg
|
||
* extracts no thumbnail frame — but exits 0, so the compression worker keeps the upload. That's the
|
||
* intended shape here." None of that was intended. `-ss` sat AFTER `-i` (an output-side seek), so
|
||
* against `sample.mp4` — which is exactly 1.000 s — ffmpeg exited 0 having written nothing, and
|
||
* both the worker and the export gated on the exit status. The missing thumbnail was observed here
|
||
* and written down as expected behaviour instead of investigated; every video test in the suite ran
|
||
* against that one boundary fixture, and none of them ever fetched the poster.
|
||
*
|
||
* The seek is now input-side with a 0 s fallback and the artifact is verified rather than the exit
|
||
* code, so this clip DOES get a thumbnail. The assertion at the bottom pins that.
|
||
*/
|
||
import { test, expect } from '../../fixtures/test';
|
||
import { uploadRaw } from '../../helpers/upload-client';
|
||
import { readFileSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { BASE } from '../../helpers/env';
|
||
|
||
test.describe('Export — video streaming (P4)', () => {
|
||
test('a real video upload is streamed into Memories.zip (not dropped)', async ({ host }) => {
|
||
test.setTimeout(60_000);
|
||
const bearer = { Authorization: `Bearer ${host.jwt}` };
|
||
|
||
// Seed a real video upload owned by the host.
|
||
const mp4 = readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.mp4'));
|
||
const up = await uploadRaw(host.jwt, mp4, { filename: 'clip.mp4', contentType: 'video/mp4' });
|
||
expect(up.status).toBe(201);
|
||
const { id } = await up.json();
|
||
|
||
// Release the gallery → spawns the real zip + html export jobs.
|
||
const rel = await fetch(`${BASE}/api/v1/host/gallery/release`, {
|
||
method: 'POST',
|
||
headers: bearer,
|
||
});
|
||
expect(rel.status).toBe(204);
|
||
|
||
// Wait for the HTML (Memories.zip) job — that's the one whose media staging P4 changed.
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
|
||
return (await res.json()).html?.status;
|
||
},
|
||
{ timeout: 45_000, intervals: [500] }
|
||
)
|
||
.toBe('done');
|
||
|
||
// Download Memories.zip via the gated single-use ticket.
|
||
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
|
||
method: 'POST',
|
||
headers: bearer,
|
||
});
|
||
const { ticket } = await ticketRes.json();
|
||
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
|
||
expect(dl.status).toBe(200);
|
||
|
||
const bytes = new Uint8Array(await dl.arrayBuffer());
|
||
// Valid ZIP (PK local-file-header magic).
|
||
expect(Array.from(bytes.subarray(0, 2))).toEqual([0x50, 0x4b]);
|
||
|
||
// ZIP entry names are stored verbatim (uncompressed) in the archive, so the video's
|
||
// media entry name appears as plaintext bytes. Its presence means the streamed
|
||
// video entry was written; if the stream had failed, entry.close() would have
|
||
// errored and the job would never have reached 'done'.
|
||
const needle = `media/${id}.mp4`;
|
||
const haystack = new TextDecoder('latin1').decode(bytes);
|
||
expect(haystack.includes(needle), `Memories.zip must contain ${needle}`).toBe(true);
|
||
|
||
// And its poster is really in the archive. This clip is 1.000 s — the exact case the old
|
||
// output-side seek produced nothing for, silently, while `data.json` still advertised the
|
||
// entry. See the note at the top of this file.
|
||
expect(
|
||
haystack.includes(`media/${id}_thumb.jpg`),
|
||
`Memories.zip must contain the poster for ${id}, not just reference it`
|
||
).toBe(true);
|
||
});
|
||
});
|