Merge branch 'fix/video-thumbnail-seek'
Some checks failed
Audit / cargo audit (backend) (push) Failing after 13m24s
Audit / npm audit (frontend) (push) Successful in 54s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 45s
Checks / Frontend — vitest + svelte-check (push) Failing after 6m31s
Checks / E2E — typecheck + lint (push) Failing after 47s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m31s
E2E / Cross-UA smoke matrix (push) Failing after 5m36s
Some checks failed
Audit / cargo audit (backend) (push) Failing after 13m24s
Audit / npm audit (frontend) (push) Successful in 54s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 45s
Checks / Frontend — vitest + svelte-check (push) Failing after 6m31s
Checks / E2E — typecheck + lint (push) Failing after 47s
E2E / Playwright E2E (chromium + webkit) (push) Failing after 9m31s
E2E / Cross-UA smoke matrix (push) Failing after 5m36s
This commit is contained in:
@@ -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}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
140
backend/src/services/video.rs
Normal file
140
backend/src/services/video.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
84
e2e/specs/03-feed/video-poster.spec.ts
Normal file
84
e2e/specs/03-feed/video-poster.spec.ts
Normal 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/');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
113
e2e/specs/06-export/viewer-no-broken-tiles.spec.ts
Normal file
113
e2e/specs/06-export/viewer-no-broken-tiles.spec.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user