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;
|
||||
|
||||
Reference in New Issue
Block a user