85453da49f
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
174 lines
6.2 KiB
JavaScript
174 lines
6.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/* check-timing.cjs — verify plan.json word timings vs transcript.json (1:1 port of check-timing.py).
|
||
* node check-timing.cjs <project-dir> [--strict]
|
||
*/
|
||
const path = require("path");
|
||
const fs = require("fs");
|
||
|
||
const DRIFT_TOL = 0.08;
|
||
const CREATIVE_SUBS = {
|
||
"15%": ["fifteen", "percent"],
|
||
"1/3": ["one", "third"],
|
||
"2x": ["two", "times"],
|
||
};
|
||
const norm = (s) =>
|
||
String(s)
|
||
.replace(/^[ .,?!"']+|[ .,?!"']+$/g, "")
|
||
.toLowerCase();
|
||
const splitPacked = (t) =>
|
||
String(t)
|
||
.split(/<br>|\s+/)
|
||
.filter(Boolean);
|
||
|
||
function estimateVerticalBand(css, words, fw, fh) {
|
||
const top = css.match(/top:\s*(-?[\d.]+)%/);
|
||
if (!top) return null;
|
||
const topPct = parseFloat(top[1]);
|
||
const sizeM = css.match(/font-size:\s*calc\(\s*([\d.]+)\s*\*\s*var\(--h\)\s*\)/);
|
||
const fontPx = sizeM ? parseFloat(sizeM[1]) * fh : 0.05 * fh;
|
||
const lhM = css.match(/line-height:\s*([\d.]+)/);
|
||
const lh = lhM ? parseFloat(lhM[1]) : 1.1;
|
||
const charW = /uppercase/i.test(css) ? 0.72 : 0.6;
|
||
let lineCount = 1,
|
||
cur = 0;
|
||
const spaceW = fontPx * 0.3;
|
||
for (const w of words) {
|
||
const parts = String(w.text).split(/<br>/);
|
||
parts.forEach((part, i) => {
|
||
const pw = part.length * fontPx * charW;
|
||
if (i > 0) {
|
||
lineCount++;
|
||
cur = pw;
|
||
} else {
|
||
const test = cur + (cur > 0 ? spaceW : 0) + pw;
|
||
if (test > fw && cur > 0) {
|
||
lineCount++;
|
||
cur = pw;
|
||
} else cur = test;
|
||
}
|
||
});
|
||
}
|
||
return [topPct, topPct + (100 * (lineCount * fontPx * lh)) / fh];
|
||
}
|
||
|
||
function check(project, strict) {
|
||
const plan = JSON.parse(fs.readFileSync(path.join(project, "plan.json"), "utf8"));
|
||
const t = JSON.parse(fs.readFileSync(path.join(project, "transcript.json"), "utf8"));
|
||
const seq = (t.words || [])
|
||
.filter((w) => w.type === "word")
|
||
.map((w) => [norm(w.text), w.start, w.end]);
|
||
const issues = [];
|
||
const fw = plan.width || 720,
|
||
fh = plan.height || 1290;
|
||
const groups = plan.groups || [];
|
||
const bands = groups.map((g) =>
|
||
g.plane
|
||
? [g.id || "?", g.in, g.out, null, true]
|
||
: [
|
||
g.id || "?",
|
||
g.in,
|
||
g.out,
|
||
estimateVerticalBand(g.css || "", g.words || [], fw, fh),
|
||
g.allow_overlap || false,
|
||
],
|
||
);
|
||
for (let i = 0; i < bands.length; i++) {
|
||
const [gi, ai, bi, bbi, oki] = bands[i];
|
||
if (!bbi) continue;
|
||
for (let j = i + 1; j < bands.length; j++) {
|
||
const [gj, aj, bj, bbj, okj] = bands[j];
|
||
if (!bbj || oki || okj) continue;
|
||
if (Math.min(bi, bj) - Math.max(ai, aj) <= 0.05) continue;
|
||
if (Math.min(bbi[1], bbj[1]) - Math.max(bbi[0], bbj[0]) > 2.0)
|
||
issues.push(
|
||
`[${gi}↔${gj}] groups overlap in time (${Math.max(ai, aj).toFixed(2)}–${Math.min(bi, bj).toFixed(2)}s) AND vertically (band ${Math.max(bbi[0], bbj[0]).toFixed(0)}%–${Math.min(bbi[1], bbj[1]).toFixed(0)}%) — reposition one or add "allow_overlap": true if deliberate.`,
|
||
);
|
||
}
|
||
}
|
||
let ti = 0;
|
||
for (const g of groups) {
|
||
const gid = g.id || "?",
|
||
gin = g.in,
|
||
gout = g.out;
|
||
const ws = (g.words || []).map((w) => w.start),
|
||
we = (g.words || []).map((w) => w.end);
|
||
if (ws.length && gin != null) {
|
||
const e = Math.min(...ws);
|
||
if (e < gin - 0.01)
|
||
issues.push(
|
||
`[${gid}] group.in=${gin.toFixed(2)} but earliest word starts at ${e.toFixed(2)} — word delayed by ${(gin - e >= 0 ? "+" : "") + (gin - e).toFixed(2)}s. Lower group.in.`,
|
||
);
|
||
}
|
||
if (we.length && gout != null) {
|
||
const l = Math.max(...we);
|
||
if (l > gout + 0.01)
|
||
issues.push(
|
||
`[${gid}] group.out=${gout.toFixed(2)} but latest word ends at ${l.toFixed(2)} — word clipped. Raise group.out.`,
|
||
);
|
||
}
|
||
for (const w of g.words || []) {
|
||
// Exact pairing: a compiler that KNOWS the transcript index emits `ti` —
|
||
// duplicate words then pair by POSITION, not text-search (which grabs the
|
||
// first occurrence and reports a phantom drift). Sanity-check the text; on
|
||
// mismatch fall back to the text matcher below.
|
||
if (
|
||
Number.isInteger(w.ti) &&
|
||
seq[w.ti] &&
|
||
seq[w.ti][0] === norm(splitPacked(w.text)[0] || "")
|
||
) {
|
||
const drift = w.start - seq[w.ti][1];
|
||
if (Math.abs(drift) > DRIFT_TOL)
|
||
issues.push(
|
||
`[${gid}] '${norm(w.text)}': plan=${w.start.toFixed(3)} transcript=${seq[w.ti][1].toFixed(3)} drift ${(drift >= 0 ? "+" : "") + drift.toFixed(3)}s`,
|
||
);
|
||
ti = w.ti + 1;
|
||
continue;
|
||
}
|
||
const parts = splitPacked(w.text).map(norm);
|
||
parts.forEach((part, pi) => {
|
||
if (!part) return;
|
||
if (part in CREATIVE_SUBS) {
|
||
for (const sub of CREATIVE_SUBS[part]) {
|
||
const j = seq.findIndex((s, k) => k >= ti && s[0] === sub);
|
||
if (j >= 0) ti = j + 1;
|
||
}
|
||
return;
|
||
}
|
||
let found = seq.findIndex((s, k) => k >= ti && s[0] === part);
|
||
if (found < 0) found = seq.findIndex((s) => s[0] === part);
|
||
if (found < 0) {
|
||
issues.push(`[${gid}] '${part}': NOT IN TRANSCRIPT (plan start=${w.start.toFixed(3)})`);
|
||
return;
|
||
}
|
||
const ts = seq[found][1];
|
||
ti = found + 1;
|
||
if (pi === 0) {
|
||
const drift = w.start - ts;
|
||
if (Math.abs(drift) > DRIFT_TOL)
|
||
issues.push(
|
||
`[${gid}] '${part}': plan=${w.start.toFixed(3)} transcript=${ts.toFixed(3)} drift ${(drift >= 0 ? "+" : "") + drift.toFixed(3)}s`,
|
||
);
|
||
} else
|
||
issues.push(
|
||
`[${gid}] '${part}' packed with '${parts[0]}': transcript=${ts.toFixed(3)} but entry has no distinct timing — split into separate word entries`,
|
||
);
|
||
});
|
||
}
|
||
}
|
||
const name = path.basename(project);
|
||
if (!issues.length) {
|
||
console.log(`${name}: timing OK ✓ (${seq.length} transcript words)`);
|
||
return 0;
|
||
}
|
||
console.log(`${name}: ${issues.length} timing issue(s):`);
|
||
for (const i of issues) console.log(` ${i}`);
|
||
return strict ? 1 : 0;
|
||
}
|
||
|
||
const args = process.argv.slice(2).filter((a) => a !== "--strict");
|
||
if (!args.length) {
|
||
console.error("usage: check-timing.cjs <project-dir> [--strict]");
|
||
process.exit(2);
|
||
}
|
||
process.exit(check(path.resolve(args[0]), process.argv.includes("--strict")));
|