mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-17 16:23:33 -05:00
322 lines
9.5 KiB
TypeScript
322 lines
9.5 KiB
TypeScript
/** biome-ignore-all lint/suspicious/noConsole: CLI script output */
|
|
/**
|
|
* CLI equivalent of the VoD tab: scan a video file with the full detector
|
|
* registry and write the same events CSV the tab's Export menu downloads.
|
|
* ffmpeg decodes the video to raw RGBA frames piped through the
|
|
* DetectorScheduler + detectors, and every parse event goes through a
|
|
* TimelineBuilder with the tab's default merge/confidence options — so the
|
|
* CSV matches a browser scan of the same footage (minus the calm-stretch
|
|
* keyframe skimming, which only affects speed, not results).
|
|
*
|
|
* Requires ffmpeg (and ffprobe for the progress percentage) on PATH.
|
|
*
|
|
* Usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]
|
|
* --telemetry prints the VoD tab's ?telemetry=true scan counters after the
|
|
* run (per-detector gate/parse time, scheduling savings).
|
|
*/
|
|
import { spawn } from "node:child_process";
|
|
import { writeFileSync } from "node:fs";
|
|
import { basename } from "node:path";
|
|
import {
|
|
type CsvEvent,
|
|
eventsToCsv,
|
|
} from "../../app/features/scanner/components/events-csv";
|
|
import { loadOpenCV } from "../../app/features/scanner/core/cv";
|
|
import { MAP_START_EVENT_TYPE } from "../../app/features/scanner/core/detectors/map-start/index";
|
|
import {
|
|
createAllDetectors,
|
|
SCOREBOARD_EVENT_TYPES,
|
|
} from "../../app/features/scanner/core/detectors/registry";
|
|
import { DetectorScheduler } from "../../app/features/scanner/core/detectors/scheduler";
|
|
import {
|
|
createScanTelemetry,
|
|
detectorTelemetry,
|
|
} from "../../app/features/scanner/core/detectors/telemetry";
|
|
import { normalizeFrame, toMat } from "../../app/features/scanner/core/image";
|
|
import { TimelineBuilder } from "../../app/features/scanner/core/timeline/index";
|
|
import { loadScoreboardResources } from "../../app/features/scanner/node/resources";
|
|
|
|
const FRAME_WIDTH = 1920;
|
|
const FRAME_HEIGHT = 1080;
|
|
const FRAME_BYTES = FRAME_WIDTH * FRAME_HEIGHT * 4;
|
|
/** Slightly over the scheduler's densest cadence (refineIntervalS 0.15s). */
|
|
const DEFAULT_FPS = 8;
|
|
const PROGRESS_INTERVAL_SECONDS = 60;
|
|
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (!options) {
|
|
console.error(
|
|
"usage: pnpm scanner:scan-vod <video> [--fps 8] [--start T] [--duration S] [--out file.csv] [--telemetry]",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const { videoPath, fps, start, duration, outPath, collectTelemetry } = options;
|
|
|
|
await loadOpenCV();
|
|
const detectors = createAllDetectors(await loadScoreboardResources());
|
|
const scheduler = new DetectorScheduler(detectors, {
|
|
matchOpeningTypes: [MAP_START_EVENT_TYPE],
|
|
matchClosingTypes: SCOREBOARD_EVENT_TYPES,
|
|
});
|
|
scheduler.reset(start);
|
|
const timeline = new TimelineBuilder();
|
|
const telemetry = collectTelemetry ? createScanTelemetry() : null;
|
|
|
|
const totalSeconds = await probeDurationSeconds(videoPath);
|
|
const scanEnd =
|
|
duration !== undefined
|
|
? start + duration
|
|
: totalSeconds !== null
|
|
? totalSeconds
|
|
: null;
|
|
|
|
const ffmpeg = spawn(
|
|
"ffmpeg",
|
|
[
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
...(start > 0 ? ["-ss", String(start)] : []),
|
|
"-i",
|
|
videoPath,
|
|
...(duration !== undefined ? ["-t", String(duration)] : []),
|
|
"-vf",
|
|
`fps=${fps},scale=${FRAME_WIDTH}:${FRAME_HEIGHT}`,
|
|
"-f",
|
|
"rawvideo",
|
|
"-pix_fmt",
|
|
"rgba",
|
|
"pipe:1",
|
|
],
|
|
{ stdio: ["ignore", "pipe", "inherit"] },
|
|
);
|
|
|
|
const frameBuffer = Buffer.alloc(FRAME_BYTES);
|
|
let frameFill = 0;
|
|
let frameIndex = 0;
|
|
let framesAnalyzed = 0;
|
|
let nextProgressT = start;
|
|
const startedAt = Date.now();
|
|
|
|
for await (const chunk of ffmpeg.stdout) {
|
|
let offset = 0;
|
|
while (offset < chunk.length) {
|
|
const take = Math.min(FRAME_BYTES - frameFill, chunk.length - offset);
|
|
chunk.copy(frameBuffer, frameFill, offset, offset + take);
|
|
frameFill += take;
|
|
offset += take;
|
|
if (frameFill < FRAME_BYTES) continue;
|
|
frameFill = 0;
|
|
processFrame(start + frameIndex / fps);
|
|
frameIndex++;
|
|
}
|
|
}
|
|
const exitCode = await new Promise<number | null>((resolve) =>
|
|
ffmpeg.on("close", resolve),
|
|
);
|
|
if (exitCode !== 0) {
|
|
console.error(`ffmpeg exited with code ${exitCode}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const csv = eventsToCsv(timeline.events as CsvEvent[]);
|
|
writeFileSync(outPath, csv);
|
|
printSummary();
|
|
|
|
function parseArgs(argv: string[]): {
|
|
videoPath: string;
|
|
fps: number;
|
|
start: number;
|
|
duration: number | undefined;
|
|
outPath: string;
|
|
collectTelemetry: boolean;
|
|
} | null {
|
|
let videoPath: string | undefined;
|
|
let fps = DEFAULT_FPS;
|
|
let start = 0;
|
|
let duration: number | undefined;
|
|
let outPath: string | undefined;
|
|
let collectTelemetry = false;
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i]!;
|
|
if (arg === "--fps") fps = Number(argv[++i]);
|
|
else if (arg === "--start") start = Number(argv[++i]);
|
|
else if (arg === "--duration") duration = Number(argv[++i]);
|
|
else if (arg === "--out") outPath = argv[++i];
|
|
else if (arg === "--telemetry") collectTelemetry = true;
|
|
else if (!arg.startsWith("--") && videoPath === undefined) videoPath = arg;
|
|
else return null;
|
|
}
|
|
if (
|
|
videoPath === undefined ||
|
|
Number.isNaN(fps) ||
|
|
fps <= 0 ||
|
|
Number.isNaN(start) ||
|
|
(duration !== undefined && Number.isNaN(duration))
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
videoPath,
|
|
fps,
|
|
start,
|
|
duration,
|
|
outPath:
|
|
outPath ?? `${basename(videoPath).replace(/\.[^.]+$/, "")}-events.csv`,
|
|
collectTelemetry,
|
|
};
|
|
}
|
|
|
|
function probeDurationSeconds(path: string): Promise<number | null> {
|
|
return new Promise((resolve) => {
|
|
const ffprobe = spawn(
|
|
"ffprobe",
|
|
[
|
|
"-v",
|
|
"error",
|
|
"-show_entries",
|
|
"format=duration",
|
|
"-of",
|
|
"default=noprint_wrappers=1:nokey=1",
|
|
path,
|
|
],
|
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
);
|
|
let output = "";
|
|
ffprobe.stdout.on("data", (data) => {
|
|
output += data;
|
|
});
|
|
ffprobe.on("close", () => {
|
|
const seconds = Number.parseFloat(output.trim());
|
|
resolve(Number.isFinite(seconds) ? seconds : null);
|
|
});
|
|
ffprobe.on("error", () => resolve(null));
|
|
});
|
|
}
|
|
|
|
function processFrame(t: number): void {
|
|
if (t >= nextProgressT) {
|
|
const percent =
|
|
scanEnd === null
|
|
? ""
|
|
: ` (${Math.round(((t - start) / (scanEnd - start)) * 100)}%)`;
|
|
const rate = (t - start) / Math.max(0.001, (Date.now() - startedAt) / 1000);
|
|
console.error(
|
|
`scanning t=${Math.round(t)}s${percent} · ${rate.toFixed(1)}x realtime · ${timeline.events.length} events`,
|
|
);
|
|
nextProgressT += PROGRESS_INTERVAL_SECONDS;
|
|
}
|
|
|
|
if (telemetry) {
|
|
telemetry.decodedFrames++;
|
|
telemetry.activeVideoS += 1 / fps;
|
|
}
|
|
const due = scheduler.dueDetectors(t);
|
|
if (due.length === 0) return;
|
|
framesAnalyzed++;
|
|
if (telemetry) telemetry.analyzedFrames++;
|
|
const src = toMat({
|
|
width: FRAME_WIDTH,
|
|
height: FRAME_HEIGHT,
|
|
data: new Uint8ClampedArray(
|
|
frameBuffer.buffer,
|
|
frameBuffer.byteOffset,
|
|
FRAME_BYTES,
|
|
),
|
|
});
|
|
const frame = normalizeFrame(src);
|
|
src.delete();
|
|
for (const detector of detectors) {
|
|
if (!due.includes(detector.id)) continue;
|
|
const counters = telemetry
|
|
? detectorTelemetry(telemetry, detector.id)
|
|
: null;
|
|
const gateStart = counters ? performance.now() : 0;
|
|
const gate = detector.gate(frame);
|
|
if (counters) {
|
|
counters.checks++;
|
|
counters.gateMs += performance.now() - gateStart;
|
|
}
|
|
scheduler.recordGate(detector.id, t, gate.pass, gate.signature);
|
|
if (!gate.pass) continue;
|
|
if (counters) counters.gatePasses++;
|
|
if (!scheduler.shouldParse(detector.id, t)) {
|
|
if (counters) counters.suppressedParses++;
|
|
continue;
|
|
}
|
|
const parseStart = counters ? performance.now() : 0;
|
|
const events = detector.parse(frame, t, gate);
|
|
if (counters) {
|
|
counters.parses++;
|
|
counters.parseMs += performance.now() - parseStart;
|
|
}
|
|
scheduler.recordParse(detector.id, t, events);
|
|
for (const event of events) {
|
|
const action = timeline.push(event);
|
|
if (action.action === "added" || action.action === "replaced") {
|
|
console.error(
|
|
` event t=${event.t.toFixed(2)} ${event.type} conf=${event.confidence.toFixed(3)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
frame.delete();
|
|
}
|
|
|
|
function printSummary(): void {
|
|
const counts = new Map<string, number>();
|
|
for (const event of timeline.events) {
|
|
counts.set(event.type, (counts.get(event.type) ?? 0) + 1);
|
|
}
|
|
const countText =
|
|
[...counts.entries()].map(([type, n]) => `${type} ${n}`).join(", ") ||
|
|
"none";
|
|
console.error(
|
|
`analyzed ${framesAnalyzed}/${frameIndex} decoded frames in ${Math.round((Date.now() - startedAt) / 1000)}s`,
|
|
);
|
|
console.error(`timeline events: ${timeline.events.length} (${countText})`);
|
|
console.error(`wrote ${outPath}`);
|
|
if (telemetry) printTelemetry();
|
|
console.error(`next: pnpm scanner:status-audit ${outPath}`);
|
|
}
|
|
|
|
/** The VoD tab's ?telemetry=true panel, as an aligned stderr table. */
|
|
function printTelemetry(): void {
|
|
if (!telemetry) return;
|
|
telemetry.wallMs = Date.now() - startedAt;
|
|
console.error(
|
|
`telemetry · analyzed ${telemetry.analyzedFrames}/${telemetry.decodedFrames} decoded frames · ${(telemetry.wallMs / 1000).toFixed(1)}s wall · ${telemetry.activeVideoS.toFixed(0)}s video covered (dense, no skim in CLI)`,
|
|
);
|
|
const header = [
|
|
"detector",
|
|
"checks",
|
|
"gate pass",
|
|
"gate ms",
|
|
"parses",
|
|
"parse ms",
|
|
"suppressed",
|
|
];
|
|
const rows = Object.entries(telemetry.detectors)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([id, d]) => [
|
|
id,
|
|
String(d.checks),
|
|
String(d.gatePasses),
|
|
String(Math.round(d.gateMs)),
|
|
String(d.parses),
|
|
String(Math.round(d.parseMs)),
|
|
String(d.suppressedParses),
|
|
]);
|
|
const widths = header.map((h, i) =>
|
|
Math.max(h.length, ...rows.map((row) => row[i]!.length)),
|
|
);
|
|
const line = (cells: string[]) =>
|
|
cells
|
|
.map((cell, i) =>
|
|
i === 0 ? cell.padEnd(widths[i]!) : cell.padStart(widths[i]!),
|
|
)
|
|
.join(" ");
|
|
console.error(line(header));
|
|
for (const row of rows) console.error(line(row));
|
|
}
|