/** * 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); }); });