Files
EventSnap/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts
fabi 402215d405 fix(video): extract a real poster frame, and stop claiming one that isn't there
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>
2026-07-30 19:40:27 +02:00

114 lines
5.1 KiB
TypeScript

/**
* Regression guard — the keepsake never renders a broken image tile.
*
* The HTML export wrote `thumb: "media/<id>_thumb.jpg"` into `data.json` unconditionally. When
* ffmpeg produced no poster frame — which it did, silently and with exit 0, for any clip of a
* second or less — the ZIP writer skipped the unopenable file but `data.json` still advertised it.
* The viewer then requested an entry the archive did not contain and drew a broken `<img>`.
*
* The viewer was never the problem: `+page.svelte` already guards `{#if post.media.thumb}` and
* falls back to a proper dark video tile with a play glyph. The guard simply never fired, because
* the backend always handed it a non-empty string. The fix is the backend telling the truth —
* `thumb: ""` when there is no poster — so no viewer change was needed.
*
* This asserts the property that actually matters to a guest and that no server-side signal can
* report: **every image in the opened keepsake resolves**. `naturalWidth > 0` is false for exactly
* the broken-tile case, whatever produced it — a missing video poster, a failed image thumbnail, or
* some future path nobody has thought of yet. It is deliberately not an assertion about ffmpeg.
*
* Runs over `file://`, the way a guest opens it.
*/
import { test, expect } from '../../fixtures/test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { uploadRaw } from '../../helpers/upload-client';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
test.describe('Export — the keepsake has no broken tiles', () => {
test('every image in the opened viewer resolves', async ({ page, host, guest, db }) => {
test.setTimeout(150_000);
const bearer = { Authorization: `Bearer ${host.jwt}` };
const g = await guest('TileChecker');
const ids: string[] = [await seedUpload(g.jwt, { caption: 'ein Foto' })];
// Both clips: the 1.000 s one is the case that produced the broken tile, the 5 s one is the
// ordinary path that had no coverage at all.
for (const file of ['sample.mp4', 'sample-5s.mp4']) {
const bytes = readFileSync(join(process.cwd(), 'fixtures', 'media', file));
const res = await uploadRaw(g.jwt, bytes, { filename: file, contentType: 'video/mp4' });
expect(res.status).toBe(201);
ids.push(((await res.json()) as { id: string }).id);
}
for (const id of ids) {
await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done');
}
expect(
(await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }))
.status
).toBe(204);
await expect
.poll(
async () => {
const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
return (await res.json()).html?.status;
},
{ timeout: 120_000, intervals: [500] }
)
.toBe('done');
const ticketRes = await fetch(`${BASE}/api/v1/export/ticket`, {
method: 'POST',
headers: bearer,
});
const { ticket } = (await ticketRes.json()) as { ticket: string };
const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
expect(dl.status).toBe(200);
const dir = mkdtempSync(join(tmpdir(), 'eventsnap-tiles-'));
try {
const zipPath = join(dir, 'Memories.zip');
writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
execFileSync('unzip', ['-qo', zipPath, '-d', dir]);
await page.goto('file://' + join(dir, 'index.html'));
// Every post is present, whether or not it has a poster.
const posts = await page.evaluate(() => {
const d = (
window as unknown as {
__EXPORT_DATA__?: { posts?: { media?: { thumb?: string; type?: string } }[] };
}
).__EXPORT_DATA__;
return d?.posts?.map((p) => ({ thumb: p.media?.thumb ?? '', type: p.media?.type })) ?? null;
});
expect(posts, '__EXPORT_DATA__ was never assigned').not.toBeNull();
expect(posts!.length).toBe(3);
// Any thumb data.json DOES advertise must be a file the archive actually contains.
const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' }).split('\n');
for (const p of posts!.filter((p) => p.thumb)) {
expect(
entries.includes(p.thumb),
`data.json advertises ${p.thumb} but the archive does not contain it`
).toBe(true);
}
// THE assertion: nothing rendered broken. Give the images a moment to settle first.
await page.waitForLoadState('networkidle');
const broken = await page.evaluate(() =>
Array.from(document.querySelectorAll('img'))
.filter((i) => i.complete && i.naturalWidth === 0)
.map((i) => i.getAttribute('src') ?? '(no src)')
);
expect(broken, `broken image tiles in the keepsake: ${broken.join(', ')}`).toEqual([]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});