agvid 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +176 -2
- package/bin/agvid.js +7 -0
- package/package.json +35 -4
- package/skill/agvid/SKILL.md +24 -0
- package/skill/agvid/references/advanced.md +59 -0
- package/src/changes.js +215 -0
- package/src/cli.js +122 -0
- package/src/geometry.js +31 -0
- package/src/media.js +163 -0
- package/src/output.js +128 -0
- package/src/plan.js +184 -0
- package/src/probe.js +160 -0
- package/src/process.js +86 -0
- package/src/seek.js +38 -0
- package/src/sheets.js +144 -0
- package/src/time.js +19 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFile, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { findChanges, requireFfmpeg51 } from './changes.js';
|
|
6
|
+
import { extractFrames } from './media.js';
|
|
7
|
+
import { outputDirectory, release, saveManifest } from './output.js';
|
|
8
|
+
import { parseArgs, parseOptions, planRun, videoStreamOption } from './plan.js';
|
|
9
|
+
import { probe } from './probe.js';
|
|
10
|
+
import { cancel, cancellationError, cancelled, pool } from './process.js';
|
|
11
|
+
import { buildSheets } from './sheets.js';
|
|
12
|
+
|
|
13
|
+
export { displayedSize, parseCrop } from './geometry.js';
|
|
14
|
+
export { resolveRange } from './plan.js';
|
|
15
|
+
export { labelScale, planSheets, renderLabel, sheetLayout } from './sheets.js';
|
|
16
|
+
export { formatTimecode, parseTime } from './time.js';
|
|
17
|
+
|
|
18
|
+
const HELP = `agvid <command> <video> [options]
|
|
19
|
+
|
|
20
|
+
Work progressively: probe, overview, changes (screen recordings), then inspect or frame.
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
probe <video>... Source metadata as JSON; several videos print an array
|
|
24
|
+
overview <video> Evenly spaced frames: --frames N (1-64, default 12)
|
|
25
|
+
changes <video> Range start plus each frame where the picture changes
|
|
26
|
+
inspect <video> --around TIME [--window DURATION] [--fps N]
|
|
27
|
+
inspect <video> --start TIME --end TIME [--fps N]
|
|
28
|
+
Frames at --fps (default 4) in a centered window (total, default 2s) or range
|
|
29
|
+
frame <video> --at TIME The frame on screen at each time
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--start TIME --end TIME Limit overview, changes or inspect to a range
|
|
33
|
+
--crop X,Y,W,H Region as fractions 0-1 of the displayed frame (left, top, width, height)
|
|
34
|
+
--width PX Maximum frame width, 64-4096, default 640
|
|
35
|
+
--output DIR New or empty directory; default .agvid/runs/ under the git root (else cwd)
|
|
36
|
+
--video-stream N Zero-based video stream (0:v:N), default 0; works with probe too
|
|
37
|
+
|
|
38
|
+
changes tuning: --threshold X (changed-pixel share, default 0.002), --min-gap DURATION (0.5s),
|
|
39
|
+
--max N (48, up to 239), --analysis-fps N (30, 1-60), --analysis-width PX (256, 64-512),
|
|
40
|
+
--analysis-budget DURATION (300s: source video per decode attempt). Progress goes to stderr
|
|
41
|
+
every 5s; past --max candidates it keeps the strongest per time slice and reports truncated: true.
|
|
42
|
+
|
|
43
|
+
TIME is seconds, MM:SS.s or HH:MM:SS.s; DURATION is seconds with an optional s. --at and --around take
|
|
44
|
+
comma lists or repeat; a run makes at most 240 frames. A time shows the last frame at or before it,
|
|
45
|
+
and the manifest records the requested time. Writes JPEG frames, timecode-labeled sheets and
|
|
46
|
+
manifest.json, and prints their paths as JSON. A killed run leaves DIR/.agvid.lock; if no agvid run
|
|
47
|
+
uses DIR, delete it and any .agvid.lock.work-* directories. Needs Node 20+ and FFmpeg/FFprobe 5.1+.
|
|
48
|
+
Details: README.md and skill/agvid/references/advanced.md in the package.`;
|
|
49
|
+
|
|
50
|
+
export async function main(args) {
|
|
51
|
+
if (args.length === 0 || ['--help', '-h', 'help'].includes(args[0])) {
|
|
52
|
+
console.log(HELP);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (args[0] === '--version' || args[0] === '-v') {
|
|
56
|
+
const pkg = JSON.parse(await readFile(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
57
|
+
console.log(pkg.version);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const parsed = parseArgs(args, HELP);
|
|
61
|
+
process.on('SIGINT', cancel);
|
|
62
|
+
process.on('SIGTERM', cancel);
|
|
63
|
+
try { await execute(parsed); }
|
|
64
|
+
catch (error) { throw cancelled ? cancellationError() : error; }
|
|
65
|
+
finally {
|
|
66
|
+
process.off('SIGINT', cancel);
|
|
67
|
+
process.off('SIGTERM', cancel);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function execute({ command, video, videos, options }) {
|
|
72
|
+
const videoStream = videoStreamOption(options['video-stream'] ?? '0');
|
|
73
|
+
if (command === 'probe') {
|
|
74
|
+
if (videos.length === 1) {
|
|
75
|
+
console.log(JSON.stringify(await probe(videos[0], videoStream), null, 2));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const results = await pool(videos, Math.min(os.availableParallelism(), 8),
|
|
79
|
+
(file) => probe(file, videoStream).catch((error) => ({ video: file, error: error.message })));
|
|
80
|
+
if (cancelled) throw cancellationError();
|
|
81
|
+
if (results.some((entry) => 'error' in entry)) process.exitCode = 1;
|
|
82
|
+
console.log(JSON.stringify(results, null, 2));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const parsed = parseOptions(command, options);
|
|
86
|
+
const info = await probe(video, videoStream);
|
|
87
|
+
const { crop, width, range, settings, windows, ...plan } = planRun(command, options, info, parsed);
|
|
88
|
+
if (settings) await requireFfmpeg51();
|
|
89
|
+
// Claimed before change detection, so an unusable output fails before the scan.
|
|
90
|
+
const output = await outputDirectory(video, command, options.output);
|
|
91
|
+
const { directory, work } = output;
|
|
92
|
+
const produced = [];
|
|
93
|
+
try {
|
|
94
|
+
let { times } = plan;
|
|
95
|
+
let scores;
|
|
96
|
+
let detection;
|
|
97
|
+
if (settings) {
|
|
98
|
+
const changes = await findChanges({ video, info, range, crop, settings });
|
|
99
|
+
times = [range.start, ...changes.events.map((event) => event.time)];
|
|
100
|
+
scores = [null, ...changes.events.map((event) => event.score)];
|
|
101
|
+
({ detection } = changes);
|
|
102
|
+
}
|
|
103
|
+
const frames = await extractFrames({ video, info, times, width, crop, directory: work, produced, command, windows });
|
|
104
|
+
if (scores) frames.forEach((frame, i) => { frame.score = scores[i]; });
|
|
105
|
+
const sheets = command !== 'frame' || frames.length > 1
|
|
106
|
+
? await buildSheets({ directory: work, frames, windows, width, info, crop, produced }) : undefined;
|
|
107
|
+
// No FFmpeg runs past here, so honor a cancellation that arrived during the last steps before committing.
|
|
108
|
+
if (cancelled) throw cancellationError();
|
|
109
|
+
for (const file of [...frames.map((frame) => frame.file), ...(sheets ?? []).map((sheet) => sheet.file)]) {
|
|
110
|
+
produced.push(path.join(directory, file));
|
|
111
|
+
await rename(path.join(work, file), path.join(directory, file));
|
|
112
|
+
}
|
|
113
|
+
produced.push(path.join(directory, 'manifest.json.tmp'), path.join(directory, 'manifest.json'));
|
|
114
|
+
const manifest = await saveManifest(directory, { command, source: info, outputWidth: width, ...(crop ? { crop } : {}), ...(range && (options.start !== undefined || options.end !== undefined) ? { range } : {}), ...(detection ? { detection } : {}), frames, ...(windows ? { windows } : {}), ...(sheets ? { sheets } : {}) });
|
|
115
|
+
await rm(work, { recursive: true, force: true });
|
|
116
|
+
await rm(output.lock, { force: true });
|
|
117
|
+
console.log(JSON.stringify({ directory, ...(sheets ? { sheets: sheets.map((sheet) => path.join(directory, sheet.file)) } : {}), manifest, frames: frames.length, ...(detection ? { changes: frames.length - 1, candidates: detection.candidates, truncated: detection.truncated } : {}) }, null, 2));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
await release(output, produced);
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/geometry.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Parses --crop fractions and maps them to even pixel offsets and sizes in the source's displayed orientation.
|
|
2
|
+
// FFmpeg auto-rotates before -vf. SAR stretches one displayed axis uniformly, so equal fractions of stored pixels
|
|
3
|
+
// still name the displayed region; `displayed` is that region's size in square pixels (see displayedSize).
|
|
4
|
+
export function parseCrop(value, info) {
|
|
5
|
+
const parts = String(value).split(',');
|
|
6
|
+
const numbers = parts.map((part) => (/^\s*\d*\.?\d+\s*$/.test(part) ? Number(part) : NaN));
|
|
7
|
+
if (parts.length !== 4 || numbers.some((n) => !Number.isFinite(n) || n < 0 || n > 1)) {
|
|
8
|
+
throw new Error(`--crop must be four fractions from 0 to 1 (x,y,w,h): ${value}`);
|
|
9
|
+
}
|
|
10
|
+
const [x, y, w, h] = numbers;
|
|
11
|
+
if (w <= 0 || h <= 0) throw new Error(`--crop width and height must be above 0: ${value}`);
|
|
12
|
+
if (x + w > 1 + 1e-9 || y + h > 1 + 1e-9) throw new Error(`--crop region must fit in the frame (x+w and y+h at most 1): ${value}`);
|
|
13
|
+
const axis = (start, size, total) => {
|
|
14
|
+
const offset = Math.floor(start * total / 2) * 2;
|
|
15
|
+
return [offset, Math.min(Math.round(size * total / 2) * 2, Math.floor((total - offset) / 2) * 2)];
|
|
16
|
+
};
|
|
17
|
+
const [px, width] = axis(x, w, info.width);
|
|
18
|
+
const [py, height] = axis(y, h, info.height);
|
|
19
|
+
if (width < 16 || height < 16) throw new Error(`--crop region is ${width}x${height} source pixels; it must be at least 16x16`);
|
|
20
|
+
return { x, y, w, h, pixels: { x: px, y: py, width, height, displayed: displayedSize(width, height, info) } };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Square-pixel size of width x height stored pixels in displayed orientation. SAR scales the coded horizontal axis,
|
|
24
|
+
// keeping the other at stored resolution; after a 90/270 turn that axis is displayed vertically. info.sar is
|
|
25
|
+
// rotation-adjusted (inverted on a turn, as FFmpeg's transpose does), so dividing by it applies the coded SAR.
|
|
26
|
+
export function displayedSize(width, height, info) {
|
|
27
|
+
const sar = info.sar ?? 1;
|
|
28
|
+
return info.rotation === 90 || info.rotation === 270
|
|
29
|
+
? { width, height: Math.max(2, Math.round(height / sar)) }
|
|
30
|
+
: { width: Math.max(2, Math.round(width * sar)), height };
|
|
31
|
+
}
|
package/src/media.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { copyFile, rename } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { nonempty } from './output.js';
|
|
5
|
+
import { cancellationError, cancelled, lines, pool, run } from './process.js';
|
|
6
|
+
import { TICK, TIME_TOLERANCE, attempts, seekArgs, trimEnd } from './seek.js';
|
|
7
|
+
import { formatTimecode } from './time.js';
|
|
8
|
+
|
|
9
|
+
// setpts numbers the kept frames in whole seconds, the encoder's time base, so frames sharing a source pts (which the
|
|
10
|
+
// JPEG encoder rejects as invalid timestamps) reach it in decode order; select and showinfo before it see source pts.
|
|
11
|
+
function filters(width, crop) {
|
|
12
|
+
const cut = crop ? `crop=${crop.pixels.width}:${crop.pixels.height}:${crop.pixels.x}:${crop.pixels.y},` : '';
|
|
13
|
+
return `setpts=N/TB,${cut}scale=${width}:max(2\\,round(${width}/dar/2)*2),setsar=1`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function extract(video, info, time, width, filename, crop, decoding) {
|
|
17
|
+
const target = time + info.containerStart + TIME_TOLERANCE;
|
|
18
|
+
let emptyError;
|
|
19
|
+
// A time before the first decoded frame (AVI rebuilds pts from dts, delaying it) shows that first frame.
|
|
20
|
+
for (const { slow, window } of [...attempts(info, time), { slow: true, window: 0 }]) {
|
|
21
|
+
const select = window === 0 ? '' : `${trimEnd(info, target)},select='${Number.isFinite(window) ? `between(t,${target - window},${target})` : `lte(t,${target})`}',`;
|
|
22
|
+
try {
|
|
23
|
+
// image2 -update rewrites the file per kept frame, leaving the last one at or before `time` (of frames sharing a
|
|
24
|
+
// pts, the last decoded). Passthrough keeps
|
|
25
|
+
// every kept frame; the default constant-rate sync drops a VFR frame that lands in the same output tick.
|
|
26
|
+
await run('ffmpeg', ['-hide_banner', '-loglevel', 'error', ...decoding, ...seekArgs(slow, time), '-i', video, '-map', `0:v:${info.videoStream}`, ...(window === 0 ? ['-frames:v', '1'] : []), '-vf', `${select}${filters(width, crop)}`, '-fps_mode', 'passthrough', '-enc_time_base:v', '1', '-update', '1', '-threads:v', '1', '-q:v', '4', '-y', filename]);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
// FFmpeg 8 can fail to initialize MJPEG at EOF when seeking yielded no decoded frame.
|
|
29
|
+
if (await nonempty(filename) || !/Non full-range YUV[\s\S]*Could not open encoder before EOF/.test(error.message)) throw error;
|
|
30
|
+
emptyError ??= error;
|
|
31
|
+
}
|
|
32
|
+
if (await nonempty(filename)) return;
|
|
33
|
+
}
|
|
34
|
+
if (emptyError) throw emptyError;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Dense times (consecutive ones at most DENSE_GAP apart) share one decode, split into chunks of at least CHUNK_SPAN
|
|
38
|
+
// seconds so several can run at once. Wider gaps seek per time instead, skipping the frames between.
|
|
39
|
+
const DENSE_GAP = 1;
|
|
40
|
+
const CHUNK_SPAN = 1;
|
|
41
|
+
// Packets are listed past the last time by this much: decode order can put frames shown before it after later ones.
|
|
42
|
+
const REORDER = 1;
|
|
43
|
+
|
|
44
|
+
function denseChunks(info, times) {
|
|
45
|
+
const groups = [];
|
|
46
|
+
for (const [i, time] of times.entries()) {
|
|
47
|
+
if (i && time - times[i - 1] <= DENSE_GAP) groups.at(-1).push(time);
|
|
48
|
+
else groups.push([time]);
|
|
49
|
+
}
|
|
50
|
+
return groups.filter((group) => group.length > 1).flatMap((group) => {
|
|
51
|
+
// Every chunk of a group decoded from the origin (MPEG-TS, near the start) would decode the same frames again.
|
|
52
|
+
if (attempts(info, group.at(-1))[0].slow) return [group];
|
|
53
|
+
const count = Math.max(1, Math.min(group.length >> 1, Math.floor((group.at(-1) - group[0]) / CHUNK_SPAN)));
|
|
54
|
+
return Array.from({ length: count }, (_, k) => group.slice(Math.round(k * group.length / count), Math.round((k + 1) * group.length / count)));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Extracts the frames shown at `times` (sorted, unique) in one decode from the keyframe before the first (or the
|
|
59
|
+
// origin, where `extract` would decode from there), encoding each shown frame once. The frame shown at a time needs
|
|
60
|
+
// the next frame's pts to recognize, so a demux-only packet listing guesses the shown pts and select keeps exactly
|
|
61
|
+
// those: every frame at a kept pts, as frames sharing one show the last decoded. showinfo before select reports every
|
|
62
|
+
// frame decoded up to the trim end, so each guess is checked against every frame that could be shown, including any
|
|
63
|
+
// the listing missed (stopping after as many frames as guesses could cut that evidence short). Returns Map time =>
|
|
64
|
+
// file for the verified times; the caller extracts the rest one by one.
|
|
65
|
+
async function extractChunk(video, info, times, width, crop, decoding, directory, prefix, produced) {
|
|
66
|
+
const slow = attempts(info, times[0])[0].slow;
|
|
67
|
+
const target = (time) => time + info.containerStart + TIME_TOLERANCE;
|
|
68
|
+
const end = target(times.at(-1));
|
|
69
|
+
const listed = [];
|
|
70
|
+
let untimed = false;
|
|
71
|
+
await lines('ffprobe', ['-v', 'error', '-select_streams', `v:${info.videoStream}`, '-read_intervals',
|
|
72
|
+
`${slow ? '' : Math.max(0, target(times[0]) - REORDER)}%${end + REORDER}`, '-show_entries', 'packet=pts,flags', '-of', 'csv=p=0', video],
|
|
73
|
+
'stdout', (line) => {
|
|
74
|
+
// Discarded packets (before an edit list start) decode to no frame.
|
|
75
|
+
const [, pts, flags] = /^(-?\d+|N\/A),(\S*)$/.exec(line.trim()) ?? [];
|
|
76
|
+
if (pts === 'N/A') untimed = true;
|
|
77
|
+
if (pts !== undefined && !flags.includes('D') && Number.isSafeInteger(Number(pts))) listed.push(Number(pts));
|
|
78
|
+
});
|
|
79
|
+
// The last pts at or before a time, as select's t compares it: pts times the time base as a double.
|
|
80
|
+
const shown = (candidates, tick, time) => candidates.reduce((best, pts) => (pts * tick <= target(time) && !(pts <= best) ? pts : best), undefined);
|
|
81
|
+
const guesses = [...new Set(times.map((time) => shown(listed, info[TICK], time)).filter((pts) => pts !== undefined))].sort((a, b) => a - b);
|
|
82
|
+
// Without every pts (AVI rebuilds reordered frames' pts from dts) guesses would miss; those times seek instead.
|
|
83
|
+
if (untimed || !guesses.length) return new Map();
|
|
84
|
+
const file = (k) => path.join(directory, `${prefix}-${String(k).padStart(4, '0')}.jpg`);
|
|
85
|
+
const kept = new Set(guesses);
|
|
86
|
+
const files = Array.from({ length: listed.filter((pts) => kept.has(pts)).length }, (_, k) => file(k));
|
|
87
|
+
produced.push(...files);
|
|
88
|
+
const decoded = [];
|
|
89
|
+
const encoded = [];
|
|
90
|
+
let timeBase;
|
|
91
|
+
await lines('ffmpeg', ['-hide_banner', '-nostats', '-loglevel', 'info', ...decoding, ...seekArgs(slow, times[0]), '-i', video,
|
|
92
|
+
'-map', `0:v:${info.videoStream}`, '-vf', `${trimEnd(info, end)},showinfo=checksum=0,select='${guesses.map((pts) => `eq(pts,${pts})`).join('+')}',showinfo=checksum=0,${filters(width, crop)}`,
|
|
93
|
+
'-fps_mode', 'passthrough', '-enc_time_base:v', '1', '-threads:v', '1', '-q:v', '4', '-start_number', '0', '-y',
|
|
94
|
+
path.join(directory, `${prefix}-%04d.jpg`)], 'stderr', (line) => {
|
|
95
|
+
const [, filter, rest] = /\[Parsed_showinfo_(\d+) @ [^\]]*\] (.*)/.exec(line) ?? [];
|
|
96
|
+
if (filter === undefined) return;
|
|
97
|
+
timeBase = /^config in time_base:\s*(\d+)\/(\d+)/.exec(rest)?.slice(1).map(Number) ?? timeBase;
|
|
98
|
+
const pts = /^n:\s*\d+\s+pts:\s*(-?\d+)\s/.exec(rest)?.[1];
|
|
99
|
+
if (pts !== undefined) (filter === '1' ? decoded : encoded).push(Number(pts));
|
|
100
|
+
});
|
|
101
|
+
// Frames the listing missed add files beyond those expected; the run's work directory is removed whole regardless.
|
|
102
|
+
for (let k = files.length; k < encoded.length; k++) produced.push(files[k] = file(k));
|
|
103
|
+
const result = new Map();
|
|
104
|
+
if (!timeBase?.[1]) return result;
|
|
105
|
+
for (const time of times) {
|
|
106
|
+
const pts = shown(decoded, timeBase[0] / timeBase[1], time);
|
|
107
|
+
const k = pts === undefined ? -1 : encoded.lastIndexOf(pts);
|
|
108
|
+
if (k >= 0 && k < files.length && await nonempty(files[k])) result.set(time, files[k]);
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Extracts the frame on screen at each of `times` into `directory`, at most `width` wide, and returns { file, time,
|
|
114
|
+
// timecode } entries in the order of `times`. Every file it expects to write is added to `produced` before
|
|
115
|
+
// FFmpeg starts. Equal times are extracted once and copied. Dense inspect times are decoded in chunks, choosing each
|
|
116
|
+
// shown frame before encoding; other times, and any a chunk could not verify, seek per time.
|
|
117
|
+
export async function extractFrames({ video, info, times, width, crop, directory, produced, command }) {
|
|
118
|
+
const unique = [...new Set(times)].sort((a, b) => a - b);
|
|
119
|
+
const cores = os.availableParallelism();
|
|
120
|
+
// Concurrent jobs share the cores between their decoders; one job keeps FFmpeg's automatic count. On 16 cores this
|
|
121
|
+
// made multi-frame runs 8-18% faster, while one decoder thread per job slowed long-GOP single frames 2x.
|
|
122
|
+
const share = (count) => {
|
|
123
|
+
const jobs = Math.min(count, cores, 8);
|
|
124
|
+
return { jobs, decoding: jobs > 1 ? ['-threads', String(Math.max(1, Math.floor(cores / jobs)))] : [] };
|
|
125
|
+
};
|
|
126
|
+
const names = times.map((time, i) => `frame-${String(i).padStart(4, '0')}_${formatTimecode(time).replaceAll(':', '-')}.jpg`);
|
|
127
|
+
const sources = new Map();
|
|
128
|
+
const chunks = command === 'inspect' ? denseChunks(info, unique) : [];
|
|
129
|
+
if (chunks.length) {
|
|
130
|
+
const { jobs, decoding } = share(chunks.length);
|
|
131
|
+
await pool(chunks, jobs, async (chunk, c) => {
|
|
132
|
+
let found;
|
|
133
|
+
try { found = await extractChunk(video, info, chunk, width, crop, decoding, directory, `.chunk-${String(c).padStart(3, '0')}`, produced); }
|
|
134
|
+
catch {
|
|
135
|
+
if (cancelled) throw cancellationError();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
for (const [time, file] of found) sources.set(time, file);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
const rest = unique.filter((time) => !sources.has(time));
|
|
142
|
+
const { jobs, decoding } = share(rest.length);
|
|
143
|
+
await pool(rest, jobs, async (time) => {
|
|
144
|
+
const target = path.join(directory, names[times.indexOf(time)]);
|
|
145
|
+
produced.push(target);
|
|
146
|
+
await extract(video, info, time, width, target, crop, decoding);
|
|
147
|
+
if (!(await nonempty(target))) throw new Error(`FFmpeg produced no frame for ${time}s`);
|
|
148
|
+
sources.set(time, target);
|
|
149
|
+
});
|
|
150
|
+
// The first entry showing a file takes it; later ones (equal times, or times sharing a shown frame) get copies.
|
|
151
|
+
const placed = new Map();
|
|
152
|
+
for (const [i, time] of times.entries()) {
|
|
153
|
+
const source = sources.get(time);
|
|
154
|
+
const target = path.join(directory, names[i]);
|
|
155
|
+
if (source !== target) {
|
|
156
|
+
produced.push(target);
|
|
157
|
+
if (placed.has(source)) await copyFile(placed.get(source), target);
|
|
158
|
+
else await rename(source, target);
|
|
159
|
+
}
|
|
160
|
+
if (!placed.has(source)) placed.set(source, target);
|
|
161
|
+
}
|
|
162
|
+
return times.map((time, i) => ({ file: names[i], time, timecode: formatTimecode(time) }));
|
|
163
|
+
}
|
package/src/output.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
async function findProjectRoot(cwd) {
|
|
7
|
+
for (let dir = cwd; ; dir = path.dirname(dir)) {
|
|
8
|
+
try { await stat(path.join(dir, '.git')); return dir; }
|
|
9
|
+
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error; }
|
|
10
|
+
if (path.dirname(dir) === dir) return cwd;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Removes the empty parents of `directory` that mkdir created, up to and including `first`.
|
|
15
|
+
async function removeParents(directory, first) {
|
|
16
|
+
if (!first) return;
|
|
17
|
+
for (let dir = path.dirname(directory); ; dir = path.dirname(dir)) {
|
|
18
|
+
try { await rmdir(dir); }
|
|
19
|
+
catch (error) { if (error.code !== 'ENOENT' && error.code !== 'ENOTEMPTY') throw error; }
|
|
20
|
+
if (dir === first || path.dirname(dir) === dir) return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const LOCK = '.agvid.lock';
|
|
25
|
+
const WORK = `${LOCK}.work-`;
|
|
26
|
+
|
|
27
|
+
// Claims `directory` for this run by exclusively creating a lock file holding its pid and host. Whichever run creates
|
|
28
|
+
// it first owns the directory. agvid never removes a lock it did not create: a killed run's lock stays until someone
|
|
29
|
+
// deletes it by hand, because no automatic staleness check is safe against pid reuse, other hosts and stalled runs.
|
|
30
|
+
async function claim(directory) {
|
|
31
|
+
const lock = path.join(directory, LOCK);
|
|
32
|
+
let handle;
|
|
33
|
+
try { handle = await open(lock, 'wx'); }
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error.code !== 'EEXIST') throw error;
|
|
36
|
+
const [pid, host] = (await readFile(lock, 'utf8').catch(() => '')).split('\n');
|
|
37
|
+
const holder = pid ? ` (pid ${pid} on ${host || 'an unknown host'})` : '';
|
|
38
|
+
throw Object.assign(new Error(`output directory is in use by another agvid run${holder}: ${directory}\n`
|
|
39
|
+
+ `agvid never removes a lock it did not create. If no agvid run is using it, delete ${lock} and any ${WORK}* `
|
|
40
|
+
+ 'directories beside it, then retry.'), { inUse: true });
|
|
41
|
+
}
|
|
42
|
+
try { await handle.writeFile(`${process.pid}\n${os.hostname()}\n`); }
|
|
43
|
+
catch (error) { await handle.close().catch(() => {}); await unlink(lock).catch(() => {}); throw error; }
|
|
44
|
+
await handle.close();
|
|
45
|
+
return lock;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Adds a work directory private to this run, where its FFmpeg writes. FFmpeg survives a SIGKILL of agvid, so finished
|
|
49
|
+
// files are moved into place only once this run's FFmpeg has exited; an orphan keeps writing only into its own run's
|
|
50
|
+
// work directory. Work left by killed runs is never removed automatically: its name alone does not prove agvid made it.
|
|
51
|
+
async function workspace(output) {
|
|
52
|
+
const work = path.join(output.directory, `${WORK}${randomUUID()}`);
|
|
53
|
+
try { await mkdir(work); }
|
|
54
|
+
catch (error) { await release(output, []); throw error; }
|
|
55
|
+
return { ...output, work };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Returns the claimed output directory: { directory, lock, work, created, firstParent }.
|
|
59
|
+
export async function outputDirectory(video, command, requested) {
|
|
60
|
+
if (requested) {
|
|
61
|
+
const directory = path.resolve(requested);
|
|
62
|
+
const firstParent = await mkdir(path.dirname(directory), { recursive: true });
|
|
63
|
+
let created = true;
|
|
64
|
+
try { await mkdir(directory); }
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error.code !== 'EEXIST') { await removeParents(directory, firstParent); throw error; }
|
|
67
|
+
created = false;
|
|
68
|
+
}
|
|
69
|
+
// A competing run that claimed the directory first owns it, even if this run created it. That run treats it as
|
|
70
|
+
// pre-existing, so if it fails the directory stays, empty; removing it here instead could race with its files.
|
|
71
|
+
const lock = await claim(directory).catch(async (error) => {
|
|
72
|
+
if (created && !error.inUse) await rmdir(directory).then(() => removeParents(directory, firstParent)).catch(() => {});
|
|
73
|
+
throw error;
|
|
74
|
+
});
|
|
75
|
+
const other = created ? [] : (await readdir(directory)).filter((name) => name !== LOCK);
|
|
76
|
+
if (other.length) {
|
|
77
|
+
await rm(lock, { force: true });
|
|
78
|
+
if (other.every((name) => name.startsWith(WORK))) {
|
|
79
|
+
throw new Error(`output directory holds work left by a killed agvid run: ${directory}\n`
|
|
80
|
+
+ `If no agvid run is using it, delete ${other.join(', ')} and retry.`);
|
|
81
|
+
}
|
|
82
|
+
throw new Error(`output directory is not empty: ${directory}`);
|
|
83
|
+
}
|
|
84
|
+
return workspace({ directory, lock, created, firstParent: created ? firstParent : undefined });
|
|
85
|
+
}
|
|
86
|
+
const agvid = path.join(await findProjectRoot(process.cwd()), '.agvid');
|
|
87
|
+
const root = path.join(agvid, 'runs');
|
|
88
|
+
try {
|
|
89
|
+
await mkdir(root, { recursive: true });
|
|
90
|
+
try { await writeFile(path.join(agvid, '.gitignore'), '*\n', { flag: 'wx' }); }
|
|
91
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(`cannot write output under ${agvid} (${error.code ?? error.message}); pass --output DIR`);
|
|
94
|
+
}
|
|
95
|
+
const base = `${path.parse(video).name.replace(/[^a-zA-Z0-9._-]/g, '_')}-${command}`;
|
|
96
|
+
for (let suffix = 0; ; suffix++) {
|
|
97
|
+
const directory = path.join(root, suffix ? `${base}-${suffix}` : base);
|
|
98
|
+
try {
|
|
99
|
+
await mkdir(directory);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.code === 'EEXIST') continue;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const lock = await claim(directory).catch(async (error) => { await rmdir(directory).catch(() => {}); throw error; });
|
|
105
|
+
return workspace({ directory, lock, created: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Deletes this run's files, then its lock, then the directory and parents it created if nothing else is in them.
|
|
110
|
+
export async function release({ directory, lock, work, created, firstParent }, produced) {
|
|
111
|
+
if (work) await rm(work, { recursive: true, force: true }).catch(() => {});
|
|
112
|
+
await Promise.all(produced.map((file) => rm(file, { force: true }).catch(() => {})));
|
|
113
|
+
await rm(lock, { force: true }).catch(() => {});
|
|
114
|
+
if (created) await rmdir(directory).then(() => removeParents(directory, firstParent)).catch(() => {});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function nonempty(filename) {
|
|
118
|
+
try { return (await stat(filename)).size > 0; }
|
|
119
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function saveManifest(directory, result) {
|
|
123
|
+
const filename = path.join(directory, 'manifest.json');
|
|
124
|
+
const temporary = path.join(directory, 'manifest.json.tmp');
|
|
125
|
+
await writeFile(temporary, `${JSON.stringify(result, null, 2)}\n`);
|
|
126
|
+
await rename(temporary, filename);
|
|
127
|
+
return filename;
|
|
128
|
+
}
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { displayedSize, parseCrop } from './geometry.js';
|
|
3
|
+
import { SOURCE_FORMAT, attempts } from './seek.js';
|
|
4
|
+
import { formatTimecode, parseTime } from './time.js';
|
|
5
|
+
|
|
6
|
+
const OPTIONS = {
|
|
7
|
+
overview: new Set(['frames', 'start', 'end', 'crop', 'width', 'output', 'video-stream']),
|
|
8
|
+
inspect: new Set(['around', 'window', 'fps', 'start', 'end', 'crop', 'width', 'output', 'video-stream']),
|
|
9
|
+
frame: new Set(['at', 'crop', 'width', 'output', 'video-stream']),
|
|
10
|
+
changes: new Set(['start', 'end', 'crop', 'threshold', 'min-gap', 'max', 'analysis-fps', 'analysis-width', 'analysis-budget', 'width', 'output', 'video-stream']),
|
|
11
|
+
probe: new Set(['video-stream']),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const MULTI = new Set(['at', 'around']);
|
|
15
|
+
|
|
16
|
+
const ANALYSIS_FPS = 30;
|
|
17
|
+
const ANALYSIS_WIDTH = 256;
|
|
18
|
+
// Seconds of source video changes may decode unless --analysis-budget allows more.
|
|
19
|
+
const ANALYSIS_BUDGET = 300;
|
|
20
|
+
|
|
21
|
+
function numberOption(value, name, min, max, integer = false) {
|
|
22
|
+
const number = Number(value);
|
|
23
|
+
if (!Number.isFinite(number) || number < min || number > max || (integer && !Number.isInteger(number))) {
|
|
24
|
+
throw new Error(`--${name} must be ${integer ? 'an integer' : 'a number'} from ${min} to ${max}`);
|
|
25
|
+
}
|
|
26
|
+
return number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function videoStreamOption(value) {
|
|
30
|
+
if (!/^(0|[1-9]\d*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) {
|
|
31
|
+
throw new Error('--video-stream must be a non-negative integer');
|
|
32
|
+
}
|
|
33
|
+
return Number(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolves optional --start/--end (TIME strings or seconds) to { start, end } seconds within the video's span on the
|
|
37
|
+
// container timeline.
|
|
38
|
+
export function resolveRange(options, info) {
|
|
39
|
+
const seconds = (value) => (typeof value === 'number' ? value : parseTime(value));
|
|
40
|
+
const start = options.start === undefined ? info.start : Math.max(info.start, seconds(options.start));
|
|
41
|
+
const end = options.end === undefined ? info.end : Math.min(info.end, seconds(options.end));
|
|
42
|
+
if (start >= info.end) throw new Error(`--start ${formatTimecode(start)} must be before the video ends`);
|
|
43
|
+
if (start >= end) throw new Error(`--start ${formatTimecode(start)} must be before --end ${formatTimecode(end)}`);
|
|
44
|
+
return { start, end };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Splits argv into { command, video | videos, options }; `help` is appended to usage errors.
|
|
48
|
+
export function parseArgs(args, help) {
|
|
49
|
+
const [command, ...rest] = args;
|
|
50
|
+
if (!OPTIONS[command]) throw new Error(`unknown command: ${command ?? ''}\n\n${help}`);
|
|
51
|
+
const options = {};
|
|
52
|
+
let video;
|
|
53
|
+
const videos = [];
|
|
54
|
+
for (let i = 0; i < rest.length; i++) {
|
|
55
|
+
const arg = rest[i];
|
|
56
|
+
if (arg.startsWith('--')) {
|
|
57
|
+
const equals = arg.indexOf('=');
|
|
58
|
+
const key = equals < 0 ? arg.slice(2) : arg.slice(2, equals);
|
|
59
|
+
const name = `--${key}`;
|
|
60
|
+
if (!OPTIONS[command].has(key)) throw new Error(`unknown option: ${name}`);
|
|
61
|
+
if (!MULTI.has(key) && options[key] !== undefined) throw new Error(`duplicate option: ${name}`);
|
|
62
|
+
const value = equals < 0 ? rest[++i] : arg.slice(equals + 1);
|
|
63
|
+
if (!value || (equals < 0 && value.startsWith('--'))) throw new Error(`missing value for ${name}`);
|
|
64
|
+
if (MULTI.has(key)) {
|
|
65
|
+
const items = value.split(',');
|
|
66
|
+
if (items.some((item) => !item)) throw new Error(`empty item in ${name}: ${value}`);
|
|
67
|
+
(options[key] ??= []).push(...items);
|
|
68
|
+
} else options[key] = value;
|
|
69
|
+
} else if (command === 'probe') videos.push(arg);
|
|
70
|
+
else if (!video) video = arg;
|
|
71
|
+
else throw new Error(`unexpected argument: ${arg}`);
|
|
72
|
+
}
|
|
73
|
+
if (command === 'probe') {
|
|
74
|
+
if (!videos.length) throw new Error(`missing video path\n\n${help}`);
|
|
75
|
+
return { command, videos: videos.map((file) => path.resolve(file)), options };
|
|
76
|
+
}
|
|
77
|
+
if (!video) throw new Error(`missing video path\n\n${help}`);
|
|
78
|
+
return { command, video: path.resolve(video), options };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validates a command's source-independent options (numbers, times, crop syntax and option combinations) before
|
|
82
|
+
// anything runs, and returns them parsed for planRun.
|
|
83
|
+
export function parseOptions(command, options) {
|
|
84
|
+
// A huge nominal frame checks --crop syntax; planRun checks its pixel size against the source.
|
|
85
|
+
if (options.crop !== undefined) parseCrop(options.crop, { width: 2 ** 30, height: 2 ** 30 });
|
|
86
|
+
const time = (name) => (options[name] === undefined ? undefined : parseTime(options[name]));
|
|
87
|
+
const parsed = { width: numberOption(options.width ?? 640, 'width', 64, 4096, true), start: time('start'), end: time('end') };
|
|
88
|
+
if (parsed.start >= parsed.end) throw new Error(`--start ${formatTimecode(parsed.start)} must be before --end ${formatTimecode(parsed.end)}`);
|
|
89
|
+
if (command === 'overview') parsed.count = numberOption(options.frames ?? 12, 'frames', 1, 64, true);
|
|
90
|
+
if (command === 'inspect') {
|
|
91
|
+
parsed.ranged = options.start !== undefined || options.end !== undefined;
|
|
92
|
+
if (parsed.ranged && (options.around !== undefined || options.window !== undefined)) throw new Error('--start/--end cannot be combined with --around/--window');
|
|
93
|
+
if (!parsed.ranged && options.around === undefined) throw new Error('inspect requires --around or --start/--end');
|
|
94
|
+
parsed.fps = numberOption(options.fps ?? 4, 'fps', 0.1, 60);
|
|
95
|
+
if (!parsed.ranged) {
|
|
96
|
+
parsed.arounds = [...new Set(options.around.map(parseTime))].sort((a, b) => a - b);
|
|
97
|
+
parsed.window = numberOption(parseTime(options.window ?? '2s'), 'window', 0.001, 3600);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (command === 'changes') {
|
|
101
|
+
const threshold = Number(options.threshold ?? 0.002);
|
|
102
|
+
if (!(threshold > 0 && threshold <= 1)) throw new Error('--threshold must be a fraction above 0 and at most 1');
|
|
103
|
+
parsed.settings = {
|
|
104
|
+
threshold,
|
|
105
|
+
minGap: numberOption(parseTime(options['min-gap'] ?? '0.5'), 'min-gap', 0, 3600),
|
|
106
|
+
// One of the 240 frames is the range-start baseline.
|
|
107
|
+
max: numberOption(options.max ?? 48, 'max', 1, 239, true),
|
|
108
|
+
analysisFps: numberOption(options['analysis-fps'] ?? ANALYSIS_FPS, 'analysis-fps', 1, 60),
|
|
109
|
+
analysisWidth: numberOption(options['analysis-width'] ?? ANALYSIS_WIDTH, 'analysis-width', 64, 512, true),
|
|
110
|
+
budget: numberOption(parseTime(options['analysis-budget'] ?? String(ANALYSIS_BUDGET)), 'analysis-budget', 1, 1e6),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (command === 'frame') {
|
|
114
|
+
if (options.at === undefined) throw new Error('frame requires --at');
|
|
115
|
+
// [time, as given] pairs, deduped by time and sorted.
|
|
116
|
+
parsed.at = [...new Map(options.at.map((value) => [parseTime(value), value]))].sort(([a], [b]) => a - b);
|
|
117
|
+
if (parsed.at.length > 240) throw new Error('frame would create over 240 frames; reduce --at values');
|
|
118
|
+
}
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Rejects a scan decoding source seconds `from` to `end` over `budget`. `origin` says why it decodes from the video
|
|
123
|
+
// start, where only --end narrows it.
|
|
124
|
+
export function checkBudget(from, end, budget, origin) {
|
|
125
|
+
if (end - from <= budget + 1e-9) return;
|
|
126
|
+
throw new Error(`changes would decode ${formatTimecode(end - from)} of video (${formatTimecode(from)} to ${formatTimecode(end)}`
|
|
127
|
+
+ `${origin ? `; ${origin}` : ''}), over --analysis-budget ${budget}s; narrow it with ${origin ? '--end' : '--start/--end'} or raise --analysis-budget`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Validates a command's options against the probed source and plans its output: { crop, width } plus, by command,
|
|
131
|
+
// overview { range, times }, inspect { times, windows, and range with --start/--end }, changes { range, settings }
|
|
132
|
+
// (times follow detection), frame { times }. Times are source seconds on the container timeline.
|
|
133
|
+
export function planRun(command, options, info, parsed = parseOptions(command, options)) {
|
|
134
|
+
const crop = options.crop === undefined ? undefined : parseCrop(options.crop, info);
|
|
135
|
+
// Cap at the displayed width so square-pixel output is never larger than the displayed frame or crop.
|
|
136
|
+
const displayedWidth = (crop?.pixels.displayed ?? displayedSize(info.width, info.height, info)).width;
|
|
137
|
+
const width = Math.min(displayedWidth, parsed.width);
|
|
138
|
+
if (command === 'overview') {
|
|
139
|
+
const { count } = parsed;
|
|
140
|
+
const range = resolveRange(parsed, info);
|
|
141
|
+
const { start, end } = range;
|
|
142
|
+
return { crop, width, range, times: Array.from({ length: count }, (_, i) => start + (end - start) * (i + 0.5) / count) };
|
|
143
|
+
}
|
|
144
|
+
if (command === 'inspect') {
|
|
145
|
+
const { fps, window } = parsed;
|
|
146
|
+
const range = parsed.ranged ? resolveRange(parsed, info) : undefined;
|
|
147
|
+
const spans = range ? [{ around: null, ...range }] : parsed.arounds.map((around) => {
|
|
148
|
+
if (around > info.end) throw new Error(`--around ${formatTimecode(around)} is beyond the video end ${formatTimecode(info.end)}`);
|
|
149
|
+
if (around < info.start) throw new Error(`--around ${formatTimecode(around)} is before the video starts at ${formatTimecode(info.start)}`);
|
|
150
|
+
return { around, start: Math.max(info.start, around - window / 2), end: Math.min(info.end, around + window / 2) };
|
|
151
|
+
});
|
|
152
|
+
const times = [];
|
|
153
|
+
const windows = [];
|
|
154
|
+
for (const { around, start, end } of spans) {
|
|
155
|
+
// Float noise in a centered window (1.4000000000000001 - 0.8) must not add a frame.
|
|
156
|
+
const count = Math.ceil((end - start) * fps - 1e-6);
|
|
157
|
+
if (count < 1) throw new Error('inspect window contains no frames');
|
|
158
|
+
if (times.length + count > 240) throw new Error('inspect would create over 240 frames; reduce --window, --fps or --around values');
|
|
159
|
+
const first = times.length;
|
|
160
|
+
for (let i = 0; i < count; i++) {
|
|
161
|
+
const segmentStart = start + i / fps;
|
|
162
|
+
times.push(segmentStart + Math.min(0.5 / fps, (end - segmentStart) / 2));
|
|
163
|
+
}
|
|
164
|
+
windows.push({ around, start, end, frames: [first, times.length - 1] });
|
|
165
|
+
}
|
|
166
|
+
return { crop, width, times, windows, ...(range ? { range } : {}) };
|
|
167
|
+
}
|
|
168
|
+
if (command === 'changes') {
|
|
169
|
+
const range = resolveRange(parsed, info);
|
|
170
|
+
// Scans decode from the video start where input seeking is unreliable: MPEG-TS, where only --end narrows them, or
|
|
171
|
+
// a range start near the video start. findChanges checks the budget again before falling back to the video start
|
|
172
|
+
// when input seeking finds no frame.
|
|
173
|
+
const ts = info[SOURCE_FORMAT].split(',').includes('mpegts');
|
|
174
|
+
const from = attempts(info, range.start)[0].slow ? info.start : range.start;
|
|
175
|
+
checkBudget(from, range.end, parsed.settings.budget, ts ? 'MPEG-TS is decoded from the video start' : undefined);
|
|
176
|
+
return { crop, width, range, settings: parsed.settings };
|
|
177
|
+
}
|
|
178
|
+
const times = parsed.at.map(([time, value]) => {
|
|
179
|
+
if (time >= info.end) throw new Error(`--at ${value} must be before the video ends at ${formatTimecode(info.end)}`);
|
|
180
|
+
if (time < info.start) throw new Error(`--at ${value} is before the video starts at ${formatTimecode(info.start)}`);
|
|
181
|
+
return time;
|
|
182
|
+
});
|
|
183
|
+
return { crop, width, times };
|
|
184
|
+
}
|