This commit is contained in:
@@ -38,14 +38,28 @@ in `src/plugin.ts`.
|
|||||||
|
|
||||||
## How a frame is chosen
|
## How a frame is chosen
|
||||||
|
|
||||||
1. Candidate offsets at 20 / 45 / 70 % of `fileinfo/duration`, clamped 2s away
|
1. **Record first, file second.** `fileinfo/duration` and the stream table
|
||||||
from both ends (the head is logos and black, the tail is credits).
|
from the record; if either is missing, one header-only `ffprobe` reads it
|
||||||
2. At each offset, one ffmpeg run with **input seeking** (`-ss` before `-i`, so
|
from the file (a record with no stream data at all has been seen on the dev
|
||||||
it Range-seeks over WebDAV instead of decoding from zero) and
|
stack). meta-sort treats a `failed` or
|
||||||
`thumbnail=60`, which picks the most representative of the next 60 frames.
|
`skipped` ffmpeg dependency as complete, so a missing duration is a normal
|
||||||
3. The encoded JPEG is measured (mean + stddev of a 32×32 grayscale reduction).
|
case, not a bug. Only if the probe fails too does it fall back to a blind
|
||||||
|
10s then 0s — and that fallback lands on distributor idents and opening
|
||||||
|
credits, so it is a last resort.
|
||||||
|
2. Candidate offsets at 20 / 45 / 70 % of the duration, clamped 2s away from
|
||||||
|
both ends (the head is logos and black, the tail is credits).
|
||||||
|
3. At each offset, one ffmpeg run with **input seeking** (`-ss` before `-i`, so
|
||||||
|
it Range-seeks over WebDAV instead of decoding from zero), scaled down
|
||||||
|
**before** `thumbnail=60` picks the most representative of the next 60
|
||||||
|
frames. That order matters: `thumbnail` buffers all 60 frames, and at full
|
||||||
|
1080p a single grab peaked at 409 MiB versus 196 MiB scaled-first.
|
||||||
|
4. The encoded JPEG is measured (mean + stddev of a 32×32 grayscale reduction).
|
||||||
Black, blown-out and flat frames are rejected and the next offset is tried.
|
Black, blown-out and flat frames are rejected and the next offset is tried.
|
||||||
4. All candidates rejected → `skipped`, no field written.
|
5. All candidates rejected → `skipped`, no field written.
|
||||||
|
|
||||||
|
Extractions run **one at a time** by default (`maxConcurrent`). meta-sort's
|
||||||
|
background queue sends several tasks at once, and four concurrent 1080p grabs
|
||||||
|
OOM-killed the 512 MiB container; with one CPU, parallel decodes buy nothing.
|
||||||
|
|
||||||
The primary video stream is the largest one that **looks like moving video** —
|
The primary video stream is the largest one that **looks like moving video** —
|
||||||
not `0:v:0`, and not simply the largest. ffprobe reports attached cover art as
|
not `0:v:0`, and not simply the largest. ffprobe reports attached cover art as
|
||||||
@@ -68,6 +82,7 @@ curl -k -X PUT https://metasort-dev.localhost:8180/api/plugins/still-extractor/c
|
|||||||
| `maxWidth` | `640` | Frame is scaled down to at most this width |
|
| `maxWidth` | `640` | Frame is scaled down to at most this width |
|
||||||
| `jpegQuality` | `4` | ffmpeg `-q:v`; lower is better quality |
|
| `jpegQuality` | `4` | ffmpeg `-q:v`; lower is better quality |
|
||||||
| `frameTimeoutMs` | `120000` | Hard kill for one ffmpeg grab |
|
| `frameTimeoutMs` | `120000` | Hard kill for one ffmpeg grab |
|
||||||
|
| `maxConcurrent` | `1` | Extractions allowed to run at once in this instance |
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -80,3 +95,18 @@ docker build -t metamesh-plugin-still-extractor:main .
|
|||||||
|
|
||||||
Depends on `file-info` (for `fileType`) and `ffmpeg` (for `fileinfo/duration`
|
Depends on `file-info` (for `fileType`) and `ffmpeg` (for `fileinfo/duration`
|
||||||
and the `stream/{n}` table). Runs on the **background** queue.
|
and the `stream/{n}` table). Runs on the **background** queue.
|
||||||
|
|
||||||
|
### What meta-sort actually sends
|
||||||
|
|
||||||
|
⚠ The `existingMeta` in a `/process` request is the **nested document form** —
|
||||||
|
`fileinfo: { duration }`, `stream: [...]`, `cids: [...]` — not the flat
|
||||||
|
`fileinfo/duration` / `stream/{n}` keys you see in meta-core's flat record.
|
||||||
|
Reading only the flat key made every task fall back to 10s/0s on the dev stack.
|
||||||
|
`durationFromMeta` and `selectPrimaryVideoStream` accept both shapes; keep it
|
||||||
|
that way.
|
||||||
|
|
||||||
|
A replay or rescan queues several tasks for the same file. Each reads the
|
||||||
|
record at dispatch, so the plugin re-checks `still` in meta-core once it holds
|
||||||
|
an extraction slot, instead of recomputing the same frame for every duplicate.
|
||||||
|
A file that yields no usable frame is remembered in memory for an hour, so its
|
||||||
|
queued duplicates skip instead of decoding it again; a later rescan retries.
|
||||||
|
|||||||
+272
-40
@@ -77,6 +77,7 @@ export const manifest: PluginManifest = {
|
|||||||
maxWidth: { type: 'number', label: 'Max Width (px)', default: 640 },
|
maxWidth: { type: 'number', label: 'Max Width (px)', default: 640 },
|
||||||
jpegQuality: { type: 'number', label: 'JPEG Quality (ffmpeg -q:v, lower is better)', default: 4 },
|
jpegQuality: { type: 'number', label: 'JPEG Quality (ffmpeg -q:v, lower is better)', default: 4 },
|
||||||
frameTimeoutMs: { type: 'number', label: 'Per-Frame ffmpeg Timeout (ms)', default: 120000 },
|
frameTimeoutMs: { type: 'number', label: 'Per-Frame ffmpeg Timeout (ms)', default: 120000 },
|
||||||
|
maxConcurrent: { type: 'number', label: 'Max Concurrent Extractions', default: 1 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,6 +87,20 @@ let seekPercents = [20, 45, 70];
|
|||||||
let maxWidth = 640;
|
let maxWidth = 640;
|
||||||
let jpegQuality = 4;
|
let jpegQuality = 4;
|
||||||
let frameTimeoutMs = 120000;
|
let frameTimeoutMs = 120000;
|
||||||
|
let maxConcurrent = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A config value as a number, or undefined when it is absent.
|
||||||
|
*
|
||||||
|
* ⚠ Not `Number.isFinite(Number(v))`: `Number(null)` and `Number('')` are both
|
||||||
|
* `0`, so a config payload carrying `"maxWidth": null` would silently set a
|
||||||
|
* 0-pixel width or a 0 ms timeout.
|
||||||
|
*/
|
||||||
|
function configNumber(v: unknown): number | undefined {
|
||||||
|
if (v === null || v === undefined || v === '') return undefined;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function configure(config: Record<string, unknown>): void {
|
export function configure(config: Record<string, unknown>): void {
|
||||||
forceRecompute = config.forceRecompute === true;
|
forceRecompute = config.forceRecompute === true;
|
||||||
@@ -96,15 +111,72 @@ export function configure(config: Record<string, unknown>): void {
|
|||||||
.filter((p) => Number.isFinite(p) && p > 0 && p < 100);
|
.filter((p) => Number.isFinite(p) && p > 0 && p < 100);
|
||||||
if (parsed.length > 0) seekPercents = parsed;
|
if (parsed.length > 0) seekPercents = parsed;
|
||||||
}
|
}
|
||||||
if (Number.isFinite(Number(config.maxWidth))) maxWidth = Number(config.maxWidth);
|
const width = configNumber(config.maxWidth);
|
||||||
if (Number.isFinite(Number(config.jpegQuality))) jpegQuality = Number(config.jpegQuality);
|
if (width !== undefined && width > 0) maxWidth = width;
|
||||||
if (Number.isFinite(Number(config.frameTimeoutMs))) frameTimeoutMs = Number(config.frameTimeoutMs);
|
const quality = configNumber(config.jpegQuality);
|
||||||
|
if (quality !== undefined && quality >= 1 && quality <= 31) jpegQuality = quality;
|
||||||
|
const timeout = configNumber(config.frameTimeoutMs);
|
||||||
|
if (timeout !== undefined && timeout > 0) frameTimeoutMs = timeout;
|
||||||
|
const concurrent = configNumber(config.maxConcurrent);
|
||||||
|
if (concurrent !== undefined && concurrent >= 1) maxConcurrent = Math.floor(concurrent);
|
||||||
console.log(
|
console.log(
|
||||||
`[still-extractor] Config: forceRecompute=${forceRecompute}, seekPercents=${seekPercents.join(',')}, ` +
|
`[still-extractor] Config: forceRecompute=${forceRecompute}, seekPercents=${seekPercents.join(',')}, ` +
|
||||||
`maxWidth=${maxWidth}, jpegQuality=${jpegQuality}, frameTimeoutMs=${frameTimeoutMs}`
|
`maxWidth=${maxWidth}, jpegQuality=${jpegQuality}, frameTimeoutMs=${frameTimeoutMs}, maxConcurrent=${maxConcurrent}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let activeExtractions = 0;
|
||||||
|
const extractionWaiters: Array<() => void> = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn` once an extraction slot is free.
|
||||||
|
*
|
||||||
|
* meta-sort's background queue dispatches several tasks at this single
|
||||||
|
* instance at once, and each ffmpeg grab of a 1080p file peaks near 200 MiB
|
||||||
|
* even with the scale-first chain. Four at a time OOM-killed the 512 MiB
|
||||||
|
* container on the dev stack (`OOMKilled=true`, ffmpeg dying on SIGKILL). The
|
||||||
|
* container gets one CPU, so running them concurrently buys no throughput
|
||||||
|
* anyway — the cap lives here so it holds whatever the scheduler sends.
|
||||||
|
*/
|
||||||
|
export async function withExtractionSlot<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
while (activeExtractions >= maxConcurrent) {
|
||||||
|
await new Promise<void>((resolve) => extractionWaiters.push(resolve));
|
||||||
|
}
|
||||||
|
activeExtractions++;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
activeExtractions--;
|
||||||
|
extractionWaiters.shift()?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files that produced no usable frame, remembered briefly by cid.
|
||||||
|
*
|
||||||
|
* A failed extraction writes no `still`, so the slot-time re-check cannot stop
|
||||||
|
* the duplicates a replay or rescan queues behind it — on the dev stack a 17s
|
||||||
|
* title-card test clip was fully decoded a dozen times in a row. An in-memory
|
||||||
|
* TTL absorbs that burst without inventing a persisted "tried and failed" key,
|
||||||
|
* and a rescan after the TTL still gets a fresh attempt.
|
||||||
|
*/
|
||||||
|
const NO_USABLE_FRAME_TTL_MS = 60 * 60 * 1000;
|
||||||
|
const noUsableFrameAt = new Map<string, number>();
|
||||||
|
|
||||||
|
export function recentlyFailed(cid: string, now = Date.now()): boolean {
|
||||||
|
const at = noUsableFrameAt.get(cid);
|
||||||
|
if (at === undefined) return false;
|
||||||
|
if (now - at > NO_USABLE_FRAME_TTL_MS) {
|
||||||
|
noUsableFrameAt.delete(cid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberNoUsableFrame(cid: string, now = Date.now()): void {
|
||||||
|
noUsableFrameAt.set(cid, now);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute midhash256 CID from a Buffer (matches meta-hash algorithm).
|
* Compute midhash256 CID from a Buffer (matches meta-hash algorithm).
|
||||||
* Copied verbatim from metamesh-plugin-subtitle-extractor.
|
* Copied verbatim from metamesh-plugin-subtitle-extractor.
|
||||||
@@ -185,6 +257,32 @@ export interface VideoStream {
|
|||||||
height?: number;
|
height?: number;
|
||||||
codec?: string;
|
codec?: string;
|
||||||
frameRate?: string;
|
frameRate?: string;
|
||||||
|
/** ffprobe's `attached_pic` disposition. Probe path only — the ffmpeg
|
||||||
|
* plugin's stream table does not carry dispositions. */
|
||||||
|
attachedPic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `fileinfo/duration` out of whichever shape the payload carries.
|
||||||
|
*
|
||||||
|
* ⚠ meta-sort's /process payload is the NESTED document form — `fileinfo:
|
||||||
|
* { duration }`, `stream: [...]`, `cids: [...]` — not meta-core's flat
|
||||||
|
* `fileinfo/duration` keys. On the dev stack every task's existingMeta had
|
||||||
|
* `fileinfo` and `stream`, never `fileinfo/duration`, and reading only the flat
|
||||||
|
* key sent 16 of 16 stills to the 10s/0s fallback. Accept both, plus a
|
||||||
|
* JSON-string `fileinfo`.
|
||||||
|
*/
|
||||||
|
export function durationFromMeta(existingMeta: Record<string, unknown> | undefined): number | undefined {
|
||||||
|
if (!existingMeta) return undefined;
|
||||||
|
let fileinfo: unknown = existingMeta['fileinfo'];
|
||||||
|
if (typeof fileinfo === 'string') {
|
||||||
|
try { fileinfo = JSON.parse(fileinfo); } catch { fileinfo = undefined; }
|
||||||
|
}
|
||||||
|
const raw = existingMeta['fileinfo/duration']
|
||||||
|
?? (fileinfo && typeof fileinfo === 'object' ? (fileinfo as Record<string, unknown>)['duration'] : undefined);
|
||||||
|
if (raw === null || raw === undefined || raw === '') return undefined;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,19 +308,21 @@ const STILL_IMAGE_CODECS = new Set(['mjpeg', 'png', 'bmp', 'gif', 'webp', 'tiff'
|
|||||||
* within the better class; if nothing looks like moving video we fall back to
|
* within the better class; if nothing looks like moving video we fall back to
|
||||||
* the largest of whatever is there rather than giving up.
|
* the largest of whatever is there rather than giving up.
|
||||||
*
|
*
|
||||||
* Accepts both shapes meta-core hands back: the namespaced `stream/{n}` keys
|
* Accepts both shapes: the nested `stream` collection meta-sort's /process
|
||||||
* the ffmpeg plugin writes, and the flattened `stream` array.
|
* payload actually carries (array, JSON string, or index-keyed object), and the
|
||||||
|
* namespaced `stream/{n}` keys meta-core stores.
|
||||||
*/
|
*/
|
||||||
export function selectPrimaryVideoStream(existingMeta: Record<string, string> | undefined): VideoStream | null {
|
export function selectPrimaryVideoStream(existingMeta: Record<string, string> | undefined): VideoStream | null {
|
||||||
if (!existingMeta) return null;
|
if (!existingMeta) return null;
|
||||||
|
|
||||||
const raw: unknown[] = [];
|
const raw: unknown[] = [];
|
||||||
|
|
||||||
const flattened = existingMeta['stream'] as unknown;
|
const nested = existingMeta['stream'] as unknown;
|
||||||
if (flattened) {
|
if (nested) {
|
||||||
try {
|
try {
|
||||||
const arr = Array.isArray(flattened) ? flattened : JSON.parse(String(flattened));
|
const parsed = typeof nested === 'string' ? JSON.parse(nested) : nested;
|
||||||
if (Array.isArray(arr)) raw.push(...arr);
|
if (Array.isArray(parsed)) raw.push(...parsed);
|
||||||
|
else if (parsed && typeof parsed === 'object') raw.push(...Object.values(parsed as Record<string, unknown>));
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to the namespaced form
|
// fall through to the namespaced form
|
||||||
}
|
}
|
||||||
@@ -234,9 +334,7 @@ export function selectPrimaryVideoStream(existingMeta: Record<string, string> |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const moving: VideoStream[] = [];
|
const candidates: VideoStream[] = [];
|
||||||
const other: VideoStream[] = [];
|
|
||||||
|
|
||||||
for (const entry of raw) {
|
for (const entry of raw) {
|
||||||
let stream: {
|
let stream: {
|
||||||
type?: string; index?: number; width?: number; height?: number;
|
type?: string; index?: number; width?: number; height?: number;
|
||||||
@@ -249,22 +347,31 @@ export function selectPrimaryVideoStream(existingMeta: Record<string, string> |
|
|||||||
}
|
}
|
||||||
if (!stream || stream.type !== 'video' || stream.index == null) continue;
|
if (!stream || stream.type !== 'video' || stream.index == null) continue;
|
||||||
|
|
||||||
const candidate: VideoStream = {
|
candidates.push({
|
||||||
index: Number(stream.index),
|
index: Number(stream.index),
|
||||||
width: stream.width != null ? Number(stream.width) : undefined,
|
width: stream.width != null ? Number(stream.width) : undefined,
|
||||||
height: stream.height != null ? Number(stream.height) : undefined,
|
height: stream.height != null ? Number(stream.height) : undefined,
|
||||||
codec: stream.codec,
|
codec: stream.codec,
|
||||||
frameRate: stream.frameRate,
|
frameRate: stream.frameRate,
|
||||||
};
|
});
|
||||||
|
|
||||||
const looksMoving =
|
|
||||||
!!candidate.frameRate && !STILL_IMAGE_CODECS.has((candidate.codec ?? '').toLowerCase());
|
|
||||||
(looksMoving ? moving : other).push(candidate);
|
|
||||||
}
|
}
|
||||||
|
return pickPrimaryVideoStream(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
const area = (s: VideoStream) => (s.width ?? 0) * (s.height ?? 0);
|
/**
|
||||||
const pool = moving.length > 0 ? moving : other;
|
* The moving picture among video candidates, wherever they came from.
|
||||||
return pool.reduce<VideoStream | null>((best, s) => (!best || area(s) > area(best) ? s : best), null);
|
*
|
||||||
|
* Prefers streams that have a frame rate, are not a still-image codec and are
|
||||||
|
* not flagged as an attached picture; the largest area wins within that class,
|
||||||
|
* falling back to the largest of the rest rather than giving up.
|
||||||
|
*/
|
||||||
|
export function pickPrimaryVideoStream(candidates: VideoStream[]): VideoStream | null {
|
||||||
|
const looksMoving = (v: VideoStream) =>
|
||||||
|
!v.attachedPic && !!v.frameRate && !STILL_IMAGE_CODECS.has((v.codec ?? '').toLowerCase());
|
||||||
|
const moving = candidates.filter(looksMoving);
|
||||||
|
const pool = moving.length > 0 ? moving : candidates;
|
||||||
|
const area = (v: VideoStream) => (v.width ?? 0) * (v.height ?? 0);
|
||||||
|
return pool.reduce<VideoStream | null>((best, v) => (!best || area(v) > area(best) ? v : best), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,7 +430,7 @@ function run(
|
|||||||
cmd: string,
|
cmd: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
): Promise<{ code: number | null; stdout: Buffer; stderr: string; timedOut: boolean }> {
|
): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: Buffer; stderr: string; timedOut: boolean }> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
const stdout: Buffer[] = [];
|
const stdout: Buffer[] = [];
|
||||||
@@ -342,15 +449,74 @@ function run(
|
|||||||
});
|
});
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve({ code: null, stdout: Buffer.concat(stdout), stderr: stderr + String(err), timedOut });
|
resolve({ code: null, signal: null, stdout: Buffer.concat(stdout), stderr: stderr + String(err), timedOut });
|
||||||
});
|
});
|
||||||
child.on('close', (code) => {
|
child.on('close', (code, signal) => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve({ code, stdout: Buffer.concat(stdout), stderr, timedOut });
|
resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, timedOut });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MediaProbe {
|
||||||
|
duration?: number;
|
||||||
|
videoStreams: VideoStream[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duration and video streams read straight from the file.
|
||||||
|
*
|
||||||
|
* Both normally arrive from the ffmpeg plugin, but meta-sort marks a dependency
|
||||||
|
* complete on `failed` and `skipped` callbacks as well as `completed`
|
||||||
|
* (ContainerPluginScheduler.markPluginCompleted), so a still task can run
|
||||||
|
* against a record missing either — or both: on the dev stack a midhash record
|
||||||
|
* had `fileType=video` and no `fileinfo` or `stream` at all. Without a duration
|
||||||
|
* frame choice falls back to 10s/0s (idents and opening credits); without
|
||||||
|
* streams there is nothing to map. One header-only ffprobe answers both.
|
||||||
|
*/
|
||||||
|
export async function probeMedia(input: string): Promise<MediaProbe> {
|
||||||
|
const args = ['-v', 'error'];
|
||||||
|
if (/^https?:\/\//i.test(input)) {
|
||||||
|
args.push('-rw_timeout', '30000000'); // microseconds
|
||||||
|
}
|
||||||
|
args.push('-print_format', 'json', '-show_format', '-show_streams', input);
|
||||||
|
|
||||||
|
const result = await run('ffprobe', args, 30000);
|
||||||
|
if (result.timedOut || result.code !== 0) return { videoStreams: [] };
|
||||||
|
|
||||||
|
let parsed: {
|
||||||
|
format?: { duration?: string };
|
||||||
|
streams?: Array<{
|
||||||
|
codec_type?: string; index?: number; codec_name?: string;
|
||||||
|
width?: number; height?: number; avg_frame_rate?: string;
|
||||||
|
disposition?: { attached_pic?: number };
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(result.stdout.toString());
|
||||||
|
} catch {
|
||||||
|
return { videoStreams: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Number(parsed.format?.duration);
|
||||||
|
const videoStreams = (parsed.streams ?? [])
|
||||||
|
.filter((v) => v.codec_type === 'video' && v.index != null)
|
||||||
|
.map((v): VideoStream => ({
|
||||||
|
index: Number(v.index),
|
||||||
|
width: v.width,
|
||||||
|
height: v.height,
|
||||||
|
codec: v.codec_name,
|
||||||
|
frameRate: v.avg_frame_rate && v.avg_frame_rate !== '0/0' ? v.avg_frame_rate : undefined,
|
||||||
|
attachedPic: v.disposition?.attached_pic === 1,
|
||||||
|
}));
|
||||||
|
return { duration: Number.isFinite(duration) && duration > 0 ? duration : undefined, videoStreams };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Container duration read from the file — see probeMedia. */
|
||||||
|
export async function probeDuration(input: string): Promise<number | undefined> {
|
||||||
|
return (await probeMedia(input)).duration;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grab one frame at `offsetSec` and return the encoded JPEG.
|
* Grab one frame at `offsetSec` and return the encoded JPEG.
|
||||||
*
|
*
|
||||||
@@ -365,7 +531,9 @@ export async function grabFrame(
|
|||||||
offsetSec: number,
|
offsetSec: number,
|
||||||
outputPath: string
|
outputPath: string
|
||||||
): Promise<Buffer | null> {
|
): Promise<Buffer | null> {
|
||||||
const args = ['-y', '-hide_banner', '-loglevel', 'error'];
|
// -threads caps the decoder's thread pool. Left alone, dav1d/hevc size it
|
||||||
|
// to the host's cores, and every thread holds its own frame buffers.
|
||||||
|
const args = ['-y', '-hide_banner', '-loglevel', 'error', '-threads', '2'];
|
||||||
|
|
||||||
// rw_timeout is a protocol option; only http/tcp accept it, and passing it
|
// rw_timeout is a protocol option; only http/tcp accept it, and passing it
|
||||||
// to the file protocol makes ffmpeg bail before it starts.
|
// to the file protocol makes ffmpeg bail before it starts.
|
||||||
@@ -380,8 +548,11 @@ export async function grabFrame(
|
|||||||
'-an', '-sn', '-dn',
|
'-an', '-sn', '-dn',
|
||||||
'-frames:v', '1',
|
'-frames:v', '1',
|
||||||
// thumbnail=N picks the most representative of the next N frames rather
|
// thumbnail=N picks the most representative of the next N frames rather
|
||||||
// than whatever the seek happened to land on.
|
// than whatever the seek happened to land on — and it BUFFERS all N.
|
||||||
'-vf', `thumbnail=60,scale='min(iw,${maxWidth})':-2`,
|
// ⚠ Scale first. `thumbnail=60,scale=…` holds 60 full-resolution frames;
|
||||||
|
// on a 1080p AV1 episode that peaked at 409 MiB, and `scale=…,thumbnail=60`
|
||||||
|
// at 196 MiB (and 2s instead of 4s) for a byte-for-byte comparable JPEG.
|
||||||
|
'-vf', `scale='min(iw,${maxWidth})':-2,thumbnail=60`,
|
||||||
'-q:v', String(jpegQuality),
|
'-q:v', String(jpegQuality),
|
||||||
'-f', 'image2',
|
'-f', 'image2',
|
||||||
outputPath
|
outputPath
|
||||||
@@ -393,7 +564,12 @@ export async function grabFrame(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (result.code !== 0 || !existsSync(outputPath)) {
|
if (result.code !== 0 || !existsSync(outputPath)) {
|
||||||
console.warn(`[still-extractor] ffmpeg failed at ${offsetSec}s: ${result.stderr.trim() || `exit ${result.code}`}`);
|
// A signal with no stderr is the kernel, not ffmpeg: in practice the
|
||||||
|
// OOM killer. Say so, instead of the old unhelpful "exit null".
|
||||||
|
const why = result.signal
|
||||||
|
? `killed by ${result.signal}${result.signal === 'SIGKILL' ? ' (likely out of memory)' : ''}`
|
||||||
|
: result.stderr.trim() || `exit ${result.code}`;
|
||||||
|
console.warn(`[still-extractor] ffmpeg failed at ${offsetSec}s: ${why}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,12 +673,16 @@ export async function process(
|
|||||||
const { taskId, cid, filePath, existingMeta } = request;
|
const { taskId, cid, filePath, existingMeta } = request;
|
||||||
const metaCore = new MetaCoreClient(request.metaCoreUrl);
|
const metaCore = new MetaCoreClient(request.metaCoreUrl);
|
||||||
|
|
||||||
const skip = (reason: string) =>
|
const skip = (reason: string, quiet = false) => {
|
||||||
sendCallback({ taskId, status: 'skipped', duration: Date.now() - startTime, reason });
|
// Every non-video file in the library comes through here too — logging
|
||||||
|
// those would bury the skips that matter ("No video stream" on a video).
|
||||||
|
if (!quiet) console.log(`[still-extractor] ${filePath}: skipped — ${reason}`);
|
||||||
|
return sendCallback({ taskId, status: 'skipped', duration: Date.now() - startTime, reason });
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existingMeta?.fileType !== 'video') {
|
if (existingMeta?.fileType !== 'video') {
|
||||||
await skip('Not a video file');
|
await skip('Not a video file', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,16 +694,17 @@ export async function process(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stream = selectPrimaryVideoStream(existingMeta);
|
let stream = selectPrimaryVideoStream(existingMeta);
|
||||||
if (!stream) {
|
|
||||||
await skip('No video stream');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebDAV of the very core we're enriching, so the frame bytes and the
|
// WebDAV of the very core we're enriching, so the frame bytes and the
|
||||||
// CID we write can never point at different cores.
|
// CID we write can never point at different cores.
|
||||||
const webdavClient = await getWebDAVClient(request.metaCoreUrl);
|
const webdavClient = await getWebDAVClient(request.metaCoreUrl);
|
||||||
if (!webdavClient) {
|
if (!webdavClient) {
|
||||||
|
if (!stream) {
|
||||||
|
// Nothing in the record and no way to read the file.
|
||||||
|
await skip('No video stream');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await sendCallback({
|
await sendCallback({
|
||||||
taskId,
|
taskId,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
@@ -533,10 +714,61 @@ export async function process(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = Number(existingMeta?.['fileinfo/duration']);
|
|
||||||
const input = webdavClient.toWebDAVUrl(filePath);
|
const input = webdavClient.toWebDAVUrl(filePath);
|
||||||
|
let probe: MediaProbe | undefined;
|
||||||
|
const probeOnce = async () => (probe ??= await probeMedia(input));
|
||||||
|
|
||||||
const extracted = await extractStill(input, stream.index, Number.isFinite(duration) ? duration : undefined);
|
let streamSource = '';
|
||||||
|
if (!stream) {
|
||||||
|
stream = pickPrimaryVideoStream((await probeOnce()).videoStreams);
|
||||||
|
streamSource = ' [probed]';
|
||||||
|
if (!stream) {
|
||||||
|
await skip('No video stream');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordDuration = durationFromMeta(existingMeta as Record<string, unknown> | undefined);
|
||||||
|
|
||||||
|
if (recordDuration === undefined) {
|
||||||
|
// Name what this task actually received, so an upstream cause of a
|
||||||
|
// missing duration stays diagnosable even though we recover from it.
|
||||||
|
const keys = Object.keys(existingMeta ?? {});
|
||||||
|
console.warn(
|
||||||
|
`[still-extractor] ${filePath}: no usable fileinfo/duration ` +
|
||||||
|
`(fileinfo=${JSON.stringify((existingMeta as Record<string, unknown> | undefined)?.['fileinfo'] ?? existingMeta?.['fileinfo/duration'] ?? null).slice(0, 120)}), probing the file; ` +
|
||||||
|
`existingMeta has ${keys.length} keys: ${keys.slice(0, 40).join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const duration = recordDuration ?? (await probeOnce()).duration;
|
||||||
|
const durationSource = recordDuration !== undefined ? 'record' : duration !== undefined ? 'probed' : 'unknown';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[still-extractor] ${filePath}: stream=0:${stream.index}${streamSource} ` +
|
||||||
|
`${stream.width ?? '?'}x${stream.height ?? '?'} ${stream.codec ?? ''} ` +
|
||||||
|
`duration=${duration ?? 'unknown'} (${durationSource}) ` +
|
||||||
|
`offsets=${candidateOffsets(duration).join(',')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const extracted = await withExtractionSlot(async () => {
|
||||||
|
// existingMeta was read at dispatch, before this task queued for a slot.
|
||||||
|
// A replay or rescan queues several tasks per file, so re-check here —
|
||||||
|
// otherwise every duplicate behind the first recomputes the same still.
|
||||||
|
if (!forceRecompute && await metaCore.getProperty(cid, 'still')) return 'present' as const;
|
||||||
|
if (!forceRecompute && recentlyFailed(cid)) return 'recently-failed' as const;
|
||||||
|
const result = await extractStill(input, stream.index, duration);
|
||||||
|
if (!result) rememberNoUsableFrame(cid);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
if (extracted === 'present') {
|
||||||
|
await skip('Still already present');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (extracted === 'recently-failed') {
|
||||||
|
// The first attempt already logged "No usable frame".
|
||||||
|
await skip('No usable frame (recent attempt, not retried)', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!extracted) {
|
if (!extracted) {
|
||||||
await skip('No usable frame');
|
await skip('No usable frame');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ import {
|
|||||||
selectPrimaryVideoStream,
|
selectPrimaryVideoStream,
|
||||||
extractStill,
|
extractStill,
|
||||||
grabFrame,
|
grabFrame,
|
||||||
|
configure,
|
||||||
|
withExtractionSlot,
|
||||||
|
probeDuration,
|
||||||
|
durationFromMeta,
|
||||||
|
probeMedia,
|
||||||
|
pickPrimaryVideoStream,
|
||||||
|
recentlyFailed,
|
||||||
|
rememberNoUsableFrame,
|
||||||
} from '../src/plugin.js';
|
} from '../src/plugin.js';
|
||||||
import type { CallbackPayload } from '../src/types.js';
|
import type { CallbackPayload } from '../src/types.js';
|
||||||
|
|
||||||
@@ -282,3 +290,175 @@ describe('process - skip logic', () => {
|
|||||||
expect(seen[0].reason).toBe('No video stream');
|
expect(seen[0].reason).toBe('No video stream');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('withExtractionSlot', () => {
|
||||||
|
const measurePeak = async (jobs: number) => {
|
||||||
|
let active = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const job = () => withExtractionSlot(async () => {
|
||||||
|
active++;
|
||||||
|
peak = Math.max(peak, active);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
active--;
|
||||||
|
});
|
||||||
|
await Promise.all(Array.from({ length: jobs }, job));
|
||||||
|
return peak;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('never runs more extractions than maxConcurrent', async () => {
|
||||||
|
// The dev-stack OOM: four concurrent 1080p grabs in a 512 MiB container.
|
||||||
|
configure({ maxConcurrent: 1 });
|
||||||
|
expect(await measurePeak(4)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows parallelism when configured, and releases every slot', async () => {
|
||||||
|
configure({ maxConcurrent: 3 });
|
||||||
|
expect(await measurePeak(6)).toBe(3);
|
||||||
|
configure({ maxConcurrent: 1 });
|
||||||
|
expect(await measurePeak(3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the slot when the job throws', async () => {
|
||||||
|
configure({ maxConcurrent: 1 });
|
||||||
|
await expect(withExtractionSlot(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
||||||
|
expect(await measurePeak(2)).toBe(1); // would hang forever if the slot leaked
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configure', () => {
|
||||||
|
it.skipIf(!ffmpegAvailable || !fixturesReady)(
|
||||||
|
'treats null numeric values as absent instead of zero',
|
||||||
|
async () => {
|
||||||
|
// Number(null) === 0: before the fix this set a 0 px width and a 0 ms
|
||||||
|
// timeout, killing every grab on the spot.
|
||||||
|
configure({ maxWidth: null, jpegQuality: null, frameTimeoutMs: null, maxConcurrent: null });
|
||||||
|
mkdirSync(TEMP, { recursive: true });
|
||||||
|
const result = await extractStill(fixture('color.mp4'), 0, 10, TEMP);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!ffmpegAvailable || !fixturesReady)('probeDuration', () => {
|
||||||
|
it('reads the duration from the file header', async () => {
|
||||||
|
const d = await probeDuration(fixture('color.mp4'));
|
||||||
|
expect(d).toBeGreaterThan(9);
|
||||||
|
expect(d).toBeLessThan(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an unreadable input instead of throwing', async () => {
|
||||||
|
expect(await probeDuration(fixture('does-not-exist.mkv'))).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns a record with no duration into real candidate offsets, not the 10s/0s fallback', async () => {
|
||||||
|
const d = await probeDuration(fixture('black-head.mp4'));
|
||||||
|
expect(d).toBeDefined();
|
||||||
|
expect(candidateOffsets(d)).not.toEqual([10, 0]);
|
||||||
|
// and the 20% candidate lands past the 6s black head
|
||||||
|
expect(candidateOffsets(d)[0]).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('durationFromMeta', () => {
|
||||||
|
it('reads the nested document form meta-sort actually sends', () => {
|
||||||
|
expect(durationFromMeta({ fileType: 'video', fileinfo: { duration: '1433.089', formatName: 'matroska,webm' } }))
|
||||||
|
.toBeCloseTo(1433.089);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a numeric nested duration, the exact shape metasort-app/meta returns', () => {
|
||||||
|
// Captured from the dev stack: fileinfo.duration is a JSON number there, not a string.
|
||||||
|
expect(durationFromMeta({ fileinfo: { duration: 1433.089, formatName: 'matroska,webm' } })).toBe(1433.089);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the flat meta-core key', () => {
|
||||||
|
expect(durationFromMeta({ 'fileinfo/duration': '1380.031' })).toBeCloseTo(1380.031);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a JSON-string fileinfo', () => {
|
||||||
|
expect(durationFromMeta({ fileinfo: JSON.stringify({ duration: '42.5' }) })).toBe(42.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats missing, zero and junk values as absent', () => {
|
||||||
|
expect(durationFromMeta(undefined)).toBeUndefined();
|
||||||
|
expect(durationFromMeta({})).toBeUndefined();
|
||||||
|
expect(durationFromMeta({ fileinfo: {} })).toBeUndefined();
|
||||||
|
expect(durationFromMeta({ fileinfo: { duration: '0' } })).toBeUndefined();
|
||||||
|
expect(durationFromMeta({ fileinfo: { duration: 'N/A' } })).toBeUndefined();
|
||||||
|
expect(durationFromMeta({ fileinfo: 'not json' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectPrimaryVideoStream — nested payload shapes', () => {
|
||||||
|
it('reads a nested stream object keyed by index', () => {
|
||||||
|
const picked = selectPrimaryVideoStream({
|
||||||
|
stream: {
|
||||||
|
'0': JSON.stringify({ type: 'audio', index: 0 }),
|
||||||
|
'1': JSON.stringify({ type: 'video', index: 1, width: 1280, height: 720, codec: 'h264', frameRate: '24/1' }),
|
||||||
|
},
|
||||||
|
} as unknown as Record<string, string>);
|
||||||
|
expect(picked?.index).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a nested stream array of objects', () => {
|
||||||
|
const picked = selectPrimaryVideoStream({
|
||||||
|
stream: [
|
||||||
|
{ type: 'video', index: 0, width: 1920, height: 1080, codec: 'av1', frameRate: '24000/1001' },
|
||||||
|
{ type: 'audio', index: 1 },
|
||||||
|
],
|
||||||
|
} as unknown as Record<string, string>);
|
||||||
|
expect(picked?.index).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pickPrimaryVideoStream', () => {
|
||||||
|
it('never prefers an attached picture, even one with a frame rate', () => {
|
||||||
|
const picked = pickPrimaryVideoStream([
|
||||||
|
{ index: 0, width: 640, height: 480, codec: 'h264', frameRate: '24/1' },
|
||||||
|
{ index: 1, width: 1000, height: 1500, codec: 'h264', frameRate: '24/1', attachedPic: true },
|
||||||
|
]);
|
||||||
|
expect(picked?.index).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null with no candidates', () => {
|
||||||
|
expect(pickPrimaryVideoStream([])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!ffmpegAvailable || !fixturesReady)('probeMedia', () => {
|
||||||
|
it('reads the duration and video stream of a plain video', async () => {
|
||||||
|
const probe = await probeMedia(fixture('color.mp4'));
|
||||||
|
expect(probe.duration).toBeGreaterThan(9);
|
||||||
|
expect(pickPrimaryVideoStream(probe.videoStreams)?.width).toBe(320);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags attached cover art and never picks it', async () => {
|
||||||
|
const probe = await probeMedia(fixture('cover-art.mp4'));
|
||||||
|
expect(probe.videoStreams.some((v) => v.attachedPic)).toBe(true);
|
||||||
|
const primary = pickPrimaryVideoStream(probe.videoStreams);
|
||||||
|
expect(primary?.attachedPic).toBe(false);
|
||||||
|
expect(primary?.width).toBe(320);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds no video stream in audio-only input', async () => {
|
||||||
|
expect(pickPrimaryVideoStream((await probeMedia(fixture('audio-only.mp3'))).videoStreams)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty probe for unreadable input instead of throwing', async () => {
|
||||||
|
expect(await probeMedia(fixture('does-not-exist.mkv'))).toEqual({ videoStreams: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no-usable-frame memory', () => {
|
||||||
|
it('remembers a failed cid for an hour, then forgets it', () => {
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
expect(recentlyFailed('bafk-fail', t0)).toBe(false);
|
||||||
|
rememberNoUsableFrame('bafk-fail', t0);
|
||||||
|
expect(recentlyFailed('bafk-fail', t0 + 59 * 60 * 1000)).toBe(true);
|
||||||
|
expect(recentlyFailed('bafk-fail', t0 + 61 * 60 * 1000)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is keyed per cid', () => {
|
||||||
|
rememberNoUsableFrame('bafk-a', 5);
|
||||||
|
expect(recentlyFailed('bafk-b', 5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user