Cache audio waveforms and rescue oversized GIF previews

This commit is contained in:
Fisto
2026-09-16 23:48:17 +02:00
parent caf9b7db3a
commit 8f30ec14ad
7 changed files with 123 additions and 5 deletions
+6
View File
@@ -40,6 +40,12 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C30 Preview completeness. Audio attachments and voice messages get an ffmpeg waveform
strip; oversized animated GIFs fall back to an ffmpeg-read first frame instead of `too large`;
transient network failures stay pending for the next backfill instead of becoming permanent.
Live: a scrape with 58 audio items backfilled 13 waveforms in 40 s, serving `image/webp`.
Remaining non-cached: Lottie stickers/custom emoji (not indexed as media) and embeds whose
pages refuse bots (marked PRIVATE/GONE by their 403/404).
- [x] C29 Attachment video previews. Discord's image proxy renders a frame for video files - [x] C29 Attachment video previews. Discord's image proxy renders a frame for video files
(`?width=320&height=320&format=webp`), so `previewUrlFor` now resizes attachment mp4/webm in (`?width=320&height=320&format=webp`), so `previewUrlFor` now resizes attachment mp4/webm in
links mode too and `pendingPreviews` includes non-embed videos. Live: paused-scrape videos links mode too and `pendingPreviews` includes non-embed videos. Live: paused-scrape videos
+6 -1
View File
@@ -130,7 +130,12 @@ Previews run after every walk:
10 s timeout, one attempt). 10 s timeout, one attempt).
- Animated GIF previews are stored as a static first frame (`ffmpeg`, 480px WebP); the original - Animated GIF previews are stored as a static first frame (`ffmpeg`, 480px WebP); the original
animated GIF URL stays in `preview_url` for hover playback. Tenor embeds without a thumbnail are animated GIF URL stays in `preview_url` for hover playback. Tenor embeds without a thumbnail are
resolved through Tenor's oEmbed. resolved through Tenor's oEmbed. An animated GIF over the size cap is framed by ffmpeg reading
the stream directly, so it is not lost to `too large`.
- Audio attachments and voice messages get a waveform strip
(`ffmpeg -filter_complex showwavespic`) as their preview, rendered by ffmpeg from the URL.
- Transient network failures are left pending for the next backfill pass; only skips (4xx, too
large, no source) are marked permanent.
- One attempt per row, no retries; a failure marks `preview_error` and never fails the scrape. - One attempt per row, no retries; a failure marks `preview_error` and never fails the scrape.
- Stored by hash in `thumbs/<sha256>.<ext>`, where the extension follows the response type - Stored by hash in `thumbs/<sha256>.<ext>`, where the extension follows the response type
(`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the (`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the
+3 -2
View File
@@ -91,8 +91,9 @@ media-scrape/db/
- Third-party embeds are **never downloaded as media**: YouTube, Instagram, X, TikTok, and every - Third-party embeds are **never downloaded as media**: YouTube, Instagram, X, TikTok, and every
other embed stays a link. Its thumbnail is cached (below); the video itself is never fetched. other embed stays a link. Its thumbnail is cached (below); the video itself is never fetched.
- Previews are cached in `thumbs/` after every walk, in both modes: Discord-hosted images, GIFs, - Previews are cached in `thumbs/` after every walk, in both modes: Discord-hosted images, GIFs,
and video files (the proxy renders a frame, resized to 320px WebP) plus third-party embed and video files (the proxy renders a frame, resized to 320px WebP), third-party embed thumbnails
thumbnails (YouTube, X, TikTok, and so on). The (YouTube, X, TikTok, and so on), and audio, which gets a waveform strip so audio tiles are not
blank. The
stored bytes keep their real type (`preview_ext`), so JPEG provider thumbnails are served as stored bytes keep their real type (`preview_ext`), so JPEG provider thumbnails are served as
`image/jpeg`, not mislabeled WebP. Embeds without a thumbnail in Discord's data are resolved `image/jpeg`, not mislabeled WebP. Embeds without a thumbnail in Discord's data are resolved
through the provider's oEmbed/fxtwitter API and finally the page's `og:image`. Previews count through the provider's oEmbed/fxtwitter API and finally the page's `og:image`. Previews count
+37
View File
@@ -319,6 +319,43 @@ export async function videoPoster(
} }
} }
/**
* A poster frame fetched by ffmpeg itself, without downloading the whole file through us.
* Handles oversized animated GIFs (first frame only) and audio (waveform strip).
*/
export async function remoteFrame(
url: string,
outDir: string,
options: { waveform?: boolean; ffmpeg?: string } = {}
): Promise<{ hash: string; bytes: number } | null> {
const ffmpeg = options.ffmpeg ?? process.env.VESKSLOP_FFMPEG ?? "ffmpeg";
const temp = join(outDir, `.remote-${Date.now()}-${Math.random().toString(16).slice(2)}.webp`);
const filters = options.waveform
? ["-filter_complex", "showwavespic=s=480x120:colors=white"]
: ["-frames:v", "1", "-vf", "scale=480:-1"];
try {
await execFileAsync(ffmpeg, ["-v", "error", "-y", "-i", url, ...filters, temp], {
timeout: 60_000
});
const buffer = readFileSync(temp);
if (!buffer.length) return null;
const hash = createHash("sha256").update(buffer).digest("hex");
renameSync(temp, join(outDir, `${hash}.webp`));
return { hash, bytes: buffer.length };
} catch {
return null;
} finally {
try {
rmSync(temp, { force: true });
} catch {
// already renamed
}
}
}
/** Static first-frame WebP from an animated GIF. Null when ffmpeg is missing or fails. */ /** Static first-frame WebP from an animated GIF. Null when ffmpeg is missing or fails. */
export async function gifPoster( export async function gifPoster(
inputPath: string, inputPath: string,
+35
View File
@@ -12,6 +12,7 @@ import {
gifPoster, gifPoster,
previewGapMs, previewGapMs,
probeDimensions, probeDimensions,
remoteFrame,
videoPoster, videoPoster,
type DownloadOptions type DownloadOptions
} from "./download.js"; } from "./download.js";
@@ -387,6 +388,24 @@ export class ScrapeEngine {
return; return;
} }
// Audio has no image proxy: render a waveform strip with ffmpeg.
if (row.kind === "audio" && row.source !== "embed") {
const waveform = await remoteFrame(row.url, join(dir, "thumbs"), { waveform: true });
if (waveform) {
store.markPreview(row.id, {
hash: waveform.hash,
bytes: waveform.bytes,
ext: "webp"
});
meta.previewed = (meta.previewed ?? 0) + 1;
} else {
store.markPreviewError(row.id, "waveform failed");
}
this.persist(dir, meta);
await this.sleep(previewGapMs(options));
continue;
}
// Some providers (fxtwitter, vxReddit, ...) ship no thumbnail in Discord's data; // Some providers (fxtwitter, vxReddit, ...) ship no thumbnail in Discord's data;
// resolve one from the provider or the page before caching it. // resolve one from the provider or the page before caching it.
let previewRow = row; let previewRow = row;
@@ -429,6 +448,22 @@ export class ScrapeEngine {
mkdirSync(dirname(target), { recursive: true }); mkdirSync(dirname(target), { recursive: true });
copyFileSync(join(dir, "thumbs", `${hash}.${ext}`), target); copyFileSync(join(dir, "thumbs", `${hash}.${ext}`), target);
} }
} else if (
outcome.state === "skipped" &&
outcome.error === "too large" &&
/\.gif(\?|$)/i.test(previewRow.preview_url ?? previewRow.url)
) {
// Oversized animated GIF: let ffmpeg read the stream and keep one frame.
const frame = await remoteFrame(previewRow.preview_url ?? previewRow.url, join(dir, "thumbs"));
if (frame) {
store.markPreview(row.id, { hash: frame.hash, bytes: frame.bytes, ext: "webp" });
meta.previewed = (meta.previewed ?? 0) + 1;
} else {
store.markPreviewError(row.id, "too large");
}
} else if (outcome.state === "failed") {
// Transient network failure: leave the row pending for the next pass.
this.log(`${meta.id}: preview ${row.id} failed: ${outcome.error ?? "unknown"}`);
} else { } else {
store.markPreviewError(row.id, outcome.error ?? outcome.state); store.markPreviewError(row.id, outcome.error ?? outcome.state);
} }
+1 -1
View File
@@ -343,7 +343,7 @@ export class ScrapeStore {
*/ */
pendingPreviews(limit = 5000, includeEmbeds = false): MediaRow[] { pendingPreviews(limit = 5000, includeEmbeds = false): MediaRow[] {
const local = const local =
"((source IS NULL OR source != 'embed') AND kind IN ('image', 'gif', 'video'))"; "((source IS NULL OR source != 'embed') AND kind IN ('image', 'gif', 'video', 'audio'))";
const condition = includeEmbeds const condition = includeEmbeds
? `(${local} OR (source = 'embed' AND preview_url IS NOT NULL))` ? `(${local} OR (source = 'embed' AND preview_url IS NOT NULL))`
: local; : local;
+35 -1
View File
@@ -9,7 +9,14 @@ import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { downloadOne, downloadPreview, gifPoster, previewUrlFor, videoPoster } from "../src/download.js"; import {
downloadOne,
downloadPreview,
gifPoster,
previewUrlFor,
remoteFrame,
videoPoster
} from "../src/download.js";
import { ogImageFromHtml, resolveEmbedPreview } from "../src/embed-preview.js"; import { ogImageFromHtml, resolveEmbedPreview } from "../src/embed-preview.js";
import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js"; import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
import { extractMedia } from "../src/extract.js"; import { extractMedia } from "../src/extract.js";
@@ -954,6 +961,33 @@ void (async () => {
); );
}); });
await test("remoteFrame renders an audio waveform", async () => {
const dir = join(root, "waveform-test");
mkdirSync(dir, { recursive: true });
const wavPath = join(dir, "tone.wav");
try {
execFileSync("ffmpeg", [
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
wavPath
]);
} catch {
console.log("ok - remoteFrame skipped (no ffmpeg)");
return;
}
const frame = await remoteFrame(wavPath, dir, { waveform: true });
assert.ok(frame?.hash);
assert.ok((frame?.bytes ?? 0) > 0);
assert.equal(existsSync(join(dir, `${frame?.hash}.webp`)), true);
});
await test("snowflake helpers round-trip dates", () => { await test("snowflake helpers round-trip dates", () => {
const id = snowflakeFromIso("2026-09-16T00:00:00.000Z"); const id = snowflakeFromIso("2026-09-16T00:00:00.000Z");
assert.ok(id); assert.ok(id);