@litfamily/litgrok 1.0.26 → 1.0.28
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/.grok/hooks/lit-mark.mjs +42 -0
- package/.grok/hooks/lit-status-line.mjs +174 -21
- package/.grok/hooks/plan-gate.mjs +13 -3
- package/.grok/hooks/record-passive-event.mjs +40 -6
- package/.grok/hooks/session-end.json +15 -0
- package/.grok/hooks/session-end.mjs +6 -0
- package/.grok/hooks/session-liveness.mjs +102 -0
- package/.grok/hooks/session-rows.mjs +57 -0
- package/.grok/hooks/session-start.mjs +17 -0
- package/.grok/hooks/workflow-stage.mjs +5 -2
- package/.grok/rules/00-litgrok.md +14 -0
- package/.grok/skills/frontend-ui-ux/ATTRIBUTION.md +23 -0
- package/.grok/skills/frontend-ui-ux/SKILL.md +4 -1
- package/.grok/skills/frontend-ui-ux/examples/phase-3-readme-ab/README.md +9 -0
- package/.grok/skills/frontend-ui-ux/kit/components.css +414 -0
- package/.grok/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/.grok/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/.grok/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/.grok/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/.grok/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/.grok/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/.grok/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/.grok/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/.grok/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/.grok/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/.grok/skills/frontend-ui-ux/references/adaptive-layout.md +10 -4
- package/.grok/skills/frontend-ui-ux/references/brand-and-imagery.md +11 -7
- package/.grok/skills/frontend-ui-ux/references/complete-contract.md +21 -0
- package/.grok/skills/frontend-ui-ux/references/composition.md +10 -6
- package/.grok/skills/frontend-ui-ux/references/craft-floor.md +256 -93
- package/.grok/skills/frontend-ui-ux/references/creative-directions.md +9 -6
- package/.grok/skills/frontend-ui-ux/references/default-editorial-pixel.json +28 -0
- package/.grok/skills/frontend-ui-ux/references/house-styles.md +234 -0
- package/.grok/skills/frontend-ui-ux/references/implementation-platforms.md +10 -6
- package/.grok/skills/frontend-ui-ux/references/inclusive-interface.md +11 -5
- package/.grok/skills/frontend-ui-ux/references/interaction-motion.md +34 -78
- package/.grok/skills/frontend-ui-ux/references/motion-guide.md +10 -7
- package/.grok/skills/frontend-ui-ux/references/performance-delivery.md +11 -8
- package/.grok/skills/frontend-ui-ux/references/probe-loop.md +1 -1
- package/.grok/skills/frontend-ui-ux/references/product-direction.md +9 -4
- package/.grok/skills/frontend-ui-ux/references/production-interview.md +17 -0
- package/.grok/skills/frontend-ui-ux/references/redesign-playbook.md +10 -6
- package/.grok/skills/frontend-ui-ux/references/slop-register.md +148 -73
- package/.grok/skills/frontend-ui-ux/references/system-foundations.md +10 -4
- package/.grok/skills/frontend-ui-ux/references/visual-language.md +10 -4
- package/.grok/skills/frontend-ui-ux/references/visual-reconstruction.md +39 -72
- package/.grok/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/.grok/skills/frontend-ui-ux/scripts/interface-probe-driver.mjs +33 -0
- package/.grok/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
- package/.grok/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +1555 -0
- package/.grok/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +153 -0
- package/.grok/skills/frontend-ui-ux/scripts/interface-probe-server.mjs +57 -0
- package/.grok/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +139 -0
- package/.grok/skills/frontend-ui-ux/scripts/interface-probe.mjs +363 -0
- package/.grok/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/.grok/skills/lit-adhd/SKILL.md +155 -0
- package/.grok/skills/lit-adhd/references/card-examples.md +131 -0
- package/.grok/skills/lit-adhd/references/status-taxonomy.md +50 -0
- package/.grok/skills/lit-adhd/scripts/lib/brief.mjs +421 -0
- package/.grok/skills/lit-adhd/scripts/lib/collect.mjs +395 -0
- package/.grok/skills/lit-adhd/scripts/lib/gauge.mjs +87 -0
- package/.grok/skills/lit-adhd/scripts/lib/next-step.mjs +22 -0
- package/.grok/skills/lit-adhd/scripts/lib/plan-task-rows.mjs +32 -0
- package/.grok/skills/lit-adhd/scripts/lib/project-state-root.mjs +55 -0
- package/.grok/skills/lit-adhd/scripts/lib/redact.mjs +64 -0
- package/.grok/skills/lit-adhd/scripts/lib/suggest.mjs +140 -0
- package/.grok/skills/lit-adhd/scripts/lib/transcripts.mjs +232 -0
- package/.grok/skills/lit-adhd/scripts/rewind.mjs +6 -0
- package/.grok/skills/lit-diagram-drawer/SKILL.md +17 -5
- package/.grok/skills/lit-diagram-drawer/references/output-spec.md +2 -2
- package/.grok/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/.grok/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/.grok/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/.grok/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/.grok/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/.grok/skills/lit-diagram-drawer/references/verifier-guide.md +4 -0
- package/.grok/skills/lit-diagram-drawer/scripts/visual-quality.mjs +2 -2
- package/.grok/skills/lit-handoff/SKILL.md +2 -0
- package/.grok/skills/lit-hijack/SKILL.md +459 -0
- package/.grok/skills/lit-hijack/references/capture-and-provenance.md +124 -0
- package/.grok/skills/lit-hijack/references/clean-room-build.md +130 -0
- package/.grok/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/.grok/skills/lit-hijack/references/gap-and-port-design.md +150 -0
- package/.grok/skills/lit-hijack/references/parity-and-report.md +130 -0
- package/.grok/skills/lit-hijack/scripts/hijack.mjs +890 -0
- package/.grok/skills/lit-hijack/scripts/lib/safe-fetch.mjs +213 -0
- package/.grok/skills/lit-plan/SKILL.md +3 -1
- package/.grok/skills/lit-plan/references/plan-schema.md +4 -0
- package/.grok/skills/lit-plan/scripts/scaffold-plan.mjs +9 -2
- package/.grok/skills/lit-plan/scripts/scope-roots.mjs +83 -0
- package/.grok/skills/lit-pptx/SKILL.md +10 -5
- package/.grok/skills/lit-pptx/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/.grok/skills/lit-pptx/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/.grok/skills/lit-pptx/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/.grok/skills/lit-pptx/examples/16-community-programme-riso-ko.md +158 -0
- package/.grok/skills/lit-pptx/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/.grok/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/.grok/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/.grok/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/.grok/skills/lit-pptx/fonts/jetbrains-mono/provenance.json +1 -0
- package/.grok/skills/lit-pptx/references/direction-step.md +34 -8
- package/.grok/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/.grok/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/.grok/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/.grok/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/.grok/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/.grok/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/.grok/skills/lit-pptx/scripts/craft_extras.py +61 -3
- package/.grok/skills/lit-pptx/scripts/deck_output.py +76 -62
- package/.grok/skills/lit-pptx/scripts/embed_fonts.py +14 -1
- package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +389 -582
- package/.grok/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +60 -8
- package/.grok/skills/lit-pptx/scripts/qa_deck.py +4 -4
- package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +2 -0
- package/.grok/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +2 -0
- package/.grok/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +2 -0
- package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -0
- package/.grok/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/.grok/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +2 -0
- package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +2 -0
- package/.grok/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +3 -0
- package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +2 -0
- package/.grok/skills/lit-recap/SKILL.md +1 -1
- package/.grok/skills/lit-schematic/SKILL.md +140 -0
- package/.grok/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/.grok/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/.grok/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/.grok/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/.grok/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/.grok/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/.grok/skills/lit-schematic/references/journal-figures.md +72 -0
- package/.grok/skills/lit-schematic/references/kit-api.md +134 -0
- package/.grok/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/.grok/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/.grok/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/.grok/skills/lit-schematic/references/verification.md +100 -0
- package/.grok/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/.grok/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/.grok/skills/lit-schematic/scripts/lib/fonts.mjs +27 -0
- package/.grok/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/.grok/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/.grok/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/.grok/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/.grok/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/.grok/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/.grok/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/.grok/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/.grok/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/.grok/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/.grok/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/.grok/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/.grok/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/.grok/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/.grok/skills/lit-schematic/scripts/render.mjs +233 -0
- package/.grok/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/.grok/skills/lit-scientific-visualization/SKILL.md +36 -12
- package/.grok/skills/lit-typographic-motion/SKILL.md +7 -4
- package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +11 -0
- package/.grok/skills/lit-typographic-motion/recipes/hero-3d/expected.json +62 -0
- package/.grok/skills/lit-typographic-motion/recipes/hero-3d/index.html +82 -0
- package/.grok/skills/lit-typographic-motion/references/choice-first.md +30 -0
- package/.grok/skills/lit-typographic-motion/references/cover.md +10 -1
- package/.grok/skills/lit-typographic-motion/references/depth.md +119 -0
- package/.grok/skills/lit-typographic-motion/references/treatment.md +9 -1
- package/.grok/skills/lit-typographic-motion/scripts/blender/runner.py +218 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/assets.mjs +230 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/blender.mjs +136 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/board.mjs +153 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/chrome.mjs +61 -32
- package/.grok/skills/lit-typographic-motion/scripts/lib/constants.mjs +4 -2
- package/.grok/skills/lit-typographic-motion/scripts/lib/cover-capture.mjs +20 -11
- package/.grok/skills/lit-typographic-motion/scripts/lib/cover-treatment.mjs +26 -2
- package/.grok/skills/lit-typographic-motion/scripts/lib/cover.mjs +12 -5
- package/.grok/skills/lit-typographic-motion/scripts/lib/frame-pool.mjs +78 -1
- package/.grok/skills/lit-typographic-motion/scripts/lib/gallery.mjs +160 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/house.mjs +43 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/look.mjs +39 -1
- package/.grok/skills/lit-typographic-motion/scripts/lib/scaffold.mjs +174 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/score.mjs +640 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/sound.mjs +143 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/soundtrack.mjs +12 -3
- package/.grok/skills/lit-typographic-motion/scripts/lib/stage-gate.mjs +44 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/stage-qa.mjs +132 -3
- package/.grok/skills/lit-typographic-motion/scripts/lib/stage-session.mjs +51 -3
- package/.grok/skills/lit-typographic-motion/scripts/lib/stage.mjs +74 -9
- package/.grok/skills/lit-typographic-motion/scripts/lib/styles.mjs +224 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/treatment.mjs +198 -4
- package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/LICENSE +21 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/.grok/skills/lit-typographic-motion/scripts/lib/vendor/three/three.module.js +54571 -0
- package/.grok/skills/lit-typographic-motion/scripts/motion.mjs +56 -6
- package/.grok/skills/lit-typographic-motion/scripts/stage/cover-init.js +20 -8
- package/.grok/skills/lit-typographic-motion/scripts/stage/loop-kit.js +2 -2
- package/.grok/skills/lit-typographic-motion/scripts/stage/stage-kit.js +25 -0
- package/.grok/skills/lit-typographic-motion/styles/paper.json +1 -0
- package/.grok/skills/lit-typographic-motion/styles/swiss-signal.json +1 -0
- package/.grok/skills/lit-typographic-motion/styles/terminalcore.json +1 -0
- package/.grok/skills/lit-typographic-motion/styles/tidal.json +1 -0
- package/.grok/skills/litgrok/SKILL.md +1 -1
- package/.grok/skills/litresearch/SKILL.md +2 -0
- package/.grok/skills/readme-studio/SKILL.md +14 -12
- package/.grok/skills/readme-studio/references/choice-first.md +24 -0
- package/.grok/skills/start-work/SKILL.md +8 -1
- package/.grok-plugin/plugin.json +4 -1
- package/CHANGELOG.md +18 -0
- package/README.md +11 -11
- package/README_ko-KR.md +11 -11
- package/TRADEMARK.md +12 -0
- package/bin/litgrok.mjs +2 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/showcase/brief.webp +0 -0
- package/docs/assets/showcase/deck.webp +0 -0
- package/docs/assets/showcase/page.webp +0 -0
- package/docs/assets/showcase/pid.webp +0 -0
- package/docs/assets/skills/lit-adhd.webp +0 -0
- package/docs/assets/skills/lit-hijack.webp +0 -0
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference.md +11 -7
- package/docs/reference_ko-KR.md +11 -7
- package/hooks/hooks.json +11 -0
- package/package.json +6 -1
- package/plugin.json +1 -1
- package/.grok/skills/frontend-ui-ux/scripts/page-probe.mjs +0 -105
- package/.grok/skills/frontend-ui-ux/scripts/probe.mjs +0 -91
- package/.grok/skills/frontend-ui-ux/scripts/rule-data.mjs +0 -56
- package/.grok/skills/frontend-ui-ux/scripts/static-probe.mjs +0 -27
- package/.grok/skills/frontend-ui-ux/scripts/static-server.mjs +0 -19
|
@@ -372,3 +372,146 @@ export function audioGate({ mp4, videoDurationSec, mode, ffmpeg, ffprobe }) {
|
|
|
372
372
|
const detail = `${mode} track ${stats.durationSec} s, peak ${stats.peakDbfs} dBFS, quiet opening ${stats.quietOpeningSec} s${problems.length ? `; ${problems.join('; ')}` : ''}`;
|
|
373
373
|
return done(status, detail, exit20, stats);
|
|
374
374
|
}
|
|
375
|
+
|
|
376
|
+
// ---- layered score primitives ------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
// A normalized single-cycle table of additive partials (TABLE + 1 samples, wrapped).
|
|
379
|
+
const TABLE = 4096;
|
|
380
|
+
export { TABLE as WAVETABLE_SIZE };
|
|
381
|
+
export function wavetable(harmonics) {
|
|
382
|
+
const table = new Float32Array(TABLE + 1);
|
|
383
|
+
let peak = 0;
|
|
384
|
+
for (let i = 0; i < TABLE; i += 1) {
|
|
385
|
+
let v = 0;
|
|
386
|
+
harmonics.forEach((a, k) => { if (a) v += a * Math.sin((2 * Math.PI * (k + 1) * i) / TABLE); });
|
|
387
|
+
table[i] = v;
|
|
388
|
+
peak = Math.max(peak, Math.abs(v));
|
|
389
|
+
}
|
|
390
|
+
for (let i = 0; i < TABLE; i += 1) table[i] /= peak || 1;
|
|
391
|
+
table[TABLE] = table[0];
|
|
392
|
+
return table;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function samplePeakDbfs(channels) {
|
|
396
|
+
let peak = 0;
|
|
397
|
+
for (const ch of channels) for (let i = 0; i < ch.length; i += 1) {
|
|
398
|
+
const v = Math.abs(ch[i]);
|
|
399
|
+
if (v > peak) peak = v;
|
|
400
|
+
}
|
|
401
|
+
return dbfs(peak);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Sounds an on-screen event can make: a tap, a hit, a whoosh and a chime.
|
|
405
|
+
export const EVENT_SOUNDS = Object.freeze(['tap', 'hit', 'whoosh', 'chime']);
|
|
406
|
+
|
|
407
|
+
const CUE_WORDS = Object.freeze([
|
|
408
|
+
['hit', /\b(?:hits?|impacts?|cuts?|stabs?|accents?)\b|타격|히트|컷|강세/iu],
|
|
409
|
+
['rise', /\b(?:rises?|rising|risers?|swells?|swelling|builds?|building|build-?ups?|lifts?|lifting)\b|상승|고조|빌드업/iu],
|
|
410
|
+
['cadence', /\b(?:cadences?|resolves?|resolving|resolution|close|closing|ending|final chord)\b|종지|마무리|해결/iu],
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
// Accent kinds a beat's free-text `sound` field asks for; none when nothing matches.
|
|
414
|
+
export function cueKindsFor(soundText) {
|
|
415
|
+
const text = String(soundText ?? '');
|
|
416
|
+
return CUE_WORDS.filter(([, re]) => re.test(text)).map(([kind]) => kind);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const nearestTime = (times, t, within) => {
|
|
420
|
+
let best = null;
|
|
421
|
+
for (const c of times) if (Math.abs(c - t) <= within && (best === null || Math.abs(c - t) < Math.abs(best - t))) best = c;
|
|
422
|
+
return best;
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const round4 = (x) => Math.round(x * 10000) / 10000;
|
|
426
|
+
|
|
427
|
+
// Accent cues measured against the beat boundary or cut they serve, plus one `event` cue per
|
|
428
|
+
// on-screen event at its own time (an event never snaps to a cut: it follows the picture).
|
|
429
|
+
export function planCues({ beats = [], durationSec, cutTimes = [], events = [] }) {
|
|
430
|
+
const cues = [];
|
|
431
|
+
const clampT = (t) => Math.min(durationSec, Math.max(0, t));
|
|
432
|
+
beats.forEach((beat, beatIndex) => {
|
|
433
|
+
const t0 = clampT(Number(beat.t0)), t1 = clampT(Number(beat.t1));
|
|
434
|
+
for (const kind of cueKindsFor(beat.sound)) {
|
|
435
|
+
if (kind === 'hit') {
|
|
436
|
+
const cut = nearestTime(cutTimes, t0, 0.5);
|
|
437
|
+
const target = cut ?? t0;
|
|
438
|
+
cues.push({ kind, t: round4(target), beatIndex, target: round4(target), delta: 0 });
|
|
439
|
+
} else if (kind === 'rise') {
|
|
440
|
+
const cut = nearestTime(cutTimes, t1, 0.5);
|
|
441
|
+
const end = cut ?? t1;
|
|
442
|
+
const start = Math.max(t0, end - 1.0);
|
|
443
|
+
cues.push({ kind, t: round4(end), start: round4(start), beatIndex, target: round4(end), delta: 0 });
|
|
444
|
+
} else {
|
|
445
|
+
const t = t0 + 0.6 * (t1 - t0);
|
|
446
|
+
cues.push({ kind, t: round4(t), beatIndex, target: round4(t), delta: 0, end: round4(beatIndex === beats.length - 1 ? durationSec : t1) });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
for (const e of Array.isArray(events) ? events : []) {
|
|
451
|
+
if (!EVENT_SOUNDS.includes(e?.kind) || !(e.t >= 0 && e.t < durationSec)) continue;
|
|
452
|
+
const beatIndex = Math.max(0, beats.findIndex((b) => e.t >= Number(b.t0) && e.t < Number(b.t1)));
|
|
453
|
+
const cut = nearestTime(cutTimes, e.t, Infinity);
|
|
454
|
+
cues.push({ kind: 'event', sound: e.kind, t: round4(e.t), beatIndex, target: round4(e.t), delta: 0, on: String(e.on ?? ''), fromCut: cut === null ? null : round4(Math.abs(e.t - cut)) });
|
|
455
|
+
}
|
|
456
|
+
cues.sort((a, b) => a.t - b.t || a.beatIndex - b.beatIndex);
|
|
457
|
+
return cues;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The master stage every built track ends in: loudness to -16 LUFS through a lookahead limiter
|
|
461
|
+
// under -2 dBFS, quantized as the WAV will be. The limiter turns the gain down smoothly before a
|
|
462
|
+
// peak (5 ms lookahead, both channels linked, 80 ms release) instead of bending the waveform:
|
|
463
|
+
// a waveshaper adds harmonics the AAC encode turns into overshoot.
|
|
464
|
+
export function masterTo(L, R) {
|
|
465
|
+
const sampleCount = L.length;
|
|
466
|
+
const ceiling = 10 ** (-2.1 / 20);
|
|
467
|
+
const ahead = Math.round(0.005 * SAMPLE_RATE);
|
|
468
|
+
const release = 1 - Math.exp(-1 / (0.08 * SAMPLE_RATE));
|
|
469
|
+
const q = (v) => Math.round(Math.max(-1, Math.min(1, v)) * 32767) / 32767;
|
|
470
|
+
const render = (gain) => {
|
|
471
|
+
// need[s]: the gain that keeps sample s under the ceiling; held[s]: the lowest need in the
|
|
472
|
+
// next `ahead` samples (a monotonic deque); then the release lets it rise slowly; then a
|
|
473
|
+
// moving average over `ahead` samples reaches every held value before its peak.
|
|
474
|
+
const need = new Float32Array(sampleCount);
|
|
475
|
+
for (let s = 0; s < sampleCount; s += 1) {
|
|
476
|
+
const a = Math.max(Math.abs(L[s]), Math.abs(R[s])) * gain;
|
|
477
|
+
need[s] = a > ceiling ? ceiling / a : 1;
|
|
478
|
+
}
|
|
479
|
+
const held = new Float32Array(sampleCount);
|
|
480
|
+
const deque = new Int32Array(sampleCount + ahead + 1);
|
|
481
|
+
let head = 0, tail = 0, next = 0;
|
|
482
|
+
for (let s = 0; s < sampleCount; s += 1) {
|
|
483
|
+
for (; next < Math.min(sampleCount, s + ahead + 1); next += 1) {
|
|
484
|
+
while (tail > head && need[deque[tail - 1]] >= need[next]) tail -= 1;
|
|
485
|
+
deque[tail] = next; tail += 1;
|
|
486
|
+
}
|
|
487
|
+
while (deque[head] < s) head += 1;
|
|
488
|
+
held[s] = need[deque[head]];
|
|
489
|
+
}
|
|
490
|
+
let env = 1;
|
|
491
|
+
const smooth = new Float32Array(sampleCount);
|
|
492
|
+
for (let s = 0; s < sampleCount; s += 1) {
|
|
493
|
+
env = Math.min(held[s], env + (1 - env) * release);
|
|
494
|
+
smooth[s] = env;
|
|
495
|
+
}
|
|
496
|
+
const left = new Float32Array(sampleCount), right = new Float32Array(sampleCount);
|
|
497
|
+
// The averaging window starts full of unity gain; the hard ceiling below catches the first 5 ms.
|
|
498
|
+
let win = ahead;
|
|
499
|
+
for (let s = 0; s < sampleCount; s += 1) {
|
|
500
|
+
win += smooth[s] - (s >= ahead ? smooth[s - ahead] : 1);
|
|
501
|
+
const g = win / ahead;
|
|
502
|
+
left[s] = q(Math.max(-ceiling, Math.min(ceiling, L[s] * gain * g)));
|
|
503
|
+
right[s] = q(Math.max(-ceiling, Math.min(ceiling, R[s] * gain * g)));
|
|
504
|
+
}
|
|
505
|
+
return [left, right];
|
|
506
|
+
};
|
|
507
|
+
const raw = integratedLoudness([Float32Array.from(L), Float32Array.from(R)]);
|
|
508
|
+
let gain = Number.isFinite(raw) ? 10 ** ((TARGET_LUFS - raw) / 20) : 1;
|
|
509
|
+
let [left, right] = render(gain);
|
|
510
|
+
let lufs = integratedLoudness([left, right]);
|
|
511
|
+
for (let i = 0; i < 8 && Number.isFinite(lufs) && Math.abs(lufs - TARGET_LUFS) > 0.2; i += 1) {
|
|
512
|
+
gain *= 10 ** ((TARGET_LUFS - lufs) / 20);
|
|
513
|
+
[left, right] = render(gain);
|
|
514
|
+
lufs = integratedLoudness([left, right]);
|
|
515
|
+
}
|
|
516
|
+
return { left, right, gain, lufs };
|
|
517
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { existsSync, realpathSync } from 'node:fs';
|
|
5
5
|
import { isAbsolute, join, sep } from 'node:path';
|
|
6
6
|
import { findTool } from './render.mjs';
|
|
7
|
+
import { writeScore } from './score.mjs';
|
|
7
8
|
import { audioGate, prepareTrack, writeBed } from './sound.mjs';
|
|
8
9
|
|
|
9
10
|
export function soundFile(out, treatment) {
|
|
@@ -14,6 +15,10 @@ export function soundFile(out, treatment) {
|
|
|
14
15
|
export function prepareSound({ out, treatment, fps, frameCount, cutTimes = [], ffmpeg, tempo = null }) {
|
|
15
16
|
const mode = treatment.sound.mode;
|
|
16
17
|
if (mode === 'none') return null;
|
|
18
|
+
if (mode === 'score') {
|
|
19
|
+
const score = writeScore({ treatment, frameCount, fps, cutTimes, dir: out });
|
|
20
|
+
return { mode, path: score.file, score };
|
|
21
|
+
}
|
|
17
22
|
if (mode === 'generated') {
|
|
18
23
|
const bed = writeBed(out, treatment, { frameCount, fps, cutTimes, tempo });
|
|
19
24
|
return { mode, path: bed.wavPath, bed };
|
|
@@ -30,8 +35,12 @@ export function prepareSound({ out, treatment, fps, frameCount, cutTimes = [], f
|
|
|
30
35
|
|
|
31
36
|
export function muxGate({ mp4, durationSec, treatment, env = process.env, track }) {
|
|
32
37
|
const mode = treatment.sound.mode;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
38
|
+
// A score is built here to the loudness target like the generated bed: a quiet opening is a
|
|
39
|
+
// failure, not a warning.
|
|
40
|
+
const built = mode === 'score' ? 'generated' : mode;
|
|
41
|
+
const gate = audioGate({ mp4, videoDurationSec: durationSec, mode: built, ffmpeg: findTool('ffmpeg', env), ffprobe: findTool('ffprobe', env) });
|
|
42
|
+
const bed = track?.bed, score = track?.score;
|
|
43
|
+
const label = mode === 'score' && score ? `score, ${score.genre} in ${score.key}, ${score.tempo} BPM` : mode === 'generated' ? 'generated sound bed' : mode === 'none' ? 'silent by request' : `${mode} track`;
|
|
44
|
+
const summary = { mode, label, ...(bed ? { timbre: bed.timbre, key: bed.key, tempo: bed.tempo, lufs: bed.lufs, peakDbfs: bed.peakDbfs, method: bed.method, sha256: bed.sha256 } : {}), ...(score ? { genre: score.genre, key: score.key, tempo: score.tempo, lufs: score.lufs, peakDbfs: score.peakDbfs, method: score.loudnessMethod, sidechainDb: score.sidechainDb, sha256: score.sha256 } : {}), ...(track?.prepared ? { sourceSec: track.prepared.sourceSec, padded: track.prepared.padded, trimmed: track.prepared.trimmed } : {}) };
|
|
36
45
|
return { results: gate.results, exit20: gate.exit20, stats: { ...gate.stats, ...summary }, summary };
|
|
37
46
|
}
|
|
@@ -13,6 +13,48 @@ const pass = (id, detail = '') => ({ id, status: 'PASS', detail });
|
|
|
13
13
|
const fail = (id, detail) => ({ id, status: 'FAIL', detail });
|
|
14
14
|
const warn = (id, detail) => ({ id, status: 'WARN', detail });
|
|
15
15
|
|
|
16
|
+
// An empty frame of any colour: almost every pixel within 0.02 of one luminance (0.5-99.5 %).
|
|
17
|
+
// A cream or white frame with nothing on it is as empty as a black one; MO-D-03 sees only black.
|
|
18
|
+
const FLAT = Object.freeze({ spread: 0.02, holdSec: 1, edgeSec: 1 });
|
|
19
|
+
// A near-empty frame: fewer than 2.5 % of its 48 px cells carry anything. A run longer than 0.5 s
|
|
20
|
+
// fails; the opening may build from nothing for 0.4 s more, the close may fade for 0.6 s more.
|
|
21
|
+
const DEAD = Object.freeze({ busy: 0.025, holdSec: 0.5, openSec: 0.4, closeSec: 0.6 });
|
|
22
|
+
|
|
23
|
+
const worstRun = (flags, { limit, allowance }) => {
|
|
24
|
+
let worst = null;
|
|
25
|
+
let start = -1;
|
|
26
|
+
for (let f = 0; f <= flags.length; f++) {
|
|
27
|
+
if (f < flags.length && flags[f]) {
|
|
28
|
+
if (start < 0) start = f;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (start >= 0) {
|
|
32
|
+
const length = f - start;
|
|
33
|
+
if (length > limit + allowance(start === 0, f === flags.length) && (!worst || length > worst.length)) worst = { length, from: start };
|
|
34
|
+
start = -1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return worst;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function checkFlatFrames(frames, fps) {
|
|
41
|
+
if (!frames.length || frames.some((f) => typeof f.lumP005 !== 'number' || typeof f.lumP995 !== 'number')) return { id: 'FLAT-FRAME', status: 'N/A', detail: 'per-frame spread not recorded' };
|
|
42
|
+
const flat = frames.map((f) => f.lumP995 - f.lumP005 < FLAT.spread);
|
|
43
|
+
const worst = worstRun(flat, { limit: FLAT.holdSec * fps, allowance: (atStart, atEnd) => (atStart || atEnd ? FLAT.edgeSec * fps : 0) });
|
|
44
|
+
return worst
|
|
45
|
+
? fail('FLAT-FRAME', `${(worst.length / fps).toFixed(2)} s of empty frames from ${(worst.from / fps).toFixed(2)} s (nothing but one flat colour; limit ${FLAT.holdSec} s)`)
|
|
46
|
+
: pass('FLAT-FRAME', `no run of empty frames longer than ${FLAT.holdSec} s`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function checkDeadAir(frames, fps) {
|
|
50
|
+
if (!frames.length || frames.some((f) => typeof f.busy !== 'number')) return { id: 'DEAD-AIR', status: 'N/A', detail: 'per-frame busy share not recorded' };
|
|
51
|
+
const dead = frames.map((f) => f.busy < DEAD.busy);
|
|
52
|
+
const worst = worstRun(dead, { limit: DEAD.holdSec * fps, allowance: (atStart, atEnd) => (atStart ? DEAD.openSec * fps : 0) + (atEnd ? DEAD.closeSec * fps : 0) });
|
|
53
|
+
return worst
|
|
54
|
+
? fail('DEAD-AIR', `${(worst.length / fps).toFixed(2)} s of near-empty frames from ${(worst.from / fps).toFixed(2)} s (under ${DEAD.busy * 100} % of the frame carries anything; limit ${DEAD.holdSec} s)`)
|
|
55
|
+
: pass('DEAD-AIR', `no near-empty run longer than ${DEAD.holdSec} s`);
|
|
56
|
+
}
|
|
57
|
+
|
|
16
58
|
function exportInfo(path) {
|
|
17
59
|
return existsSync(path) ? { present: true, bytes: statSync(path).size, path, name: path.split('/').pop() } : { present: false };
|
|
18
60
|
}
|
|
@@ -66,6 +108,8 @@ export function evaluateStage({ manifest, frames, previewRecords, determinism, p
|
|
|
66
108
|
const still = exportInfo(join(exportsDir, 'reduced-motion.png'));
|
|
67
109
|
results.push(still.present ? pass('MO-C-14', `present Y: the final beat's midpoint frame ${stillFrame}`) : fail('MO-C-14', 'present N: reduced-motion still missing'));
|
|
68
110
|
results.push(checkDarkRuns({ fps: manifest.fps, bpm: manifest.sound?.tempo ?? 100, timeline: [] }, frames));
|
|
111
|
+
results.push(checkFlatFrames(frames, manifest.fps));
|
|
112
|
+
results.push(checkDeadAir(frames, manifest.fps));
|
|
69
113
|
results.push(...(sound?.results ?? [fail('SOUND', 'sound gate not run')]));
|
|
70
114
|
results.push(...(qa?.results ?? []));
|
|
71
115
|
results.push(manifest.round >= 1 && manifest.round <= 3 ? pass('MO-C-15/16', `round ${manifest.round} of 3`) : fail('MO-C-15/16', `round ${manifest.round} outside 1..3`));
|
|
@@ -8,11 +8,45 @@ import { join } from 'node:path';
|
|
|
8
8
|
import { SKILL_ROOT } from './fonts.mjs';
|
|
9
9
|
import { measureContrast } from './gate.mjs';
|
|
10
10
|
import { measureFrame } from './frame-pool.mjs';
|
|
11
|
+
import { LAYOUTS, ZONE_TOLERANCE, readStyle, scaleStep, styleFamilies, styleTextColors } from './styles.mjs';
|
|
12
|
+
|
|
13
|
+
const readStyleSafe = (id) => {
|
|
14
|
+
try {
|
|
15
|
+
return readStyle(id);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
11
20
|
import { openStage } from './stage-session.mjs';
|
|
12
21
|
import { readingFloor } from './text.mjs';
|
|
13
22
|
import { normalize } from './treatment.mjs';
|
|
14
23
|
|
|
15
24
|
export const INTERNAL_TERMS = Object.freeze(['path', 'preset', 'gate', 'beat', 'treatment']);
|
|
25
|
+
|
|
26
|
+
// Thresholds for the text and fill rules: visible opacity, ink pixels, overlap share of the smaller
|
|
27
|
+
// box, RGB distance for "the same colour", empty regions per beat midpoint.
|
|
28
|
+
export const TEXT_QA = Object.freeze({ visibleOpacity: 0.6, minInkPx: 12, overlapShare: 0.08, colourDistance: 12, emptyRegion: 0.15, emptyRegionsMax: 2 });
|
|
29
|
+
|
|
30
|
+
/** [r, g, b] of a CSS colour string (rgb(), rgba() or #hex), or null. */
|
|
31
|
+
export function parseCssColour(value) {
|
|
32
|
+
const text = String(value ?? '').trim();
|
|
33
|
+
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/u.exec(text);
|
|
34
|
+
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
35
|
+
const hex = /^#([0-9a-f]{6})$/iu.exec(text);
|
|
36
|
+
if (hex) return [0, 2, 4].map((i) => Number.parseInt(hex[1].slice(i, i + 2), 16));
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Classify a run against the treatment's copy: "copy" | "other" (decor is never copy here). */
|
|
41
|
+
export function runClass(run, copyNorms) {
|
|
42
|
+
const n = run.norm;
|
|
43
|
+
if (!n) return 'other';
|
|
44
|
+
return copyNorms.some((line) => line.includes(n) || n.includes(line)) ? 'copy' : 'other';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const boxArea = (b) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
|
|
48
|
+
const boxOverlap = (a, b) => boxArea([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]);
|
|
49
|
+
const unionOf = (rects) => (rects.length ? [Math.min(...rects.map((r) => r[0])), Math.min(...rects.map((r) => r[1])), Math.max(...rects.map((r) => r[2])), Math.max(...rects.map((r) => r[3]))] : null);
|
|
16
50
|
const QA_SCRIPT = join(SKILL_ROOT, 'scripts', 'stage', 'qa.js');
|
|
17
51
|
const LUT = Float64Array.from({ length: 256 }, (_, v) => { const s = v / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; });
|
|
18
52
|
const pass = (id, detail = '') => ({ id, status: 'PASS', detail });
|
|
@@ -107,11 +141,17 @@ function rgb3(rgba) {
|
|
|
107
141
|
return out;
|
|
108
142
|
}
|
|
109
143
|
|
|
110
|
-
export async function runStageQa({ executable, out, stageDir, width, height, fps, seed, fonts, treatment, totalFrames, log = () => {}, stepOrThrow, captureChecked, stop = { aborted: false } }) {
|
|
144
|
+
export async function runStageQa({ executable, out, stageDir, width, height, fps, seed, fonts, treatment, totalFrames, log = () => {}, stepOrThrow, captureChecked, rung = 'software', stop = { aborted: false } }) {
|
|
111
145
|
const plan = qaPlan(treatment.beats, fps, totalFrames);
|
|
112
146
|
const allowedFonts = new Set();
|
|
113
147
|
for (const route of fonts.routes.values()) for (const name of fontNames(new Uint8Array(readFileSync(route.path)))) allowedFonts.add(name);
|
|
114
|
-
|
|
148
|
+
let style = null;
|
|
149
|
+
if (treatment.style) {
|
|
150
|
+
const { readStyle, styleCss, styleScript } = await import('./styles.mjs');
|
|
151
|
+
const declared = readStyle(treatment.style);
|
|
152
|
+
style = { css: styleCss(declared, treatment.format), js: styleScript(declared, treatment.format, treatment) };
|
|
153
|
+
}
|
|
154
|
+
const session = await openStage({ executable, outDir: out, stageDir, width, height, fps, seed, fonts, style, label: 'q', rung, log });
|
|
115
155
|
const grid = [];
|
|
116
156
|
const samples = [];
|
|
117
157
|
let canvasTextCalls = 0;
|
|
@@ -157,7 +197,7 @@ export async function runStageQa({ executable, out, stageDir, width, height, fps
|
|
|
157
197
|
} catch { /* the node left the document between reads */ }
|
|
158
198
|
}
|
|
159
199
|
}
|
|
160
|
-
samples.push({ frame: f, beat: sample.beat, kind: sample.kind, runs: measured, texts, moved: stats.moved, nonTextStd: stats.nonTextStd, fonts: fontsUsed });
|
|
200
|
+
samples.push({ frame: f, beat: sample.beat, kind: sample.kind, runs: measured, texts, moved: stats.moved, nonTextStd: stats.nonTextStd, fonts: fontsUsed, regions: a.regions ?? null });
|
|
161
201
|
}
|
|
162
202
|
} finally { await session.close().catch(() => {}); }
|
|
163
203
|
const fileNames = ['index.html', 'treatment.json', 'stage-kit.js', 'fonts.css', 'gate-report.txt'];
|
|
@@ -166,6 +206,92 @@ export async function runStageQa({ executable, out, stageDir, width, height, fps
|
|
|
166
206
|
|
|
167
207
|
function wordHit(text, term) { return new RegExp(`(?<![\\p{L}\\p{N}])${term}(?![\\p{L}\\p{N}])`, 'iu').test(text); }
|
|
168
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The text rules on the collected samples: text over text (any visible runs), and — when the
|
|
211
|
+
* treatment declares a house style — the style's colours, faces and type scale for copy, plus the
|
|
212
|
+
* shot's type zone for settled copy runs on beats that name a layout. Pure, so tests drive it.
|
|
213
|
+
*/
|
|
214
|
+
export function judgeTextRules({ treatment, width, height, samples, settled, copyNorms }) {
|
|
215
|
+
const format = width >= height ? '16:9' : '9:16';
|
|
216
|
+
const style = treatment.style ? readStyleSafe(treatment.style) : null;
|
|
217
|
+
const colours = style ? styleTextColors(style).map(parseCssColour) : [];
|
|
218
|
+
const families = style ? styleFamilies(style) : new Set();
|
|
219
|
+
const overlaps = [];
|
|
220
|
+
const offColour = [];
|
|
221
|
+
const offFace = [];
|
|
222
|
+
const offScale = [];
|
|
223
|
+
const offScaleOther = [];
|
|
224
|
+
const offZone = [];
|
|
225
|
+
const tolX = width * ZONE_TOLERANCE;
|
|
226
|
+
const tolY = height * ZONE_TOLERANCE;
|
|
227
|
+
for (const s of samples) {
|
|
228
|
+
const visible = s.runs
|
|
229
|
+
.map((run) => ({ run, box: unionOf(run.rects) }))
|
|
230
|
+
.filter(({ run, box }) => box && !run.decor && run.opacity >= TEXT_QA.visibleOpacity && run.ink >= TEXT_QA.minInkPx);
|
|
231
|
+
for (let i = 0; i < visible.length; i++) {
|
|
232
|
+
for (let j = i + 1; j < visible.length; j++) {
|
|
233
|
+
const a = visible[i];
|
|
234
|
+
const b = visible[j];
|
|
235
|
+
const shared = boxOverlap(a.box, b.box);
|
|
236
|
+
if (shared > TEXT_QA.overlapShare * Math.min(boxArea(a.box), boxArea(b.box))) overlaps.push(`"${a.run.text.slice(0, 24)}" × "${b.run.text.slice(0, 24)}"@${s.frame}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const beat = treatment.beats[s.beat];
|
|
240
|
+
const layout = beat?.layout ? LAYOUTS[format][beat.layout] : null;
|
|
241
|
+
for (const { run, box } of visible) {
|
|
242
|
+
if (!settled(s, run)) continue;
|
|
243
|
+
const copy = !run.decor && runClass(run, copyNorms) === 'copy';
|
|
244
|
+
const where = `"${run.text.slice(0, 32)}"@${s.frame}`;
|
|
245
|
+
if (copy && style) {
|
|
246
|
+
const rgb = parseCssColour(run.color);
|
|
247
|
+
if (!run.gradient && rgb && !colours.some((c) => Math.hypot(c[0] - rgb[0], c[1] - rgb[1], c[2] - rgb[2]) <= TEXT_QA.colourDistance)) offColour.push(`${where} ${run.color}`);
|
|
248
|
+
if (run.family && ![...families].some((f) => run.family === f || run.family.startsWith(`${f} `))) offFace.push(`${where} ${run.family}`);
|
|
249
|
+
}
|
|
250
|
+
if (style) {
|
|
251
|
+
const fontPx = run.fontSizePx * (run.scale ?? 1);
|
|
252
|
+
if (!scaleStep(style, format, fontPx)) (copy ? offScale : offScaleOther).push(`${where} ${Math.round(fontPx)}px`);
|
|
253
|
+
}
|
|
254
|
+
if (copy && layout) {
|
|
255
|
+
const z = layout.type;
|
|
256
|
+
if (!z) offZone.push(`${where} in a full shot (no copy zone)`);
|
|
257
|
+
else {
|
|
258
|
+
const [zx, zy, zw, zh] = [z[0] * width, z[1] * height, z[2] * width, z[3] * height];
|
|
259
|
+
if (box[0] < zx - tolX || box[1] < zy - tolY || box[2] > zx + zw + tolX || box[3] > zy + zh + tolY) offZone.push(`${where} outside the ${beat.layout} type zone`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const rules = [];
|
|
265
|
+
rules.push(overlaps.length ? fail('TEXT-OVERLAP', `text over text: ${overlaps.slice(0, 4).join(', ')}`) : pass('TEXT-OVERLAP', 'no two visible text runs overlap'));
|
|
266
|
+
if (style) {
|
|
267
|
+
const steps = Object.entries(style.scale[format]).map(([k, v]) => `${k} ${v}`).join(', ');
|
|
268
|
+
const styleFails = [
|
|
269
|
+
offColour.length ? `colour off the ${style.id} text roles (${style.textRoles.join(', ')}): ${offColour.slice(0, 3).join(', ')}` : null,
|
|
270
|
+
offFace.length ? `face outside the style (${[...families].join(', ')}): ${offFace.slice(0, 3).join(', ')}` : null,
|
|
271
|
+
offScale.length ? `size off the type scale (${steps} px, ±4 %): ${offScale.slice(0, 3).join(', ')}` : null,
|
|
272
|
+
].filter(Boolean);
|
|
273
|
+
rules.push(styleFails.length ? fail('TEXT-ON-STYLE', styleFails.join('; '))
|
|
274
|
+
: offScaleOther.length ? warn('TEXT-ON-STYLE', `copy is on the ${style.id} style; other text off the scale: ${offScaleOther.slice(0, 3).join(', ')}`)
|
|
275
|
+
: pass('TEXT-ON-STYLE', `copy uses the ${style.id} colours, faces and type scale`));
|
|
276
|
+
}
|
|
277
|
+
const zoned = samples.some((s) => treatment.beats[s.beat]?.layout);
|
|
278
|
+
if (zoned) rules.push(offZone.length ? fail('TEXT-ZONE', offZone.slice(0, 4).join(', ')) : pass('TEXT-ZONE', "every settled copy run sits in its shot's type zone"));
|
|
279
|
+
return rules;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// A rich film fills its frame the way a dense reference does: at each beat midpoint at most two of
|
|
283
|
+
// the 12 regions may sit empty (busy share under 0.15). The end card may breathe, so one beat may miss.
|
|
284
|
+
export function richFill({ treatment, samples }) {
|
|
285
|
+
if (treatment.register === 'minimal') return { id: 'RICH-FILL', status: 'N/A', detail: 'minimal register: negative space is the point' };
|
|
286
|
+
const mids = samples.filter((s) => s.kind === 'mid' && Array.isArray(s.regions));
|
|
287
|
+
if (!mids.length) return { id: 'RICH-FILL', status: 'N/A', detail: 'region fill not measured' };
|
|
288
|
+
const thin = mids
|
|
289
|
+
.map((s) => ({ beat: s.beat, empty: s.regions.filter((v) => v < TEXT_QA.emptyRegion).length }))
|
|
290
|
+
.filter((x) => x.empty > TEXT_QA.emptyRegionsMax);
|
|
291
|
+
if (thin.length <= 1) return { id: 'RICH-FILL', status: 'PASS', detail: `${mids.length - thin.length} of ${mids.length} beat midpoints fill the frame (at most ${TEXT_QA.emptyRegionsMax} of 12 regions empty)` };
|
|
292
|
+
return { id: 'RICH-FILL', status: 'FAIL', detail: `${thin.length} of ${mids.length} beat midpoints leave the frame empty: ${thin.map((x) => `beat ${x.beat + 1} has ${x.empty} of 12 regions empty`).join(', ')}` };
|
|
293
|
+
}
|
|
294
|
+
|
|
169
295
|
export function evaluateQa({ grid, samples, plan, treatment, width, height, fps, canvasTextCalls = 0, allowedFonts = new Set(), fileNames = [] }) {
|
|
170
296
|
const copy = treatment.copy.lines.map(normalize).filter(Boolean);
|
|
171
297
|
const isCopyText = (norm) => copy.some((line) => norm.includes(line) || line.includes(norm));
|
|
@@ -262,6 +388,9 @@ export function evaluateQa({ grid, samples, plan, treatment, width, height, fps,
|
|
|
262
388
|
results.push(decorCopy || decorShare ? fail('QA-DECOR', decorCopy ?? decorShare) : pass('QA-DECOR', 'decor text carries no copy and stays under 25 % of the text area'));
|
|
263
389
|
results.push(meta ? warn('QA-META', `a meta label may be on screen: ${meta}; the look must answer it`) : pass('QA-META', 'no request text, idea, file name or internal term on screen'));
|
|
264
390
|
results.push(fontIssue ? (fontIssue.copy ? fail('QA-FONTS', `${fontIssue.text}; set copy in a product face`) : warn('QA-FONTS', fontIssue.text)) : pass('QA-FONTS', 'only product faces at beat midpoints'));
|
|
391
|
+
results.push(...judgeTextRules({ treatment, width, height, samples, settled, copyNorms: copy, families: null }));
|
|
392
|
+
const rich = richFill({ treatment, samples });
|
|
393
|
+
results.push(rich);
|
|
265
394
|
const registered = grid.some((g) => g.texts.length);
|
|
266
395
|
results.push(canvasTextCalls > 0 && !registered ? warn('QA-CANVAS', 'canvas text not measured: the page draws text on a canvas without LitStage.text registrations; the look must answer it') : pass('QA-CANVAS', registered ? 'canvas text registered' : 'no canvas text'));
|
|
267
396
|
const drawn = presence.filter(Boolean).length;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// One stage Chrome: the page is served from the synthetic origin http://lit.stage/ through CDP
|
|
2
2
|
// Fetch interception (no listening socket), the host script virtualizes time before any page
|
|
3
3
|
// script, and the renderer drives frames with step() + capture() (brief sections 6c-6e).
|
|
4
|
-
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
5
5
|
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
6
6
|
import { extname, join, sep } from 'node:path';
|
|
7
7
|
import { STAGE } from './constants.mjs';
|
|
@@ -18,6 +18,37 @@ export const EPOCH_MS = Date.UTC(2026, 0, 1);
|
|
|
18
18
|
export const KIT_PATH = join(SKILL_ROOT, 'scripts', 'stage', 'stage-kit.js');
|
|
19
19
|
const HOST_PATH = join(SKILL_ROOT, 'scripts', 'stage', 'host.js');
|
|
20
20
|
|
|
21
|
+
// The pinned three.js build and its two addons (scripts/lib/vendor/three, MIT), served at /lit/three/
|
|
22
|
+
// for 3D films and 3D heroes; never a CDN. Any other path under /lit/three/ is a missing file.
|
|
23
|
+
// The hashes below are the bytes of upstream three@0.170.0
|
|
24
|
+
// (registry.npmjs.org/three/-/three-0.170.0.tgz: build/three.module.js and the two examples/jsm
|
|
25
|
+
// files), checked byte for byte: a swapped vendored file and a regenerated notice cannot pass
|
|
26
|
+
// together, because the pin lives in code and is rechecked every time a file is served.
|
|
27
|
+
export const THREE_DIR = join(SKILL_ROOT, 'scripts', 'lib', 'vendor', 'three');
|
|
28
|
+
export const THREE_HASHES = Object.freeze({
|
|
29
|
+
'three.module.js': 'ce1fa418de16a19495a9f72495580e3015d7745c296d3ce0485897f902ddedfb',
|
|
30
|
+
'addons/environments/RoomEnvironment.js': '85869bdd22f7cf0b22e68dcefe6cb710480af0f5c901a76f7a7764fd07fc8014',
|
|
31
|
+
'addons/geometries/RoundedBoxGeometry.js': 'd20aeaf7077c459a8d952d215738d7b44fba028440bb862d84b622a34f0c80bb',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Resolve a /lit/three/... path against the vendored build: { file, type }, { missing } or { refused }. */
|
|
35
|
+
export function resolveThree(pathname) {
|
|
36
|
+
let decoded;
|
|
37
|
+
try { decoded = decodeURIComponent(pathname); } catch { return { refused: `undecodable path ${pathname}` }; }
|
|
38
|
+
const rel = decoded.slice('/lit/three/'.length);
|
|
39
|
+
if (rel.split('/').includes('..') || rel.includes('\\')) return { refused: `path traversal ${pathname}` };
|
|
40
|
+
if (!rel.endsWith('.js')) return { missing: rel };
|
|
41
|
+
const file = join(THREE_DIR, rel);
|
|
42
|
+
if (!existsSync(file)) return { missing: rel };
|
|
43
|
+
try {
|
|
44
|
+
const hash = createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
45
|
+
if (THREE_HASHES[rel] !== hash) return { refused: `changed vendored file ${rel}` };
|
|
46
|
+
} catch {
|
|
47
|
+
return { refused: `unreadable vendored file ${rel}` };
|
|
48
|
+
}
|
|
49
|
+
return { file, type: CONTENT_TYPES['.js'] };
|
|
50
|
+
}
|
|
51
|
+
|
|
21
52
|
export function hostScript({ seed, fps }) {
|
|
22
53
|
return readFileSync(HOST_PATH, 'utf8').replace('const CFG = __LIT_CFG__;', `const CFG = ${JSON.stringify({ seed: seed >>> 0, fps, epochMs: EPOCH_MS })};`);
|
|
23
54
|
}
|
|
@@ -41,9 +72,9 @@ export function resolveStagePath(stageDir, pathname) {
|
|
|
41
72
|
return { file: real, type };
|
|
42
73
|
}
|
|
43
74
|
|
|
44
|
-
export async function openStage({ executable, outDir, stageDir, width, height, fps, seed, fonts, label = 'm', log = () => {} }) {
|
|
75
|
+
export async function openStage({ executable, outDir, stageDir, width, height, fps, seed, fonts, style = null, label = 'm', rung = 'software', log = () => {} }) {
|
|
45
76
|
const profileDir = join(outDir, '.run', `s${label}-${randomBytes(3).toString('hex')}`);
|
|
46
|
-
const browser = await launchStage({ executable, profileDir, width, height });
|
|
77
|
+
const browser = await launchStage({ executable, profileDir, width, height, rung });
|
|
47
78
|
const network = [];
|
|
48
79
|
const refused = [];
|
|
49
80
|
const served = new Set();
|
|
@@ -64,7 +95,23 @@ export async function openStage({ executable, outDir, stageDir, width, height, f
|
|
|
64
95
|
return;
|
|
65
96
|
}
|
|
66
97
|
if (url.pathname === '/lit/stage-kit.js') { served.add('/lit/stage-kit.js'); await respond(requestId, 200, kit, CONTENT_TYPES['.js']); return; }
|
|
98
|
+
if (url.pathname.startsWith('/lit/three/')) {
|
|
99
|
+
const resolved = resolveThree(url.pathname);
|
|
100
|
+
if (resolved.refused) { refused.push(resolved.refused); await respond(requestId, 403, 'refused'); return; }
|
|
101
|
+
if (resolved.missing) { await respond(requestId, 404, 'not a vendored three.js file'); return; }
|
|
102
|
+
served.add(url.pathname);
|
|
103
|
+
await respond(requestId, 200, readFileSync(resolved.file), resolved.type);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
67
106
|
if (url.pathname === '/lit/fonts.css') { served.add('/lit/fonts.css'); await respond(requestId, 200, fonts.css, CONTENT_TYPES['.css']); return; }
|
|
107
|
+
if (url.pathname === '/lit/style.css') {
|
|
108
|
+
if (style) { served.add('/lit/style.css'); await respond(requestId, 200, style.css, CONTENT_TYPES['.css']); } else await respond(requestId, 404, 'no style bound');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (url.pathname === '/lit/style.js') {
|
|
112
|
+
if (style) { served.add('/lit/style.js'); await respond(requestId, 200, style.js, CONTENT_TYPES['.js']); } else await respond(requestId, 404, 'no style bound');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
68
115
|
if (url.pathname.startsWith('/lit/fonts/')) {
|
|
69
116
|
const route = fonts.routes.get(url.pathname);
|
|
70
117
|
if (route) { served.add(url.pathname); await respond(requestId, 200, readFileSync(route.path), route.type); } else await respond(requestId, 404, 'no such face');
|
|
@@ -95,6 +142,7 @@ export async function openStage({ executable, outDir, stageDir, width, height, f
|
|
|
95
142
|
|
|
96
143
|
return {
|
|
97
144
|
network, refused, served, args: browser.args, send, evaluate,
|
|
145
|
+
rung: browser.rung, renderer: browser.renderer, notice: browser.notice,
|
|
98
146
|
async load() {
|
|
99
147
|
const loaded = new Promise((resolve) => browser.on((message) => { if (message.method === 'Page.loadEventFired' && message.sessionId === browser.sessionId) resolve(); }));
|
|
100
148
|
await send('Page.navigate', { url: `${STAGE.origin}/index.html` });
|