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
+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. */
export async function gifPoster(
inputPath: string,
+35
View File
@@ -12,6 +12,7 @@ import {
gifPoster,
previewGapMs,
probeDimensions,
remoteFrame,
videoPoster,
type DownloadOptions
} from "./download.js";
@@ -387,6 +388,24 @@ export class ScrapeEngine {
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;
// resolve one from the provider or the page before caching it.
let previewRow = row;
@@ -429,6 +448,22 @@ export class ScrapeEngine {
mkdirSync(dirname(target), { recursive: true });
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 {
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[] {
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
? `(${local} OR (source = 'embed' AND preview_url IS NOT NULL))`
: local;