diff --git a/backend/src/services/compression.rs b/backend/src/services/compression.rs index eba9f43..529bdc1 100644 --- a/backend/src/services/compression.rs +++ b/backend/src/services/compression.rs @@ -172,9 +172,26 @@ impl CompressionWorker { Upload::set_derivatives_rev(&self.pool, upload_id, Self::DERIVATIVES_REV).await?; tracing::info!("preview + display generated for upload {upload_id}"); } else if mime_type.starts_with("video/") { - let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?; - Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?; - tracing::info!("thumbnail generated for upload {upload_id}"); + // A missing poster must NOT fail the upload. `set_thumbnail_path` is only reached when + // a file really exists, so `thumbnail_path` stays NULL otherwise — which every consumer + // already handles (FeedListCard, VirtualFeed, LightboxModal are all null-safe). + // + // The `?` here used to hide the defect; making the check strict without also making + // this non-fatal would have been far worse than the bug. Every clip of a second or less + // would fail compression, exhaust its retries and be soft-deleted — a cosmetic defect + // turned into data loss, on exactly the mis-tap/Live-Photo clips guests produce most. + match self.generate_video_thumbnail(upload_id, &original).await? { + Some(thumb_rel) => { + Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?; + tracing::info!("thumbnail generated for upload {upload_id}"); + } + None => { + tracing::warn!( + %upload_id, + "no poster frame could be extracted; the video keeps its own tile" + ); + } + } } Upload::set_compression_status(&self.pool, upload_id, "done").await?; @@ -325,55 +342,23 @@ impl CompressionWorker { } } - async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result { + /// Extract the feed poster for a video. `Ok(None)` when the clip yields no frame — see + /// [`crate::services::video::extract_poster_frame`], which owns the seek order, the timeout and + /// the artifact check that this function used to be missing. + async fn generate_video_thumbnail( + &self, + upload_id: Uuid, + original: &Path, + ) -> Result> { let thumbs_dir = self.media_path.join("thumbnails"); tokio::fs::create_dir_all(&thumbs_dir).await?; let thumb_filename = format!("{upload_id}.jpg"); let thumb_path = thumbs_dir.join(&thumb_filename); - // Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a - // cap, the held compression-worker semaphore permit is never released and the - // pool eventually deadlocks (no further uploads ever processed). 120s is well - // above the time to extract one frame from any sane input. - let mut child = tokio::process::Command::new("ffmpeg") - .args([ - "-i", - original.to_str().unwrap_or_default(), - "-vframes", - "1", - "-ss", - "00:00:01", - "-vf", - "scale=800:-1", - "-y", - thumb_path.to_str().unwrap_or_default(), - ]) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn() - .context("failed to spawn ffmpeg")?; + let produced = + crate::services::video::extract_poster_frame(original, &thumb_path, 800).await?; - let status = - match tokio::time::timeout(std::time::Duration::from_secs(120), child.wait()).await { - Ok(res) => res.context("ffmpeg wait failed")?, - Err(_) => { - let _ = child.kill().await; - anyhow::bail!("ffmpeg timeout after 120s"); - } - }; - - if !status.success() { - // Best-effort: drain stderr for the log. - let mut stderr = Vec::new(); - if let Some(mut handle) = child.stderr.take() { - use tokio::io::AsyncReadExt; - let _ = handle.read_to_end(&mut stderr).await; - } - anyhow::bail!("ffmpeg failed: {}", String::from_utf8_lossy(&stderr)); - } - - Ok(format!("thumbnails/{thumb_filename}")) + Ok(produced.then(|| format!("thumbnails/{thumb_filename}"))) } } diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs index d23423b..9448d75 100644 --- a/backend/src/services/export.rs +++ b/backend/src/services/export.rs @@ -785,38 +785,32 @@ async fn run_html_export_inner( let full_ext = ext_from_path(&row.original_path); let full = format!("{id_str}.{full_ext}"); - // Video thumbnail via ffmpeg + // Poster frame via the shared helper, which owns the seek order, the 120s timeout + // (this call site had NONE — a hung ffmpeg would strand the export at `running` + // forever) and the artifact check. let thumb_path = media_tmp.join(&thumb); - let ffmpeg_result = tokio::process::Command::new("ffmpeg") - .args([ - "-i", - src.to_str().unwrap_or_default(), - "-vframes", - "1", - "-ss", - "00:00:01", - "-vf", - "scale=400:-1", - "-y", - thumb_path.to_str().unwrap_or_default(), - ]) - .output() - .await; - - match ffmpeg_result { - Ok(output) if output.status.success() => {} - _ => { - tracing::warn!( - "ffmpeg thumbnail failed for upload {}, skipping thumb", - row.id - ); - // Missing thumb entry — viewer handles missing thumbs gracefully. - } + let produced = + match crate::services::video::extract_poster_frame(&src, &thumb_path, 400).await { + Ok(produced) => produced, + Err(e) => { + tracing::warn!("poster extraction errored for upload {}: {e:#}", row.id); + false + } + }; + if !produced { + tracing::info!( + upload_id = %row.id, + "no poster frame for this video; exporting it without one" + ); } // Stream the video full-res straight from the original at ZIP time — no // copy to temp (that used to transiently double disk usage per video). - (thumb, full, MediaSource::Original(src.clone())) + ( + produced.then(|| thumb.clone()), + full, + MediaSource::Original(src.clone()), + ) } else { let thumb = format!("{id_str}_thumb.jpg"); let ext = ext_from_path(&row.original_path); @@ -842,9 +836,17 @@ async fn run_html_export_inner( }) .await?; - if let Err(e) = thumb_result { - tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id); - } + // Same dangling-reference hazard as the video branch: a failure here left `thumb` + // pointing at a file the ZIP writer would then skip, so `data.json` advertised an + // entry the archive didn't contain. An undecodable image is rarer than a sub-second + // clip, but the broken tile is identical. + let thumb_ok = match thumb_result { + Ok(()) => true, + Err(e) => { + tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id); + false + } + }; // Full variant: compress to temp if >5MB, otherwise stream the original // as-is (no temp copy). `src_meta` was stat'd once at the top of the loop. @@ -882,15 +884,16 @@ async fn run_html_export_inner( MediaSource::Original(src.clone()) }; - (thumb, full, full_source) + (thumb_ok.then_some(thumb), full, full_source) }; - // Register this post's two media entries. Thumbnails always come from temp - // (they're freshly generated); the full variant's source was decided above. - media_manifest.push(( - thumb_name.clone(), - MediaSource::Temp(media_tmp.join(&thumb_name)), - )); + // Register this post's media entries. The thumbnail is registered ONLY when one was + // actually produced: pushing a manifest entry for a file that doesn't exist made the ZIP + // writer skip it silently while `data.json` still advertised it — the viewer then drew a + // broken image tile for an entry the archive never contained. + if let Some(name) = &thumb_name { + media_manifest.push((name.clone(), MediaSource::Temp(media_tmp.join(name)))); + } media_manifest.push((full_name.clone(), full_source)); // Build comments for this upload @@ -925,7 +928,15 @@ async fn run_html_export_inner( } else { "image".to_string() }, - thumb: format!("media/{thumb_name}"), + // Empty when there is no poster. The viewer already guards on this + // (`{#if post.media.thumb}` → a video tile with a play glyph, or the placeholder + // icon for an image), so telling it the truth is the entire fix — no schema + // change, no viewer rebuild. What was broken was the backend always claiming a + // thumbnail existed. + thumb: thumb_name + .as_ref() + .map(|n| format!("media/{n}")) + .unwrap_or_default(), full: format!("media/{full_name}"), }, }); diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs index 8f6a126..dd753a3 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -6,3 +6,4 @@ pub mod imaging; pub mod maintenance; pub mod rate_limiter; pub mod sse_tickets; +pub mod video; diff --git a/backend/src/services/video.rs b/backend/src/services/video.rs new file mode 100644 index 0000000..d22ee22 --- /dev/null +++ b/backend/src/services/video.rs @@ -0,0 +1,140 @@ +//! Poster-frame extraction, shared by the compression worker and the HTML export. +//! +//! Both used to spawn `ffmpeg` themselves with the same broken invocation: +//! +//! ```text +//! ffmpeg -i -vframes 1 -ss 00:00:01 -vf scale=… -y +//! ``` +//! +//! `-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, so neither noticed. The worker +//! then wrote `thumbnail_path` for a file that was never created (404 in the live feed) and the +//! export listed the entry in `data.json` while the ZIP writer skipped it (a broken image tile in +//! the keepsake). Every server-side signal stayed green. Phones produce such clips constantly: +//! mis-taps, Live Photos, boomerangs. +//! +//! This module exists for the same reason `imaging.rs` does — that one was created when compression +//! and export duplicated decode logic, and it paid off immediately when the `max_alloc` fix landed +//! in both workers at once. Same duplication, same fix. + +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result}; + +/// A malformed video can hang `ffmpeg` indefinitely. In the compression worker that never releases +/// the semaphore permit and the pool eventually deadlocks; in the export worker it strands the job +/// at `running` so the keepsake never completes. `export.rs` had NO timeout at all before this +/// module — sharing the spawn fixes that too. +const FFMPEG_TIMEOUT: Duration = Duration::from_secs(120); + +/// Seek positions to try, in order. +/// +/// One second first: the opening frame of a real video is often black, a fade-in, or motion-blurred +/// as the camera settles, so it makes a poor poster. Zero second as the fallback, which is what +/// makes short clips work — and it is genuinely required, not defensive. Moving `-ss` before `-i` +/// (an input-side seek) is necessary but NOT sufficient: seeking to 1 s in a 1.000 s clip is still +/// past the last frame, and ffmpeg still exits 0 having written nothing. Verified against the real +/// production image. +const SEEK_POSITIONS: &[&str] = &["00:00:01", "0"]; + +/// Extract one poster frame from `src` into `dest`, scaled to `width` px wide. +/// +/// `Ok(false)` means the video yielded no frame — a normal outcome for a very short or unusual +/// clip, NOT an error. Callers must degrade (no poster) rather than fail the upload: treating this +/// as an error would soft-delete every sub-second video, turning a cosmetic defect into data loss. +/// +/// `Err` is reserved for something genuinely wrong — a hang we had to kill, or a failure to spawn. +pub async fn extract_poster_frame(src: &Path, dest: &Path, width: u32) -> Result { + for seek in SEEK_POSITIONS { + // A stale file from a previous attempt would be indistinguishable from a fresh success. + let _ = tokio::fs::remove_file(dest).await; + + run_ffmpeg(src, dest, width, seek).await?; + + // THE CHECK BOTH CALL SITES WERE MISSING: ask the filesystem, not the exit status. + // Non-empty, because a zero-byte file is not a poster either. + if tokio::fs::metadata(dest) + .await + .map(|m| m.is_file() && m.len() > 0) + .unwrap_or(false) + { + return Ok(true); + } + } + + // Leave nothing behind for a caller to mistake for a result. + let _ = tokio::fs::remove_file(dest).await; + Ok(false) +} + +/// Run one ffmpeg attempt. A non-zero exit is NOT an error here — the artifact check above is the +/// authority, and a corrupt input that fails at 1 s may still yield a frame at 0. +async fn run_ffmpeg(src: &Path, dest: &Path, width: u32, seek: &str) -> Result<()> { + let mut child = tokio::process::Command::new("ffmpeg") + .args([ + // BEFORE -i: an input-side seek. See SEEK_POSITIONS. + "-ss", + seek, + "-i", + src.to_str().unwrap_or_default(), + "-vframes", + "1", + "-vf", + &format!("scale={width}:-1"), + "-y", + dest.to_str().unwrap_or_default(), + ]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .context("failed to spawn ffmpeg")?; + + match tokio::time::timeout(FFMPEG_TIMEOUT, child.wait()).await { + Ok(res) => { + res.context("ffmpeg wait failed")?; + } + Err(_) => { + let _ = child.kill().await; + anyhow::bail!("ffmpeg timed out after {}s", FFMPEG_TIMEOUT.as_secs()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The order is the whole fix. `-ss` must precede `-i`, and 0 must be tried after 1 s. + #[test] + fn the_fallback_seek_exists_and_comes_last() { + assert_eq!( + SEEK_POSITIONS, + &["00:00:01", "0"], + "1s first for a better poster, 0 as the fallback that makes short clips work" + ); + } + + /// A missing input yields no frame rather than an error: the caller must degrade to "no + /// poster", never fail the upload. `Err` is reserved for a hang or a spawn failure. + #[tokio::test] + async fn a_missing_source_yields_no_frame_rather_than_an_error() { + let dir = std::env::temp_dir().join(format!("es-video-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dest = dir.join("out.jpg"); + + let got = extract_poster_frame(Path::new("/nonexistent/clip.mp4"), &dest, 400).await; + + match got { + Ok(false) => {} + other => panic!("expected Ok(false) for a missing input, got {other:?}"), + } + assert!( + !dest.exists(), + "a failed extraction must leave nothing a caller could mistake for a poster" + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/e2e/specs/03-feed/video-playback.spec.ts b/e2e/specs/03-feed/video-playback.spec.ts index 1fb8bc4..10f9234 100644 --- a/e2e/specs/03-feed/video-playback.spec.ts +++ b/e2e/specs/03-feed/video-playback.spec.ts @@ -69,6 +69,17 @@ test.describe('Video — the lightbox plays it', () => { // 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) => { diff --git a/e2e/specs/03-feed/video-poster.spec.ts b/e2e/specs/03-feed/video-poster.spec.ts new file mode 100644 index 0000000..c8832ca --- /dev/null +++ b/e2e/specs/03-feed/video-poster.spec.ts @@ -0,0 +1,84 @@ +/** + * Regression guard — a short video gets a real poster frame, and the keepsake never shows a broken + * tile. + * + * Both the compression worker and the HTML export ran the same invocation: + * + * ffmpeg -i -vframes 1 -ss 00:00:01 -vf scale=… -y + * + * `-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. So: + * + * - the worker wrote `thumbnail_path` and logged "thumbnail generated" for a file that was never + * created → `GET /upload/{id}/thumbnail` 404s in the live feed; + * - the export listed `media/…_thumb.jpg` in `data.json` while the ZIP writer skipped the + * unopenable file → the keepsake rendered a broken image tile. + * + * Any clip at or under a second, which phones produce constantly: mis-taps, Live Photos, boomerangs. + * Every server-side signal stayed green throughout. + * + * Two fixtures on purpose, because they take different paths through the fix: + * - `sample.mp4` is exactly 1.000 s. An input-side seek to 1 s is STILL past its last frame, so it + * is the 0 s fallback that saves it. Moving `-ss` before `-i` alone does not fix this file. + * - `sample-5s.mp4` is 5 s and succeeds on the first seek — the normal path, which no test covered + * at all before, because the suite only ever had the boundary fixture. + */ +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'; + +const CLIPS = [ + { file: 'sample.mp4', label: '1.000s — needs the 0s fallback' }, + { file: 'sample-5s.mp4', label: '5s — succeeds on the first seek' }, +]; + +async function uploadClip(jwt: string, file: string): Promise { + const bytes = readFileSync(join(process.cwd(), 'fixtures', 'media', file)); + const res = await uploadRaw(jwt, bytes, { filename: file, contentType: 'video/mp4' }); + expect(res.status, `uploading ${file}`).toBe(201); + return ((await res.json()) as { id: string }).id; +} + +test.describe('Video — the poster frame is real', () => { + for (const { file, label } of CLIPS) { + test(`${file} (${label}) gets a fetchable poster`, async ({ guest, db }) => { + test.setTimeout(60_000); + const g = await guest(`Poster${file.replace(/\W/g, '')}`); + const id = await uploadClip(g.jwt, file); + await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done'); + + // The DB must not claim a thumbnail that isn't there — that claim IS the defect. + const res = await fetch(`${BASE}/api/v1/upload/${id}/thumbnail`, { + headers: { Authorization: `Bearer ${g.jwt}` }, + }); + expect(res.status, `${file}: the poster must exist, not just be recorded`).toBe(200); + expect(res.headers.get('content-type')).toContain('image/'); + expect((await res.arrayBuffer()).byteLength).toBeGreaterThan(0); + }); + } + + test('a video upload still succeeds even if no poster can be extracted', async ({ + guest, + db, + }) => { + // The mirror that keeps the fix honest. Tightening the check to "the file must exist" without + // also making a missing poster non-fatal would have been far worse than the bug: the worker's + // call used `?`, so every sub-second clip would fail compression, exhaust its retries and be + // soft-deleted. A cosmetic defect turned into data loss. + // + // `compression_status = 'done'` with the upload still present is exactly that guarantee. + const g = await guest('PosterSurvivor'); + const id = await uploadClip(g.jwt, 'sample.mp4'); + await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done'); + expect(await db.countUploadsForUser(g.userId)).toBe(1); + + // And the video itself is playable regardless of the poster. + const orig = await fetch(`${BASE}/api/v1/upload/${id}/original`, { + headers: { Authorization: `Bearer ${g.jwt}` }, + }); + expect(orig.status).toBe(200); + expect(orig.headers.get('content-type')).toContain('video/'); + }); +}); diff --git a/e2e/specs/06-export/export-video.spec.ts b/e2e/specs/06-export/export-video.spec.ts index ca406b1..a7b9412 100644 --- a/e2e/specs/06-export/export-video.spec.ts +++ b/e2e/specs/06-export/export-video.spec.ts @@ -6,9 +6,16 @@ * 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). * - * The fixture clip is <1s, so ffmpeg extracts no thumbnail frame — but exits 0, so the - * compression worker keeps the upload (it isn't auto-cleaned). That's the intended - * shape here: the full video is exported even when its thumbnail is absent. + * 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'; @@ -65,5 +72,13 @@ test.describe('Export — video streaming (P4)', () => { 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); }); }); diff --git a/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts b/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts new file mode 100644 index 0000000..72da213 --- /dev/null +++ b/e2e/specs/06-export/viewer-no-broken-tiles.spec.ts @@ -0,0 +1,113 @@ +/** + * Regression guard — the keepsake never renders a broken image tile. + * + * The HTML export wrote `thumb: "media/_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 ``. + * + * 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 }); + } + }); +});