Add video poster frames and rework the media status row

This commit is contained in:
Fisto
2026-09-16 12:40:10 +02:00
parent f9b4041157
commit 043d1cd3c5
9 changed files with 124 additions and 12 deletions
+5
View File
@@ -40,6 +40,11 @@ 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] C18 Video previews and the status row. Uploaded videos have no Discord thumbnails: the
grid renders a first-frame `<video>` for them, and downloads generate a poster frame with
ffmpeg into `thumbs/` (live: both video attachments in the test channel got posters). The
status row is now `(Images/Videos) | Downloaded A/B | Cached C/B (excl Downl) | Loaded L/B`
with B = total for the active filters.
- [x] C17 Media grid and viewer. Embed videos now render their cached thumbnail with a play - [x] C17 Media grid and viewer. Embed videos now render their cached thumbnail with a play
icon; a Metadata dropdown picks banner fields (User, Type, Date, Source, Resolution, Length); icon; a Metadata dropdown picks banner fields (User, Type, Date, Source, Resolution, Length);
nothing shows until Load media; the status row reads `downloaded A/B | cached C/D (excludes nothing shows until Load media; the status row reads `downloaded A/B | cached C/D (excludes
+2
View File
@@ -109,6 +109,8 @@ Downloads run after a finished walk when the scrape used `mode: "download"`:
- With a custom destination, files go to `<destination>/<scrape-id>/media/` and the row stores the - With a custom destination, files go to `<destination>/<scrape-id>/media/` and the row stores the
absolute path; without one they stay in `<scrape>/media/`. Embed previews are copied to absolute path; without one they stay in `<scrape>/media/`. Embed previews are copied to
`<destination>/<scrape-id>/thumbnails/` while the local copy stays for the Media tab. `<destination>/<scrape-id>/thumbnails/` while the local copy stays for the Media tab.
- Downloaded videos get a poster frame (`ffmpeg -ss 0.5`, 480px WebP) cached in `thumbs/`, because
Discord serves no thumbnails for uploaded video files.
- Progress counters: `downloaded`, `downloadErrors`. - Progress counters: `downloaded`, `downloadErrors`.
Previews run after every walk: Previews run after every walk:
+8 -6
View File
@@ -138,12 +138,14 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
21:9, Other), resolution (SD, HD, FHD, QHD, 4K+, Unknown), and a posted-date range (from/to). 21:9, Other), resolution (SD, HD, FHD, QHD, 4K+, Unknown), and a posted-date range (from/to).
Nothing renders until **Load media** is pressed. A **Metadata** dropdown toggles which fields Nothing renders until **Load media** is pressed. A **Metadata** dropdown toggles which fields
show in the small banner under each tile (User, Type, Date, Source, Resolution, Length). Video show in the small banner under each tile (User, Type, Date, Source, Resolution, Length). Video
tiles carry a translucent play icon top-right. Clicking a tile opens a viewer: wide media shows tiles carry a translucent play icon top-right. Discord serves no thumbnails for uploaded videos,
the metadata banner below, vertical media shows it to the right; the banner has the full link so a video without a cached preview renders a first-frame `<video>` in the grid, and downloaded
(with an external-site warning) and **View full**, and the play action embeds a YouTube, Vimeo, videos get an ffmpeg poster cached in `thumbs/`. Clicking a tile opens a viewer: wide media
Twitch, or Streamable player when possible. The status row reads shows the metadata banner below, vertical media shows it to the right; the banner has the full
`downloaded A/B (images/videos) | cached C/D (excludes downloaded) | N loaded`. A scrape strip link (with an external-site warning) and **View full**, and the play action embeds a YouTube,
shows live progress with Stop. Vimeo, Twitch, or Streamable player when possible. The status row reads
`(Images/Videos) | Downloaded A/B | Cached C/B (excl Downl) | Loaded L/B`, where B is the total
for the active filters. A scrape strip shows live progress with Stop.
**New scrape…** (rightmost) opens a popup with Server and Group chat trees (servers expand to **New scrape…** (rightmost) opens a popup with Server and Group chat trees (servers expand to
channels; each row has Links and Download). Every scrape button asks for confirmation; the channels; each row has Links and Download). Every scrape button asks for confirmation; the
dialog takes optional From/To dates and a **Check Message Count** button (Discord search total, dialog takes optional From/To dates and a **Check Message Count** button (Discord search total,
+49 -1
View File
@@ -5,7 +5,7 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs"; import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { MediaRow } from "./store.js"; 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. */ /** The gap before the next file, in milliseconds. */
export function downloadGapMs(options: DownloadOptions = {}): number { export function downloadGapMs(options: DownloadOptions = {}): number {
const min = options.minGapMs ?? 1000; const min = options.minGapMs ?? 1000;
+9
View File
@@ -10,6 +10,7 @@ import {
downloadOne, downloadOne,
downloadPreview, downloadPreview,
probeDimensions, probeDimensions,
videoPoster,
type DownloadOptions type DownloadOptions
} from "./download.js"; } from "./download.js";
import { import {
@@ -324,6 +325,14 @@ export class ScrapeEngine {
); );
if (dimensions) store.updateDimensions(row.id, dimensions.width, dimensions.height); 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 { } else {
store.markDownloadError(row.id, outcome.error ?? outcome.state); store.markDownloadError(row.id, outcome.error ?? outcome.state);
if (outcome.state === "failed") meta.downloadErrors = (meta.downloadErrors ?? 0) + 1; if (outcome.state === "failed") meta.downloadErrors = (meta.downloadErrors ?? 0) + 1;
+2
View File
@@ -19,6 +19,8 @@ export {
downloadOne, downloadOne,
downloadPreview, downloadPreview,
previewUrlFor, previewUrlFor,
probeDimensions,
videoPoster,
type DownloadOptions, type DownloadOptions,
type DownloadOutcome type DownloadOutcome
} from "./download.js"; } from "./download.js";
+29 -1
View File
@@ -4,11 +4,12 @@
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto"; 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, 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 { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
import { extractMedia } from "../src/extract.js"; import { extractMedia } from "../src/extract.js";
import { PuppetBackend } from "../src/puppet-backend.js"; import { PuppetBackend } from "../src/puppet-backend.js";
@@ -711,6 +712,33 @@ void (async () => {
store.close(); 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", () => { 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);
+2 -1
View File
@@ -99,7 +99,8 @@
cursor: pointer; cursor: pointer;
} }
.vk-media-media img { .vk-media-media img,
.vk-media-media video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
+18 -3
View File
@@ -194,8 +194,8 @@
function renderStatus() { function renderStatus() {
const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }; const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 };
setStatus( setStatus(
`downloaded ${stats.downloaded}/${stats.total} (images/videos) | ` + `(Images/Videos) | Downloaded ${stats.downloaded}/${stats.total} | ` +
`cached ${stats.cached}/${stats.notDownloaded} (excludes downloaded) | ${state.items.length} loaded` `Cached ${stats.cached}/${stats.total} (excl Downl) | Loaded ${state.items.length}/${stats.total}`
); );
} }
@@ -403,12 +403,27 @@
const source = gridSource(item); const source = gridSource(item);
const visual = item.kind === "image" || item.kind === "gif" || item.kind === "video"; const visual = item.kind === "image" || item.kind === "gif" || item.kind === "video";
if (visual && /^(https?:|\/)/.test(source)) { if (visual && /^(https?:|\/)/.test(source) && item.kind !== "video") {
const img = document.createElement("img"); const img = document.createElement("img");
img.loading = "lazy"; img.loading = "lazy";
img.src = source; img.src = source;
img.alt = item.kind; img.alt = item.kind;
media.append(img); media.append(img);
} else if (item.kind === "video" && /^(https?:|\/)/.test(source)) {
// Discord has no server-side video thumbnails; the browser shows the first frame.
const video = document.createElement("video");
video.preload = "metadata";
video.muted = true;
video.playsInline = true;
video.src = source;
video.addEventListener(
"loadedmetadata",
() => {
if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2);
},
{ once: true }
);
media.append(video);
} else { } else {
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase())); media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
} }