Cache audio waveforms and rescue oversized GIF previews
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,7 +9,14 @@ import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
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 { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.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", () => {
|
||||
const id = snowflakeFromIso("2026-09-16T00:00:00.000Z");
|
||||
assert.ok(id);
|
||||
|
||||
Reference in New Issue
Block a user