Add video poster frames and rework the media status row
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import type { MediaRow } from "./store.js";
|
||||
@@ -228,6 +228,54 @@ export async function downloadPreview(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Grab a poster frame from a downloaded video with ffmpeg.
|
||||
* Discord has no server-side thumbnails for uploaded videos, so the archive makes its own.
|
||||
*/
|
||||
export async function videoPoster(
|
||||
inputPath: string,
|
||||
outDir: string,
|
||||
ffmpeg = process.env.VESKSLOP_FFMPEG ?? "ffmpeg"
|
||||
): Promise<{ hash: string; bytes: number } | null> {
|
||||
const temp = join(outDir, `.poster-${Date.now()}-${Math.random().toString(16).slice(2)}.webp`);
|
||||
|
||||
try {
|
||||
await execFileAsync(
|
||||
ffmpeg,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-ss",
|
||||
"0.5",
|
||||
"-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;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
downloadOne,
|
||||
downloadPreview,
|
||||
probeDimensions,
|
||||
videoPoster,
|
||||
type DownloadOptions
|
||||
} from "./download.js";
|
||||
import {
|
||||
@@ -324,6 +325,14 @@ export class ScrapeEngine {
|
||||
);
|
||||
if (dimensions) store.updateDimensions(row.id, dimensions.width, dimensions.height);
|
||||
}
|
||||
|
||||
if (row.kind === "video" && !row.preview_hash) {
|
||||
const poster = await videoPoster(join(targetDir, outcome.path), join(dir, "thumbs"));
|
||||
if (poster) {
|
||||
store.markPreview(row.id, poster);
|
||||
meta.previewed = (meta.previewed ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
store.markDownloadError(row.id, outcome.error ?? outcome.state);
|
||||
if (outcome.state === "failed") meta.downloadErrors = (meta.downloadErrors ?? 0) + 1;
|
||||
|
||||
@@ -19,6 +19,8 @@ export {
|
||||
downloadOne,
|
||||
downloadPreview,
|
||||
previewUrlFor,
|
||||
probeDimensions,
|
||||
videoPoster,
|
||||
type DownloadOptions,
|
||||
type DownloadOutcome
|
||||
} from "./download.js";
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
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 } from "../src/download.js";
|
||||
import { downloadOne, downloadPreview, previewUrlFor, videoPoster } from "../src/download.js";
|
||||
import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
|
||||
import { extractMedia } from "../src/extract.js";
|
||||
import { PuppetBackend } from "../src/puppet-backend.js";
|
||||
@@ -711,6 +712,33 @@ void (async () => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
await test("videoPoster extracts a cached frame", async () => {
|
||||
const dir = join(root, "poster-test");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const videoPath = join(dir, "clip.mp4");
|
||||
|
||||
try {
|
||||
execFileSync("ffmpeg", [
|
||||
"-v",
|
||||
"error",
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=duration=1:size=64x64:rate=5",
|
||||
videoPath
|
||||
]);
|
||||
} catch {
|
||||
console.log("ok - videoPoster skipped (no ffmpeg)");
|
||||
return;
|
||||
}
|
||||
|
||||
const poster = await videoPoster(videoPath, dir);
|
||||
assert.ok(poster?.hash);
|
||||
assert.ok((poster?.bytes ?? 0) > 0);
|
||||
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