Add video poster frames and rework the media status row
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user