Add per-kind stats, GIF hover playback, and static gif previews
This commit is contained in:
@@ -312,6 +312,38 @@ export async function videoPoster(
|
||||
}
|
||||
}
|
||||
|
||||
/** Static first-frame WebP from an animated GIF. Null when ffmpeg is missing or fails. */
|
||||
export async function gifPoster(
|
||||
inputPath: string,
|
||||
outDir: string,
|
||||
ffmpeg = process.env.VESKSLOP_FFMPEG ?? "ffmpeg"
|
||||
): Promise<{ hash: string; bytes: number } | null> {
|
||||
const temp = join(outDir, `.gif-${Date.now()}-${Math.random().toString(16).slice(2)}.webp`);
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
ffmpeg,
|
||||
["-v", "error", "-y", "-i", inputPath, "-frames:v", "1", "-vf", "scale=480:-1", temp],
|
||||
{ timeout: 30_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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The gap before the next file, in milliseconds. */
|
||||
export function downloadGapMs(options: DownloadOptions = {}): number {
|
||||
const min = options.minGapMs ?? 1000;
|
||||
|
||||
@@ -98,6 +98,11 @@ export async function resolveEmbedPreview(
|
||||
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
|
||||
}
|
||||
|
||||
if (host === "tenor.com") {
|
||||
const data = await getJson(`https://tenor.com/oembed?url=${encodeURIComponent(media.url)}`, options);
|
||||
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
|
||||
}
|
||||
|
||||
if (host === "fxtwitter.com" || host === "vxtwitter.com" || host === "twitter.com" || host === "x.com") {
|
||||
const match = /\/status\/(\d+)/.exec(parsed.pathname);
|
||||
if (match) {
|
||||
|
||||
+20
-10
@@ -3,12 +3,13 @@
|
||||
* Rules: docs/media-scraper.md.
|
||||
*/
|
||||
|
||||
import { copyFileSync, mkdirSync } from "node:fs";
|
||||
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
downloadGapMs,
|
||||
downloadOne,
|
||||
downloadPreview,
|
||||
gifPoster,
|
||||
probeDimensions,
|
||||
videoPoster,
|
||||
type DownloadOptions
|
||||
@@ -378,21 +379,30 @@ export class ScrapeEngine {
|
||||
const outcome = await downloadPreview(previewRow, join(dir, "thumbs"), options);
|
||||
|
||||
if (outcome.state === "downloaded" && outcome.hash) {
|
||||
const ext = outcome.ext ?? "webp";
|
||||
store.markPreview(row.id, { hash: outcome.hash, bytes: outcome.bytes ?? 0, ext });
|
||||
let hash = outcome.hash;
|
||||
let bytes = outcome.bytes ?? 0;
|
||||
let ext = outcome.ext ?? "webp";
|
||||
|
||||
// Animated GIFs are kept static in the grid; hovering plays the original.
|
||||
if (ext === "gif") {
|
||||
const poster = await gifPoster(join(dir, "thumbs", `${hash}.gif`), join(dir, "thumbs"));
|
||||
if (poster) {
|
||||
rmSync(join(dir, "thumbs", `${hash}.gif`), { force: true });
|
||||
hash = poster.hash;
|
||||
bytes = poster.bytes;
|
||||
ext = "webp";
|
||||
}
|
||||
}
|
||||
|
||||
store.markPreview(row.id, { hash, bytes, ext });
|
||||
meta.previewed = (meta.previewed ?? 0) + 1;
|
||||
|
||||
// Embed thumbnails also land in the custom destination, so the download has a
|
||||
// viewable poster for embeds while the media tab keeps its local copy.
|
||||
if (options.destinationDir && row.source === "embed") {
|
||||
const target = join(
|
||||
options.destinationDir,
|
||||
meta.id,
|
||||
"thumbnails",
|
||||
`${outcome.hash}.${ext}`
|
||||
);
|
||||
const target = join(options.destinationDir, meta.id, "thumbnails", `${hash}.${ext}`);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
copyFileSync(join(dir, "thumbs", `${outcome.hash}.${ext}`), target);
|
||||
copyFileSync(join(dir, "thumbs", `${hash}.${ext}`), target);
|
||||
}
|
||||
} else {
|
||||
store.markPreviewError(row.id, outcome.error ?? outcome.state);
|
||||
|
||||
@@ -19,6 +19,7 @@ export {
|
||||
downloadGapMs,
|
||||
downloadOne,
|
||||
downloadPreview,
|
||||
gifPoster,
|
||||
previewUrlFor,
|
||||
probeDimensions,
|
||||
videoPoster,
|
||||
|
||||
@@ -476,34 +476,4 @@ export class ScrapeStore {
|
||||
return rows as unknown as MediaRow[];
|
||||
}
|
||||
|
||||
/** Counts for the status row, under the same filters as queryMedia. */
|
||||
stats(options: MediaFilterOptions = {}): {
|
||||
total: number;
|
||||
downloaded: number;
|
||||
cached: number;
|
||||
notDownloaded: number;
|
||||
} {
|
||||
const { where, values } = this.mediaWhere(options);
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(downloaded), 0) AS downloaded,
|
||||
COALESCE(SUM(CASE WHEN downloaded = 0 AND preview_hash IS NOT NULL THEN 1 ELSE 0 END), 0) AS cached,
|
||||
COALESCE(SUM(CASE WHEN downloaded = 0 THEN 1 ELSE 0 END), 0) AS not_downloaded
|
||||
FROM media ${where}`
|
||||
)
|
||||
.get(...values) as unknown as {
|
||||
total: number;
|
||||
downloaded: number;
|
||||
cached: number;
|
||||
not_downloaded: number;
|
||||
};
|
||||
|
||||
return {
|
||||
total: row?.total ?? 0,
|
||||
downloaded: row?.downloaded ?? 0,
|
||||
cached: row?.cached ?? 0,
|
||||
notDownloaded: row?.not_downloaded ?? 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ 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, previewUrlFor, videoPoster } from "../src/download.js";
|
||||
import { downloadOne, downloadPreview, gifPoster, previewUrlFor, 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";
|
||||
@@ -734,6 +734,11 @@ void (async () => {
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
if (url.includes("tenor.com/oembed")) {
|
||||
return new Response(JSON.stringify({ thumbnail_url: "https://media.tenor.com/ok.png" }), {
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
if (url.includes("api.fxtwitter.com")) {
|
||||
return new Response(
|
||||
JSON.stringify({ tweet: { media: { videos: [{ thumbnail_url: "https://pbs.twimg.com/ok.jpg" }] } } }),
|
||||
@@ -763,6 +768,13 @@ void (async () => {
|
||||
),
|
||||
"https://pbs.twimg.com/ok.jpg"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveEmbedPreview(
|
||||
{ url: "https://tenor.com/view/abc-123", source: "embed", preview_url: null },
|
||||
{ fetchImpl }
|
||||
),
|
||||
"https://media.tenor.com/ok.png"
|
||||
);
|
||||
assert.equal(
|
||||
await resolveEmbedPreview(
|
||||
{ url: "https://vxreddit.com/r/x/comments/1", source: "embed", preview_url: null },
|
||||
@@ -823,6 +835,32 @@ void (async () => {
|
||||
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
|
||||
});
|
||||
|
||||
await test("gifPoster freezes an animated gif", async () => {
|
||||
const dir = join(root, "gif-test");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const gifPath = join(dir, "anim.gif");
|
||||
|
||||
try {
|
||||
execFileSync("ffmpeg", [
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=duration=1:size=64x64:rate=2",
|
||||
gifPath
|
||||
]);
|
||||
} catch {
|
||||
console.log("ok - gifPoster skipped (no ffmpeg)");
|
||||
return;
|
||||
}
|
||||
|
||||
const poster = await gifPoster(gifPath, dir);
|
||||
assert.ok(poster?.hash);
|
||||
assert.equal(existsSync(join(dir, `${poster?.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