Cache audio waveforms and rescue oversized GIF previews
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user