Files
EventSnap/e2e/specs/03-feed/video-playback.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

193 lines
8.8 KiB
TypeScript

/**
* Regression guard — videos must actually play.
*
* Two independent defects made every video unplayable, and nothing in the suite covered
* either one (no test anywhere played media or asserted a `<video>` src):
*
* 1. The lightbox fed `<video>` the URL from `pickMediaUrl`, which is mime-agnostic.
* Compression only ever produces a *thumbnail* for a video — one ffmpeg frame — so in
* the DEFAULT saver mode the element's src was `/api/v1/upload/{id}/thumbnail`: a JPEG,
* served as image/jpeg with nosniff so the browser can't even sniff its way out.
* Chromium reported DEMUXER_ERROR_COULD_NOT_OPEN.
*
* 2. `stream_media_file` ignored `Range` entirely — always 200 with the whole body, never
* an Accept-Ranges or Content-Range. iOS Safari opens every `<video>` with a
* `Range: bytes=0-1` probe and abandons the load without a 206, so video failed on the
* app's primary platform even in `original` data mode.
*
* Fixing either alone still leaves video broken, so both are asserted here.
*/
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { BASE } from '../../helpers/env';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const SAMPLE_MP4 = join(process.cwd(), 'fixtures', 'media', 'sample.mp4');
async function seedVideo(jwt: string): Promise<string> {
const res = await uploadRaw(jwt, readFileSync(SAMPLE_MP4), {
filename: 'clip.mp4',
contentType: 'video/mp4',
caption: 'ein Video',
});
if (res.status !== 201) throw new Error(`video seed failed: ${res.status}`);
return ((await res.json()) as { id: string }).id;
}
test.describe('Video — the lightbox plays it', () => {
test('the <video> src is the original, not the thumbnail JPEG', async ({
page,
guest,
signIn,
db,
}) => {
const g = await guest('VideoWatcher');
const id = await seedVideo(g.jwt);
// The poster assertion below needs the ffmpeg thumbnail to EXIST — the lightbox binds
// `poster={upload.thumbnail_url ?? undefined}`, so the attribute is simply absent until
// compression finishes. Without this wait the test races the worker and fails against a
// cold stack (first run after `stack:down -v`, cold ffmpeg), which is exactly when a suite
// is least likely to be believed. The `src` assertion is unconditional; only the poster
// needs the wait.
await expect.poll(() => db.compressionStatus(id), { timeout: 60_000 }).toBe('done');
await signIn(page, g);
await page.goto('/feed');
const card = page.locator('article').first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
const video = page.locator('video');
await expect(video).toBeVisible({ timeout: 10_000 });
// The bug in one assertion: this was `/thumbnail` in the default data mode.
await expect(video).toHaveAttribute('src', `/api/v1/upload/${id}/original`);
// The poster SHOULD still be the thumbnail — that's what it's for.
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);
// …and it must actually RESOLVE. Asserting only the attribute is what let a phantom thumbnail
// survive nine rounds of green: `thumbnail_path` was written for a file ffmpeg never created,
// so this URL 404'd for every clip of a second or less while the attribute looked perfect.
// One extra fetch is the whole difference.
const poster = await fetch(`${BASE}/api/v1/upload/${id}/thumbnail`, {
headers: { Authorization: `Bearer ${g.jwt}` },
});
expect(poster.status, 'the poster URL must serve real bytes, not just exist').toBe(200);
expect(poster.headers.get('content-type')).toContain('image/');
expect((await poster.arrayBuffer()).byteLength).toBeGreaterThan(0);
// And the browser must accept the bytes as media. preload="none" means nothing is
// fetched until we ask, so drive a load explicitly and wait for metadata.
const readyState = await video.evaluate(async (el: HTMLVideoElement) => {
el.preload = 'metadata';
el.load();
await new Promise<void>((resolve) => {
if (el.readyState > 0) return resolve();
el.addEventListener('loadedmetadata', () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
setTimeout(resolve, 15_000);
});
return { ready: el.readyState, err: el.error?.message ?? null };
});
expect(readyState.err, `the browser rejected the media: ${readyState.err}`).toBeNull();
expect(readyState.ready, 'video metadata must load').toBeGreaterThan(0);
});
test('the video body is not downloaded before the user presses play', async ({
page,
guest,
signIn,
}) => {
// saver mode exists to protect a guest's mobile data, and there is no smaller video
// derivative to fall back to — so opening the lightbox must not pull the file down.
//
// Asserting "no request at all" would be wrong: WebKit opens a connection for a
// preload="none" <video> and immediately ABORTS it (observed: GET with no Range,
// response status 0, nothing transferred), whereas Chromium issues nothing. The
// portable guarantee — and the one that actually protects the data plan — is that no
// response carrying the body ever completes. Dropping preload="none" fails this.
const g = await guest('VideoThrifty');
const id = await seedVideo(g.jwt);
await signIn(page, g);
const delivered: string[] = [];
page.on('response', (r) => {
if (!r.url().includes(`/upload/${id}/original`)) return;
// status 0 = aborted before any bytes landed.
if (r.status() === 200 || r.status() === 206) {
delivered.push(`${r.status()} len=${r.headers()['content-length'] ?? '?'}`);
}
});
await page.goto('/feed');
const card = page.locator('article').first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.getByRole('button', { name: 'Bild vergrößern' }).click();
await expect(page.locator('video')).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(2000);
expect(delivered, 'no video bytes may be delivered before play').toEqual([]);
});
});
test.describe('Media — HTTP Range', () => {
test('a range request is answered 206 with the right slice', async ({ guest }) => {
const g = await guest('RangeReader');
const id = await seedVideo(g.jwt);
const url = `${BASE}/api/v1/upload/${id}/original`;
const full = await fetch(url);
expect(full.status).toBe(200);
expect(full.headers.get('accept-ranges'), 'clients must be told seeking works').toBe('bytes');
const total = Number(full.headers.get('content-length'));
expect(total).toBeGreaterThan(0);
// The exact probe iOS Safari opens a <video> with. Two bytes, inclusive.
const probe = await fetch(url, { headers: { Range: 'bytes=0-1' } });
expect(probe.status, 'iOS abandons the load without a 206').toBe(206);
expect(probe.headers.get('content-range')).toBe(`bytes 0-1/${total}`);
expect(Number(probe.headers.get('content-length'))).toBe(2);
expect((await probe.arrayBuffer()).byteLength).toBe(2);
// A mid-file seek must return the matching slice, not the whole body.
const mid = await fetch(url, { headers: { Range: 'bytes=10-19' } });
expect(mid.status).toBe(206);
expect(mid.headers.get('content-range')).toBe(`bytes 10-19/${total}`);
const midBytes = Buffer.from(await mid.arrayBuffer());
expect(midBytes.byteLength).toBe(10);
expect(midBytes).toEqual(Buffer.from(await full.arrayBuffer()).subarray(10, 20));
// Open-ended range runs to EOF.
const tail = await fetch(url, { headers: { Range: `bytes=${total - 5}-` } });
expect(tail.status).toBe(206);
expect(Number(tail.headers.get('content-length'))).toBe(5);
// Past EOF must be 416 — answering 200 makes a player re-request forever.
const bad = await fetch(url, { headers: { Range: `bytes=${total + 10}-` } });
expect(bad.status).toBe(416);
expect(bad.headers.get('content-range')).toBe(`bytes */${total}`);
});
test('image derivatives are range-capable too', async ({ guest, db }) => {
// Same helper serves all four media routes, so the guarantee is uniform.
const g = await guest('RangeImages');
const res = await uploadRaw(
g.jwt,
readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
{ filename: 'r.jpg', contentType: 'image/jpeg' }
);
const { id } = (await res.json()) as { id: string };
await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');
const preview = await fetch(`${BASE}/api/v1/upload/${id}/preview`, {
headers: { Range: 'bytes=0-9' },
});
expect(preview.status).toBe(206);
expect(Number(preview.headers.get('content-length'))).toBe(10);
});
});