mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
321
apps/web-react/scripts/scanner/scan-vod.ts
Normal file
321
apps/web-react/scripts/scanner/scan-vod.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/** 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));
|
||||
}
|
||||
Reference in New Issue
Block a user