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>
This commit is contained in:
fabi
2026-07-30 19:40:27 +02:00
parent 2b313e67e0
commit 402215d405
8 changed files with 447 additions and 87 deletions

View File

@@ -172,10 +172,27 @@ impl CompressionWorker {
Upload::set_derivatives_rev(&self.pool, upload_id, Self::DERIVATIVES_REV).await?; Upload::set_derivatives_rev(&self.pool, upload_id, Self::DERIVATIVES_REV).await?;
tracing::info!("preview + display generated for upload {upload_id}"); tracing::info!("preview + display generated for upload {upload_id}");
} else if mime_type.starts_with("video/") { } else if mime_type.starts_with("video/") {
let thumb_rel = self.generate_video_thumbnail(upload_id, &original).await?; // 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?; Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
tracing::info!("thumbnail generated for upload {upload_id}"); 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?; Upload::set_compression_status(&self.pool, upload_id, "done").await?;
Ok(()) Ok(())
@@ -325,55 +342,23 @@ impl CompressionWorker {
} }
} }
async fn generate_video_thumbnail(&self, upload_id: Uuid, original: &Path) -> Result<String> { /// 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<Option<String>> {
let thumbs_dir = self.media_path.join("thumbnails"); let thumbs_dir = self.media_path.join("thumbnails");
tokio::fs::create_dir_all(&thumbs_dir).await?; tokio::fs::create_dir_all(&thumbs_dir).await?;
let thumb_filename = format!("{upload_id}.jpg"); let thumb_filename = format!("{upload_id}.jpg");
let thumb_path = thumbs_dir.join(&thumb_filename); let thumb_path = thumbs_dir.join(&thumb_filename);
// Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a let produced =
// cap, the held compression-worker semaphore permit is never released and the crate::services::video::extract_poster_frame(original, &thumb_path, 800).await?;
// 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 status = Ok(produced.then(|| format!("thumbnails/{thumb_filename}")))
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}"))
} }
} }

View File

@@ -785,38 +785,32 @@ async fn run_html_export_inner(
let full_ext = ext_from_path(&row.original_path); let full_ext = ext_from_path(&row.original_path);
let full = format!("{id_str}.{full_ext}"); 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 thumb_path = media_tmp.join(&thumb);
let ffmpeg_result = tokio::process::Command::new("ffmpeg") let produced =
.args([ match crate::services::video::extract_poster_frame(&src, &thumb_path, 400).await {
"-i", Ok(produced) => produced,
src.to_str().unwrap_or_default(), Err(e) => {
"-vframes", tracing::warn!("poster extraction errored for upload {}: {e:#}", row.id);
"1", false
"-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.
} }
};
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 // 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). // 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 { } else {
let thumb = format!("{id_str}_thumb.jpg"); let thumb = format!("{id_str}_thumb.jpg");
let ext = ext_from_path(&row.original_path); let ext = ext_from_path(&row.original_path);
@@ -842,9 +836,17 @@ async fn run_html_export_inner(
}) })
.await?; .await?;
if let Err(e) = thumb_result { // 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); tracing::warn!("thumbnail generation failed for upload {}: {e:#}", row.id);
false
} }
};
// Full variant: compress to temp if >5MB, otherwise stream the original // 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. // 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()) 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 // Register this post's media entries. The thumbnail is registered ONLY when one was
// (they're freshly generated); the full variant's source was decided above. // actually produced: pushing a manifest entry for a file that doesn't exist made the ZIP
media_manifest.push(( // writer skip it silently while `data.json` still advertised it — the viewer then drew a
thumb_name.clone(), // broken image tile for an entry the archive never contained.
MediaSource::Temp(media_tmp.join(&thumb_name)), 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)); media_manifest.push((full_name.clone(), full_source));
// Build comments for this upload // Build comments for this upload
@@ -925,7 +928,15 @@ async fn run_html_export_inner(
} else { } else {
"image".to_string() "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}"), full: format!("media/{full_name}"),
}, },
}); });

View File

@@ -6,3 +6,4 @@ pub mod imaging;
pub mod maintenance; pub mod maintenance;
pub mod rate_limiter; pub mod rate_limiter;
pub mod sse_tickets; pub mod sse_tickets;
pub mod video;

View File

@@ -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 <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, 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<bool> {
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);
}
}

View File

@@ -69,6 +69,17 @@ test.describe('Video — the lightbox plays it', () => {
// The poster SHOULD still be the thumbnail — that's what it's for. // The poster SHOULD still be the thumbnail — that's what it's for.
await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`); 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 // 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. // fetched until we ask, so drive a load explicitly and wait for metadata.
const readyState = await video.evaluate(async (el: HTMLVideoElement) => { const readyState = await video.evaluate(async (el: HTMLVideoElement) => {

View File

@@ -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 <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. 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<string> {
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/');
});
});

View File

@@ -6,9 +6,16 @@
* this drives a real video upload → export → and proves the video entry lands in * 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). * 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 * NOTE ON A PREVIOUS VERSION OF THIS COMMENT. It used to read: "The fixture clip is <1s, so ffmpeg
* compression worker keeps the upload (it isn't auto-cleaned). That's the intended * extracts no thumbnail frame — but exits 0, so the compression worker keeps the upload. That's the
* shape here: the full video is exported even when its thumbnail is absent. * 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 { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client'; import { uploadRaw } from '../../helpers/upload-client';
@@ -65,5 +72,13 @@ test.describe('Export — video streaming (P4)', () => {
const needle = `media/${id}.mp4`; const needle = `media/${id}.mp4`;
const haystack = new TextDecoder('latin1').decode(bytes); const haystack = new TextDecoder('latin1').decode(bytes);
expect(haystack.includes(needle), `Memories.zip must contain ${needle}`).toBe(true); 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);
}); });
}); });

View File

@@ -0,0 +1,113 @@
/**
* 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 });
}
});
});