@litfamily/litopencode 1.0.31 → 1.0.32
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/CHANGELOG.md +24 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/activation-probe.js +1 -0
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-reentry.d.ts +20 -0
- package/dist/activation-reentry.js +110 -1
- package/dist/activation-routing.d.ts +2 -1
- package/dist/activation-routing.js +100 -11
- package/dist/activation-workflow-prompts.d.ts +4 -0
- package/dist/activation-workflow-prompts.js +30 -0
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +44 -5
- package/dist/adhd-next.d.ts +51 -0
- package/dist/adhd-next.js +150 -0
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +7 -1
- package/dist/cli/native-skill-integrity.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.js +48 -1
- package/dist/features.d.ts +80 -1
- package/dist/features.js +105 -1
- package/dist/ledger.js +12 -1
- package/dist/secret-shapes.js +5 -5
- package/dist/skills.d.ts +22 -1
- package/dist/skills.js +36 -0
- package/dist/start-work-typed.d.ts +34 -0
- package/dist/start-work-typed.js +156 -0
- package/dist/state.d.ts +2 -0
- package/dist/state.js +2 -0
- package/dist/tools.js +22 -2
- package/dist/tui/mission-band.d.ts +20 -1
- package/dist/tui/mission-band.js +73 -16
- package/dist/tui/mission-looks.d.ts +38 -0
- package/dist/tui/mission-looks.js +73 -0
- package/dist/tui/mission-model.d.ts +2 -0
- package/dist/tui/mission-model.js +3 -1
- package/dist/tui/mission-rainbow.d.ts +33 -0
- package/dist/tui/mission-rainbow.js +90 -0
- package/dist/tui/mission-source.d.ts +48 -0
- package/dist/tui/mission-source.js +184 -2
- package/dist/tui/mission-text.d.ts +2 -0
- package/dist/tui/mission-text.js +9 -1
- package/dist/tui/mission-threads.d.ts +50 -0
- package/dist/tui/mission-threads.js +119 -0
- package/dist/tui.js +84 -6
- package/dist/uiux-visual-catalog.js +8 -1
- package/dist/v2/session-hooks.js +38 -5
- package/dist/workflow-stack.d.ts +53 -0
- package/dist/workflow-stack.js +196 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference-Ko-KR.md +30 -2
- package/docs/reference.md +34 -2
- package/package.json +1 -1
- package/skills/frontend-ui-ux/SKILL.md +3 -2
- package/skills/frontend-ui-ux/kit/components.css +414 -0
- package/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
- package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
- package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
- package/skills/frontend-ui-ux/references/house-styles.md +236 -0
- package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
- package/skills/frontend-ui-ux/references/production.md +3 -1
- package/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
- package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
- package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
- package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
- package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
- package/skills/lit-adhd/SKILL.md +208 -0
- package/skills/lit-adhd/references/brief-format.md +43 -0
- package/skills/lit-adhd/references/card-examples.md +82 -0
- package/skills/lit-adhd/references/status-taxonomy.md +29 -0
- package/skills/lit-adhd/scripts/rewind.mjs +835 -0
- package/skills/lit-diagram-drawer/SKILL.md +4 -3
- package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
- package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
- package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
- package/skills/lit-docx/SKILL.md +2 -1
- package/skills/lit-handoff/SKILL.md +2 -0
- package/skills/lit-hijack/SKILL.md +422 -0
- package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
- package/skills/lit-hijack/references/clean-room-build.md +111 -0
- package/skills/lit-hijack/references/distil-and-licence.md +100 -0
- package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
- package/skills/lit-hijack/references/parity-and-report.md +110 -0
- package/skills/lit-hijack/scripts/hijack.mjs +993 -0
- package/skills/lit-plan/SKILL.md +2 -0
- package/skills/lit-pptx/SKILL.md +27 -11
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
- package/skills/lit-pptx/references/density-and-fill.md +9 -3
- package/skills/lit-pptx/references/direction-step.md +21 -6
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
- package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/skills/lit-pptx/references/layout-families.md +65 -47
- package/skills/lit-pptx/references/title-treatments.md +83 -134
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
- package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
- package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
- package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
- package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/skills/lit-pptx/references/tonalities/night.md +1 -1
- package/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/skills/lit-pptx/references/tonalities/studio.md +7 -6
- package/skills/lit-pptx/run.mjs +2 -1
- package/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
- package/skills/lit-pptx/scripts/craft_extras.py +56 -3
- package/skills/lit-pptx/scripts/deck_output.py +75 -90
- package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
- package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/skills/lit-pptx/scripts/qa_deck.py +10 -11
- package/skills/lit-pptx/scripts/render_pages.py +118 -0
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/skills/lit-recap/SKILL.md +4 -0
- package/skills/lit-schematic/SKILL.md +118 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/skills/lit-schematic/references/journal-figures.md +72 -0
- package/skills/lit-schematic/references/kit-api.md +134 -0
- package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/skills/lit-schematic/references/verification.md +100 -0
- package/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
- package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/skills/lit-schematic/scripts/render.mjs +233 -0
- package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/skills/lit-scientific-visualization/SKILL.md +27 -4
- package/skills/lit-typographic-motion/SKILL.md +19 -17
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
- package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
- package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
- package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
- package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
- package/skills/lit-typographic-motion/engine/board.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
- package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
- package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
- package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
- package/skills/lit-typographic-motion/engine/house.mjs +42 -0
- package/skills/lit-typographic-motion/engine/look.mjs +65 -8
- package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
- package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
- package/skills/lit-typographic-motion/engine/score.mjs +594 -0
- package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
- package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
- package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
- package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
- package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
- package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
- package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
- package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
- package/skills/lit-typographic-motion/engine/text.mjs +1 -1
- package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
- package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/skills/lit-typographic-motion/gate.mjs +18 -2
- package/skills/lit-typographic-motion/probe.mjs +13 -1
- package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
- package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
- package/skills/lit-typographic-motion/references/cover.md +56 -13
- package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
- package/skills/lit-typographic-motion/references/depth.md +112 -0
- package/skills/lit-typographic-motion/references/runtime.md +21 -6
- package/skills/lit-typographic-motion/references/sound.md +76 -0
- package/skills/lit-typographic-motion/references/stage.md +50 -7
- package/skills/lit-typographic-motion/references/studio.md +162 -0
- package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
- package/skills/lit-typographic-motion/references/treatment.md +108 -9
- package/skills/lit-typographic-motion/render.mjs +60 -3
- package/skills/lit-typographic-motion/styles/paper.json +185 -0
- package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
- package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
- package/skills/lit-typographic-motion/styles/tidal.json +187 -0
- package/skills/litresearch/SKILL.md +2 -0
- package/skills/managed-skill-manifest.json +517 -88
- package/skills/readme-studio/SKILL.md +1 -1
- package/skills/review-work/SKILL.md +2 -0
- package/skills/start-work/SKILL.md +8 -0
- package/skills/workflow-loop/SKILL.md +4 -0
- package/tools/check-pack-payload.mjs +4 -2
- package/tools/payload-substance-parity.json +4 -3
- package/tools/qa-cover-recipes.mjs +4 -3
|
@@ -7,16 +7,47 @@ import path from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { boxContrast } from "../contrast.mjs";
|
|
9
9
|
import { linearLut } from "../flash.mjs";
|
|
10
|
+
import { regionFill } from "../fill.mjs";
|
|
10
11
|
import { decodePng } from "../png.mjs";
|
|
11
12
|
import { readingFloor } from "../text.mjs";
|
|
12
|
-
import {
|
|
13
|
+
import { LAYOUTS, ZONE_TOLERANCE, readStyle, scaleStep, styleFamilies, styleTextColors } from "../styles.mjs";
|
|
14
|
+
import { assetUsed } from "../assets.mjs";
|
|
15
|
+
import { normalize, registerOf, withoutQuotes } from "../treatment.mjs";
|
|
13
16
|
|
|
14
17
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
18
|
export const qaPageSource = () => readFileSync(path.join(here, "qa-page.js"), "utf8");
|
|
16
|
-
export const qaLimits = Object.freeze({
|
|
19
|
+
export const qaLimits = Object.freeze({
|
|
20
|
+
inkDelta: 2, minInkPixels: 12, visibleOpacity: 0.6, judgedOpacity: 0.95, settledPx: 2, decorShare: 0.25, presenceStd: 8 / 255, largeShare: 0.03, safeInset: 0.05, floorTolerance: 0.1,
|
|
21
|
+
// The house-style rules: two visible runs overlap past this share of the smaller box; copy colour may
|
|
22
|
+
// sit this far (RGB distance) from a style text role; shot changes are sampled this far on each side.
|
|
23
|
+
overlapShare: 0.08, emptyRegion: 0.15, emptyRegionsMax: 2, colourDistance: 12, cutWindowSec: 0.4, cutStepSec: 1 / 15, settledAt: Object.freeze([0.35, 0.8])
|
|
24
|
+
});
|
|
25
|
+
export const TEXT_QA = qaLimits;
|
|
17
26
|
const internalTerms = /(?<![\p{L}\p{N}])(?:path|preset|gate|beat|treatment)(?![\p{L}\p{N}])/iu;
|
|
18
27
|
const fileName = /(?<![\p{L}\p{N}])[\w-]+\.(?:html?|json|mjs|js|css|png|jpe?g|webp|svg|wav|mp4|md)(?![\p{L}\p{N}])/iu;
|
|
19
28
|
|
|
29
|
+
// The QA sample frames: each beat midpoint plus two settled frames, and a sample every 1/15 s within
|
|
30
|
+
// 0.4 s of each cut (`cut: true`), where an outgoing and an incoming line can cross. The cut rows feed
|
|
31
|
+
// only the text-over-text check; a line mid-transition is not judged for style or zone.
|
|
32
|
+
export function qaSamples({ beats, fps, frameCount }) {
|
|
33
|
+
const clamp = (f) => Math.max(0, Math.min(frameCount - 1, Math.round(f)));
|
|
34
|
+
const rows = new Map();
|
|
35
|
+
beats.forEach((beat, i) => {
|
|
36
|
+
for (const q of qaLimits.settledAt) rows.set(clamp((beat.t0 + q * (beat.t1 - beat.t0)) * fps), { frame: clamp((beat.t0 + q * (beat.t1 - beat.t0)) * fps), beat: i, mid: false });
|
|
37
|
+
});
|
|
38
|
+
beats.forEach((beat, i) => {
|
|
39
|
+
const frame = clamp(((beat.t0 + beat.t1) / 2) * fps);
|
|
40
|
+
rows.set(frame, { frame, beat: i, mid: true });
|
|
41
|
+
});
|
|
42
|
+
const reach = Math.round(qaLimits.cutWindowSec * fps), step = Math.max(1, Math.round(qaLimits.cutStepSec * fps));
|
|
43
|
+
beats.forEach((beat, i) => {
|
|
44
|
+
if (i === 0) return;
|
|
45
|
+
const at = Math.round(beat.t0 * fps);
|
|
46
|
+
for (let f = clamp(at - reach); f <= clamp(at + reach); f += step) if (!rows.has(f)) rows.set(f, { frame: f, beat: f < at ? i - 1 : i, mid: false, cut: true });
|
|
47
|
+
});
|
|
48
|
+
return [...rows.values()].sort((a, b) => a.frame - b.frame);
|
|
49
|
+
}
|
|
50
|
+
|
|
20
51
|
export function titleSafeBox(width, height) {
|
|
21
52
|
return [width * qaLimits.safeInset, height * qaLimits.safeInset, width * (1 - qaLimits.safeInset), height * (1 - qaLimits.safeInset)];
|
|
22
53
|
}
|
|
@@ -39,10 +70,10 @@ const keyOf = (run, width, height) => {
|
|
|
39
70
|
// Drives the QA browser; returns the raw samples and the 10 fps track for analyseQa().
|
|
40
71
|
export async function captureQa(stage, context, frameCount) {
|
|
41
72
|
const { fps, treatment } = context;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const qaFrames = new Set(
|
|
73
|
+
const plan = qaSamples({ beats: treatment.beats, fps, frameCount });
|
|
74
|
+
const byFrame = new Map(plan.map((row) => [row.frame, row]));
|
|
75
|
+
const mids = new Set(plan.filter((row) => row.mid).map((row) => row.frame));
|
|
76
|
+
const qaFrames = new Set(plan.map((row) => row.frame));
|
|
46
77
|
const trackStep = Math.max(1, Math.round(fps / 10));
|
|
47
78
|
const page = stage.page, cdp = stage.cdp;
|
|
48
79
|
await cdp.send("DOM.enable");
|
|
@@ -85,11 +116,99 @@ export async function captureQa(stage, context, frameCount) {
|
|
|
85
116
|
fonts = marks.map((list) => [...new Set(list.flatMap((mark) => byMark.get(mark) ?? []))]);
|
|
86
117
|
await page.evaluate(() => window.__litQa.unmarkFonts());
|
|
87
118
|
}
|
|
88
|
-
|
|
119
|
+
const row = byFrame.get(frame);
|
|
120
|
+
samples.push({ frame, beat: row.beat, mid: row.mid, cut: row.cut === true, runs, a, b, fonts });
|
|
89
121
|
}
|
|
90
122
|
return { track, samples, trackStep, allowed: [...allowed], canvasTexts: [...canvasTexts] };
|
|
91
123
|
}
|
|
92
124
|
|
|
125
|
+
const inkIn = (diff, width, height, rects) => {
|
|
126
|
+
let n = 0;
|
|
127
|
+
for (const [x0, y0, x1, y1] of rects) {
|
|
128
|
+
for (let y = Math.max(0, Math.floor(y0)); y < Math.min(height, Math.ceil(y1)); y++) for (let x = Math.max(0, Math.floor(x0)); x < Math.min(width, Math.ceil(x1)); x++) if (diff[y * width + x]) n += 1;
|
|
129
|
+
}
|
|
130
|
+
return n;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// [r, g, b] of a CSS colour string (rgb(), rgba() or #hex), or null.
|
|
134
|
+
export function parseCssColour(value) {
|
|
135
|
+
const text = String(value ?? "").trim();
|
|
136
|
+
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/u.exec(text);
|
|
137
|
+
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
138
|
+
const hex = /^#([0-9a-f]{6})$/iu.exec(text);
|
|
139
|
+
return hex ? [0, 2, 4].map((i) => Number.parseInt(hex[1].slice(i, i + 2), 16)) : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const area = (b) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
|
|
143
|
+
const overlapArea = (a, b) => area([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]);
|
|
144
|
+
|
|
145
|
+
// The house-style rules on the collected samples, as gate rows. Text over text: any two visible,
|
|
146
|
+
// non-decor runs sharing more than 8 % of the smaller box, at the QA samples and across the cuts. For
|
|
147
|
+
// copy that is settled: the style's colours, faces and type scale, and the shot's type zone.
|
|
148
|
+
// Each sample is { frame, beat, cut, runs: [{ text, kind, copy, decor, settled, color, family, fontSizePx, gradient, box }] }.
|
|
149
|
+
export function judgeStyle({ treatment, width, height, samples }) {
|
|
150
|
+
const style = readStyle(treatment.style);
|
|
151
|
+
const format = width >= height ? "16:9" : "9:16";
|
|
152
|
+
const colours = styleTextColors(style).map(parseCssColour);
|
|
153
|
+
const families = styleFamilies(style);
|
|
154
|
+
const overlaps = [], offColour = [], offFace = [], offScale = [], offScaleOther = [], offZone = [];
|
|
155
|
+
const tolX = width * ZONE_TOLERANCE, tolY = height * ZONE_TOLERANCE;
|
|
156
|
+
for (const sample of samples) {
|
|
157
|
+
const visible = sample.runs.filter((run) => run.box && !run.decor);
|
|
158
|
+
for (let i = 0; i < visible.length; i++) for (let j = i + 1; j < visible.length; j++) {
|
|
159
|
+
const a = visible[i], b = visible[j];
|
|
160
|
+
if (overlapArea(a.box, b.box) > qaLimits.overlapShare * Math.min(area(a.box), area(b.box))) overlaps.push(`"${a.text.slice(0, 24)}" x "${b.text.slice(0, 24)}"@${sample.frame}${sample.cut ? " (cut)" : ""}`);
|
|
161
|
+
}
|
|
162
|
+
if (sample.cut) continue;
|
|
163
|
+
const beat = treatment.beats[sample.beat];
|
|
164
|
+
const layout = beat?.layout ? LAYOUTS[format][beat.layout] : null;
|
|
165
|
+
for (const run of sample.runs) {
|
|
166
|
+
if (run.kind === "canvas" || !run.settled || !run.box) continue;
|
|
167
|
+
const where = `"${run.text.slice(0, 32)}"@${sample.frame}`;
|
|
168
|
+
if (run.copy) {
|
|
169
|
+
const rgb = parseCssColour(run.color);
|
|
170
|
+
if (!run.gradient && rgb && !colours.some((c) => Math.hypot(c[0] - rgb[0], c[1] - rgb[1], c[2] - rgb[2]) <= qaLimits.colourDistance)) offColour.push(`${where} ${run.color}`);
|
|
171
|
+
if (run.family && !families.has(run.family)) offFace.push(`${where} ${run.family}`);
|
|
172
|
+
}
|
|
173
|
+
if (run.fontSizePx && !scaleStep(style, format, run.fontSizePx)) (run.copy ? offScale : offScaleOther).push(`${where} ${Math.round(run.fontSizePx)}px`);
|
|
174
|
+
if (run.copy && layout) {
|
|
175
|
+
const z = layout.type;
|
|
176
|
+
if (!z) offZone.push(`${where} in a full shot (no copy zone)`);
|
|
177
|
+
else {
|
|
178
|
+
const [zx, zy, zw, zh] = [z[0] * width, z[1] * height, z[2] * width, z[3] * height];
|
|
179
|
+
if (run.box[0] < zx - tolX || run.box[1] < zy - tolY || run.box[2] > zx + zw + tolX || run.box[3] > zy + zh + tolY) offZone.push(`${where} outside the ${beat.layout} type zone`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const list = (items) => `${items.slice(0, 3).join(", ")}${items.length > 3 ? ` (+${items.length - 3} more)` : ""}`;
|
|
185
|
+
const steps = Object.entries(style.scale[format]).map(([k, v]) => `${k} ${v}`).join(", ");
|
|
186
|
+
const styleFails = [
|
|
187
|
+
offColour.length ? `colour off the ${style.id} text roles (${style.textRoles.join(", ")}): ${list(offColour)}` : null,
|
|
188
|
+
offFace.length ? `face outside the style (${[...families].join(", ")}): ${list(offFace)}` : null,
|
|
189
|
+
offScale.length ? `size off the type scale (${steps} px, +-4 %): ${list(offScale)}` : null
|
|
190
|
+
].filter(Boolean);
|
|
191
|
+
return {
|
|
192
|
+
"TEXT-OVERLAP": overlaps.length ? { pass: false, detail: `text over text: ${list(overlaps)}` } : { pass: true, detail: `no two visible text runs overlap at the QA samples${samples.some((s) => s.cut) ? " or across the cuts" : ""}` },
|
|
193
|
+
"TEXT-ON-STYLE": styleFails.length ? { pass: false, detail: styleFails.join("; ") }
|
|
194
|
+
: offScaleOther.length ? { pass: false, warn: true, detail: `copy is on the ${style.id} style; other text off the scale: ${list(offScaleOther)}` }
|
|
195
|
+
: { pass: true, detail: `copy uses the ${style.id} colours, faces and type scale` },
|
|
196
|
+
"TEXT-ZONE": offZone.length ? { pass: false, detail: list(offZone) } : { pass: true, detail: "every settled copy run sits in its shot's type zone" }
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// RICH-FILL: at each shot's midpoint the frame is cut into 4 x 3 regions, and a region under 0.15 busy
|
|
201
|
+
// share is empty. A rich film leaves at most 2 of the 12 empty in all but one shot (the end card may
|
|
202
|
+
// breathe). A minimal film is exempt: negative space is its point.
|
|
203
|
+
export function richFill({ treatment, mids }) {
|
|
204
|
+
if (registerOf(treatment) === "minimal") return { pass: true, na: true, detail: "minimal register: negative space is the point" };
|
|
205
|
+
const measured = mids.filter((sample) => Array.isArray(sample.regions));
|
|
206
|
+
if (!measured.length) return { pass: true, na: true, detail: "region fill not measured" };
|
|
207
|
+
const thin = measured.map((sample) => ({ beat: sample.beat, empty: sample.regions.filter((value) => value < qaLimits.emptyRegion).length })).filter((shot) => shot.empty > qaLimits.emptyRegionsMax);
|
|
208
|
+
if (thin.length <= 1) return { pass: true, detail: `${measured.length - thin.length} of ${measured.length} shot midpoints fill the frame (at most ${qaLimits.emptyRegionsMax} of 12 regions empty${thin.length ? `; shot ${thin[0].beat + 1} may breathe` : ""})` };
|
|
209
|
+
return { pass: false, detail: `${thin.length} of ${measured.length} shot midpoints leave the frame empty: ${thin.map((shot) => `shot ${shot.beat + 1} has ${shot.empty} of 12 regions empty`).join(", ")} (a rich film fills the frame: more drawing, more labels, a layer behind the subject)` };
|
|
210
|
+
}
|
|
211
|
+
|
|
93
212
|
// Turns the captures into gate rows. Copy runs (their text holds a copy line) FAIL; decor runs WARN.
|
|
94
213
|
export function analyseQa(raw, context) {
|
|
95
214
|
const { width, height, fps, treatment } = context;
|
|
@@ -104,8 +223,11 @@ export function analyseQa(raw, context) {
|
|
|
104
223
|
const trackByFrame = new Map(raw.track.map((entry) => [entry.frame, entry]));
|
|
105
224
|
let presenceHits = 0, presenceMids = 0;
|
|
106
225
|
const judged = [];
|
|
226
|
+
const styleSamples = [];
|
|
227
|
+
const fillSamples = [];
|
|
107
228
|
for (const sample of raw.samples) {
|
|
108
229
|
const A = rgbaOf(sample.a), B = rgbaOf(sample.b);
|
|
230
|
+
if (sample.mid) fillSamples.push({ beat: sample.beat, regions: regionFill(A, width, height) });
|
|
109
231
|
const grow = (run) => Math.max(2, 0.2 * run.fontSize * run.scale);
|
|
110
232
|
const textRects = sample.runs.flatMap((run) => run.rects.map(([x0, y0, x1, y1]) => [x0 - grow(run), y0 - grow(run), x1 + grow(run), y1 + grow(run)]));
|
|
111
233
|
const inText = (x, y) => textRects.some(([x0, y0, x1, y1]) => x >= x0 && x < x1 && y >= y0 && y < y1);
|
|
@@ -117,6 +239,18 @@ export function analyseQa(raw, context) {
|
|
|
117
239
|
if (!inText(p % width, Math.floor(p / width))) outside += 1;
|
|
118
240
|
}
|
|
119
241
|
}
|
|
242
|
+
if (sample.cut) {
|
|
243
|
+
// A shot change in flight: only the text-over-text check reads it (judgeStyle).
|
|
244
|
+
if (outside > 0) continue;
|
|
245
|
+
const runs = [];
|
|
246
|
+
for (const run of sample.runs) {
|
|
247
|
+
if (run.kind === "canvas" || run.decor || run.opacity < qaLimits.visibleOpacity || !onScreen(run.rects, width, height)) continue;
|
|
248
|
+
if (inkIn(diff, width, height, run.rects) < qaLimits.minInkPixels) continue;
|
|
249
|
+
runs.push({ text: run.text, kind: run.kind, copy: isCopy(run), decor: false, settled: false, box: union(run.rects) });
|
|
250
|
+
}
|
|
251
|
+
styleSamples.push({ frame: sample.frame, beat: sample.beat, cut: true, runs });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
120
254
|
if (sample.mid) {
|
|
121
255
|
presenceMids += 1;
|
|
122
256
|
let n = 0, sum = 0, sumSq = 0;
|
|
@@ -129,6 +263,7 @@ export function analyseQa(raw, context) {
|
|
|
129
263
|
}
|
|
130
264
|
if (outside > 0) { warns.moved.push(`frame ${sample.frame}: ${outside} pixels changed outside the text (state moved; sample discarded)`); continue; }
|
|
131
265
|
let visibleArea = 0, decorArea = 0;
|
|
266
|
+
const styleRuns = [];
|
|
132
267
|
sample.runs.forEach((run, index) => {
|
|
133
268
|
if (run.kind === "canvas") { if (run.opacity >= qaLimits.visibleOpacity && onScreen(run.rects, width, height)) seenVisible.add(run.norm); return; }
|
|
134
269
|
const mask = new Uint8Array(width * height);
|
|
@@ -162,6 +297,7 @@ export function analyseQa(raw, context) {
|
|
|
162
297
|
const mine = union(run.rects), theirs = match ? union(match.rects) : null;
|
|
163
298
|
return theirs && mine.every((v, k) => Math.abs(v - theirs[k]) < qaLimits.settledPx);
|
|
164
299
|
});
|
|
300
|
+
styleRuns.push({ text: run.text, kind: run.kind, copy, decor: run.decor, settled, color: run.color, family: run.family, fontSizePx: run.fontSize * run.scale, gradient: run.gradient, box });
|
|
165
301
|
if (!settled) return;
|
|
166
302
|
const sizePx = run.fontSize * run.scale;
|
|
167
303
|
const measured = boxContrast(A, mask, width, height, { bbox: box, capHeightPx: sizePx, fill: run.gradient ? "gradient" : "solid", fontSizePx: sizePx, weight: run.weight }, shortSide / 1080, sizePx >= qaLimits.largeShare * shortSide);
|
|
@@ -169,6 +305,7 @@ export function analyseQa(raw, context) {
|
|
|
169
305
|
judged.push({ frame: sample.frame, text: run.text, ratio: Number(measured.ratio.toFixed(2)), floor: measured.floor, copy, decor: run.decor });
|
|
170
306
|
if (measured.ratio < measured.floor) (copy && !run.decor ? fails.contrast : warns.contrast).push(`"${run.text}" at frame ${sample.frame}: ${measured.ratio.toFixed(2)}:1 < ${measured.floor}:1`);
|
|
171
307
|
});
|
|
308
|
+
styleSamples.push({ frame: sample.frame, beat: sample.beat, cut: false, runs: styleRuns });
|
|
172
309
|
if (visibleArea > 0 && decorArea / visibleArea > qaLimits.decorShare) fails.decor.push(`decor text covers ${Math.round((100 * decorArea) / visibleArea)} % of the visible text at frame ${sample.frame}`);
|
|
173
310
|
}
|
|
174
311
|
// The 1/10 s track: visible copy runs for the reading floor, and every run for the meta-label lint.
|
|
@@ -202,6 +339,10 @@ export function analyseQa(raw, context) {
|
|
|
202
339
|
"TEXT-decor": row(fails.decor, "decor text carries no copy and stays under 25 % of the text"),
|
|
203
340
|
"TEXT-fonts": row(fails.fonts, `copy draws only with ${raw.allowed.join(", ")}`)
|
|
204
341
|
};
|
|
342
|
+
const used = context.stageDir ? assetUsed(treatment, context.stageDir) : null;
|
|
343
|
+
if (used) checks["ASSET-USED"] = used;
|
|
344
|
+
if (treatment.path === "stage") checks["RICH-FILL"] = richFill({ treatment, mids: fillSamples });
|
|
345
|
+
if (treatment.style) Object.assign(checks, judgeStyle({ treatment, width, height, samples: styleSamples }));
|
|
205
346
|
for (const [id, value] of Object.entries({ "TEXT-decor-contrast": warnRow(warns.contrast, "decor contrast"), "TEXT-moved": warnRow(warns.moved, "samples discarded"), "TEXT-meta-labels": warnRow([...warns.meta], "request, idea, file name or internal term on screen"),
|
|
206
347
|
"TEXT-canvas": warnRow(warns.canvas, "canvas text not measured"), "TEXT-decor-fonts": warnRow(warns.fonts, "decor fonts"), "TEXT-presence": warns.presence ? { pass: false, warn: true, detail: warns.presence } : null })) if (value) checks[id] = value;
|
|
207
348
|
return { checks, missing, judged, samples: raw.samples.map((sample) => ({ frame: sample.frame, mid: sample.mid, runs: sample.runs.length })), trackFrames: raw.track.length, allowedFaces: raw.allowed };
|
|
@@ -23,6 +23,9 @@ import { SOUND_EXIT, muxTrack, resolveTrack, soundChecks } from "../sound.mjs";
|
|
|
23
23
|
import { frameSize, keepFirstTreatment, loadTreatment } from "../treatment.mjs";
|
|
24
24
|
import { inspectWarmState, pins, skillRoot } from "../../runtime.mjs";
|
|
25
25
|
import { previewEncoderRung } from "../../probe.mjs";
|
|
26
|
+
import { addToStills, boardRules, writeBoard } from "../board.mjs";
|
|
27
|
+
import { readStyle, styleCss, styleScript } from "../styles.mjs";
|
|
28
|
+
import { requirePlate } from "../blender.mjs";
|
|
26
29
|
import { openStage, stageFonts } from "./capture.mjs";
|
|
27
30
|
import { analyseQa, captureQa, qaPageSource } from "./qa.mjs";
|
|
28
31
|
import { StageError, scanStage, stageSha } from "./scan.mjs";
|
|
@@ -111,6 +114,7 @@ export function prepareStage(out) {
|
|
|
111
114
|
const stageDir = path.join(out, "stage");
|
|
112
115
|
if (!existsSync(path.join(stageDir, "index.html"))) throw new StageError(17, "STAGE_CONTRACT_ERROR", `no stage page at ${path.join(stageDir, "index.html")}; write it first (references/stage.md)`);
|
|
113
116
|
const scan = scanStage(stageDir);
|
|
117
|
+
const plate = requirePlate(treatment, stageDir);
|
|
114
118
|
const state = inspectWarmState();
|
|
115
119
|
if (state.blocked) throw exit(state.blocked.exit, state.blocked.name, `${state.blocked.detail}; fix: ${state.fix}`);
|
|
116
120
|
const chrome = findChrome();
|
|
@@ -118,19 +122,26 @@ export function prepareStage(out) {
|
|
|
118
122
|
const { width, height } = frameSize(treatment);
|
|
119
123
|
const fps = treatment.fps ?? 60;
|
|
120
124
|
const seed = Number.isSafeInteger(treatment.seed) ? treatment.seed >>> 0 : fnv1a32(treatment.request);
|
|
121
|
-
|
|
125
|
+
const style = readStyle(treatment.style);
|
|
126
|
+
const styleFiles = { "/lit/style.css": styleCss(style, treatment.format), "/lit/style.js": styleScript(style, treatment.format, treatment) };
|
|
127
|
+
return { treatment, stageDir, scan, state, chromePath: chrome.path, width, height, fps, seed, fonts: stageFonts(skillRoot, state, pins), style, styleFiles, rung: stageRung(treatment), plate, notices: [] };
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
// A three.js world asks for the GPU rung; every other film is on the software rung, a Blender film too:
|
|
131
|
+
// its world is the plate, and the page only draws type over it.
|
|
132
|
+
export const stageRung = (treatment) => (treatment.depth === "3d" && treatment.render3d !== "blender" ? "gpu" : "swiftshader");
|
|
133
|
+
|
|
134
|
+
const fellBack = (why) => `the 3D route asked for the GPU rung and ${why}; rendered on the software rung (SwiftShader), slower, same frames`;
|
|
135
|
+
|
|
136
|
+
// Opens one browser on `rung`, loads the page and checks its contract against the treatment.
|
|
137
|
+
async function openOnRung(chromium, context, profile, extraInit, rung) {
|
|
138
|
+
const stage = await (context.opener ?? openStage)(chromium, { chromePath: context.chromePath, profile, stageDir: context.stageDir, width: context.width, height: context.height, fps: context.fps, seed: context.seed, fonts: context.fonts, extraInit, styleFiles: context.styleFiles, rung });
|
|
127
139
|
try {
|
|
128
140
|
const { definition, webgl } = await stage.load(context.scan.rasters.map((raster) => `/${raster.rel}`));
|
|
129
141
|
if (!definition) throw new StageError(17, "STAGE_CONTRACT_ERROR", "the page never called LitStage.define({ width, height, fps, duration, render }) or set window.litStage");
|
|
130
142
|
if (definition.width !== context.width || definition.height !== context.height) throw new StageError(17, "STAGE_CONTRACT_ERROR", `the page defines ${definition.width}x${definition.height}; the treatment's ${context.treatment.format} needs ${context.width}x${context.height}`);
|
|
131
143
|
if ((definition.fps ?? 60) !== context.fps) throw new StageError(17, "STAGE_CONTRACT_ERROR", `the page defines ${definition.fps} fps; the treatment renders at ${context.fps}`);
|
|
132
144
|
if (!(Number.isFinite(definition.duration) && definition.duration > 0)) throw new StageError(17, "STAGE_CONTRACT_ERROR", "the page's duration must be a positive number of seconds");
|
|
133
|
-
if (webgl.failed) throw exit(EXIT.BLOCKED_NO_WEBGL2, "BLOCKED_NO_WEBGL2", "the page asked for a WebGL context and the software renderer gave none; fix: check SwiftShader availability");
|
|
134
145
|
return { stage, definition, webgl };
|
|
135
146
|
} catch (error) {
|
|
136
147
|
await stage.close();
|
|
@@ -138,6 +149,34 @@ async function openChecked(chromium, context, profile, extraInit) {
|
|
|
138
149
|
}
|
|
139
150
|
}
|
|
140
151
|
|
|
152
|
+
// The GPU rung comes first when the context asks for it. When it cannot start, or gives the page no
|
|
153
|
+
// WebGL, the whole round (every later browser too) takes the software rung and says so in a notice.
|
|
154
|
+
// A page that breaks the stage contract is not retried: it would break the same way there.
|
|
155
|
+
export async function openChecked(chromium, context, profile, extraInit) {
|
|
156
|
+
if (context.rung === "gpu") {
|
|
157
|
+
let opened;
|
|
158
|
+
try { opened = await openOnRung(chromium, context, profile, extraInit, "gpu"); }
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (error?.exitCode !== undefined && error.exitCode !== EXIT.BLOCKED_NO_CHROME) throw error;
|
|
161
|
+
const line = String(error?.message ?? error).split("\n").find((part) => part.trim()) ?? "no detail";
|
|
162
|
+
context.rung = "swiftshader";
|
|
163
|
+
context.notices.push(fellBack(`it did not start (${line.slice(0, 160)})`));
|
|
164
|
+
}
|
|
165
|
+
if (opened) {
|
|
166
|
+
if (!opened.webgl.failed) return opened;
|
|
167
|
+
await opened.stage.close();
|
|
168
|
+
context.rung = "swiftshader";
|
|
169
|
+
context.notices.push(fellBack("the page got no WebGL there"));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const opened = await openOnRung(chromium, context, profile, extraInit, "swiftshader");
|
|
173
|
+
if (opened.webgl.failed) {
|
|
174
|
+
await opened.stage.close();
|
|
175
|
+
throw exit(EXIT.BLOCKED_NO_WEBGL2, "BLOCKED_NO_WEBGL2", "the page asked for a WebGL context and the software renderer gave none; fix: check SwiftShader availability");
|
|
176
|
+
}
|
|
177
|
+
return opened;
|
|
178
|
+
}
|
|
179
|
+
|
|
141
180
|
// Captures `frames` (sorted) in one browser by stepping every frame from 0. The stills round skips the
|
|
142
181
|
// compositor wait and the capture on frames it does not keep. The determinism replay (`exact`) repeats
|
|
143
182
|
// the master's own sequence instead, settling and capturing every frame and keeping only the samples:
|
|
@@ -205,7 +244,7 @@ async function captureMaster(context, stage, frameCount, keep, previewSources, r
|
|
|
205
244
|
const result = await job.promise;
|
|
206
245
|
const record = detector.push(result.cells);
|
|
207
246
|
hashes.set(job.frame, result.hash);
|
|
208
|
-
writeSync(logFd, JSON.stringify({ frame: job.frame, rgbaSha256: result.hash, p995L: result.p995L, transitions: { up: record.up, down: record.down, redUp: record.redUp, redDown: record.redDown }, fullStep: record.fullStep }) + "\n");
|
|
247
|
+
writeSync(logFd, JSON.stringify({ frame: job.frame, rgbaSha256: result.hash, p995L: result.p995L, p005L: result.p005L, busy: result.busy, transitions: { up: record.up, down: record.down, redUp: record.redUp, redDown: record.redDown }, fullStep: record.fullStep }) + "\n");
|
|
209
248
|
await write(master.child, Buffer.from(result.rgba.buffer, result.rgba.byteOffset, result.rgba.byteLength));
|
|
210
249
|
if (result.previewPng) writeFileSync(path.join(pvDir, `f${job.frame}.png`), result.previewPng);
|
|
211
250
|
consumed += 1;
|
|
@@ -288,6 +327,25 @@ function previewSourceFrames(fps, frameCount) {
|
|
|
288
327
|
return sources;
|
|
289
328
|
}
|
|
290
329
|
|
|
330
|
+
// The board round: the layout check (the text QA replay) runs on the page before any film is encoded,
|
|
331
|
+
// its rows go to stills/check.json, and the storyboard is drawn with the verdict on it. `qa` is the
|
|
332
|
+
// finished QA result when the full render already ran it.
|
|
333
|
+
async function boardRound(out, context, chromium, runDir, stills, { frameCount, qa = null }) {
|
|
334
|
+
let result = qa;
|
|
335
|
+
if (!result) {
|
|
336
|
+
const opened = await openChecked(chromium, context, path.join(runDir, "sq"), qaPageSource());
|
|
337
|
+
try { result = analyseQa(await captureQa(opened.stage, context, frameCount), context); } finally { await opened.stage.close(); }
|
|
338
|
+
}
|
|
339
|
+
const rules = boardRules(result.checks);
|
|
340
|
+
writeFileSync(path.join(out, "stills", "check.json"), JSON.stringify({ rules }, null, 2) + "\n");
|
|
341
|
+
const beats = stills.files.filter((entry) => entry.kind === "beat");
|
|
342
|
+
const file = await writeBoard({ out, context, chromium, treatment: context.treatment, beatFiles: beats.map((entry) => path.join(out, entry.file)), rules });
|
|
343
|
+
const entry = { file: "stills/board.png", kind: "board", frames: beats.map((beat) => beat.frame) };
|
|
344
|
+
addToStills(out, entry);
|
|
345
|
+
stills.files = JSON.parse(readFileSync(path.join(out, "stills", "stills.json"), "utf8")).files;
|
|
346
|
+
return { qa: result, rules, file, failed: rules.filter((rule) => rule.status === "FAIL") };
|
|
347
|
+
}
|
|
348
|
+
|
|
291
349
|
// Moves the previous round's exports aside so a new render never mixes with them.
|
|
292
350
|
function clearExports(out, runDir) {
|
|
293
351
|
const previous = path.join(runDir, "previous-round");
|
|
@@ -322,6 +380,8 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
|
|
|
322
380
|
stills = writeStillsSet(out, { width: context.width, height: context.height, fps: context.fps, frames, plan, round, pathName: "stage", beats: context.treatment.beats });
|
|
323
381
|
resolveTrack(out, context.treatment, { frameCount, fps: context.fps, cuts: stageCuts(context) });
|
|
324
382
|
writeFileSync(path.join(runDir, "stills-timing.json"), JSON.stringify({ frames: plan.all.length, totalSec: Number(((performance.now() - began) / 1000).toFixed(1)) }) + "\n");
|
|
383
|
+
const board = stillsOnly ? await boardRound(out, context, chromium, runDir, stills, { frameCount }) : null;
|
|
384
|
+
if (board) return { stillsOnly: true, stills, frameCount, board };
|
|
325
385
|
if (noFfmpeg) throw exit(EXIT.BLOCKED_NO_FFMPEG_FOR_VIDEO, "BLOCKED_NO_FFMPEG_FOR_VIDEO", "ffmpeg not found on PATH; the stills and contact sheet were written; fix: install ffmpeg, or keep working from `stage --stills-only`, which needs no ffmpeg");
|
|
326
386
|
return { stillsOnly: true, stills, frameCount };
|
|
327
387
|
}
|
|
@@ -361,6 +421,7 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
|
|
|
361
421
|
if (qaResult.missing.length) throw new StageError(17, "STAGE_CONTRACT_ERROR", `copy line never found on screen: ${qaResult.missing.map((line) => `"${line}"`).join(", ")}`);
|
|
362
422
|
const frames = new Map([...plan.all].map((frame) => [frame, decodeRgba(master.kept.get(frame))]));
|
|
363
423
|
stills = writeStillsSet(out, { width: context.width, height: context.height, fps: context.fps, frames, plan, round, pathName: "stage", beats: context.treatment.beats });
|
|
424
|
+
await boardRound(out, context, chromium, runDir, stills, { frameCount, qa: qaResult });
|
|
364
425
|
const poster = posterPng(decodeRgba(master.kept.get(posterFrame)), context.width, context.height);
|
|
365
426
|
writeFileSync(path.join(runDir, "poster.png"), poster.png);
|
|
366
427
|
writeFileSync(path.join(runDir, "reduced-motion.png"), encodePng(context.width, context.height, decodeRgba(master.kept.get(stillFrame)), 4, 9, true));
|
|
@@ -385,10 +446,11 @@ export async function renderStage(out, { round = 1, stillsOnly = false } = {}) {
|
|
|
385
446
|
const manifest = {
|
|
386
447
|
schemaVersion: 1, path: "stage", format: context.treatment.format, resolution: [context.width, context.height], fps: context.fps, frameCount,
|
|
387
448
|
durationSec: frameCount / context.fps, targetDurationSec: context.treatment.durationSec, seed: context.seed, craftRound: round,
|
|
388
|
-
chromeFlags: stage.flags, webgl, previewEncoder: preview?.encoder ?? null,
|
|
449
|
+
chromeFlags: stage.flags, rung: context.rung, renderer: webgl.renderer ?? null, ...(context.notices.length ? { warnings: [...context.notices] } : {}), webgl, previewEncoder: preview?.encoder ?? null,
|
|
389
450
|
preview: preview ? { width: preview.width, height: preview.height, fps: preview.fps, bytes: preview.bytes } : null,
|
|
390
451
|
posterFrame, posterSize: [poster.width, poster.height], stillFrame, determinismFrames: samples,
|
|
391
452
|
timing: { ...master.timing, totalSec: Number(((performance.now() - began) / 1000).toFixed(1)) },
|
|
453
|
+
...(context.plate ? { plate: { blender: context.plate.blender, engine: context.plate.engine, device: context.plate.device, samples: context.plate.samples, fps: context.plate.fps, frames: context.plate.frames.length } } : {}),
|
|
392
454
|
sound, qa: { file: ".run/qa.json", samples: qaResult.samples.length, trackFrames: qaResult.trackFrames, allowedFaces: qaResult.allowedFaces },
|
|
393
455
|
treatmentSha256: sha256(readFileSync(path.join(out, "treatment.json"))), stageSha256: stageSha(context.stageDir),
|
|
394
456
|
stillsSha256: sha256(readFileSync(path.join(out, "stills", "stills.json"))), pageErrors: stage.errors.slice(0, 5), missingAssets: stage.missing.slice(0, 5),
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// elements and APIs, flipbooks and animated rasters, and a raster budget overrun (exit 17). The same
|
|
4
4
|
// rules also run live in the page (clock.js); this scan names the file and line first.
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
6
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
|
|
9
9
|
export const STAGE_CONTRACT = 17;
|
|
@@ -15,6 +15,22 @@ export const contentTypes = Object.freeze({
|
|
|
15
15
|
".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
|
|
16
16
|
".gif": "image/gif", ".wav": "audio/wav", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".json": "application/json"
|
|
17
17
|
});
|
|
18
|
+
// The Blender plate (stage/plate/, and the preview stills in stage/plate-preview/): frames the runner wrote
|
|
19
|
+
// and plate.json lists by name and size. They are not page rasters. Returns { ...plate.json, dir } when every
|
|
20
|
+
// listed frame is there as listed, else null.
|
|
21
|
+
export function readPlate(stageDir, sub = "plate") {
|
|
22
|
+
const file = path.join(stageDir, sub, "plate.json");
|
|
23
|
+
if (!existsSync(file)) return null;
|
|
24
|
+
let plate;
|
|
25
|
+
try { plate = JSON.parse(readFileSync(file, "utf8")); } catch { return null; }
|
|
26
|
+
if (!Array.isArray(plate.frames)) return null;
|
|
27
|
+
const listed = plate.frames.every((f) => {
|
|
28
|
+
const full = path.join(stageDir, sub, String(f.name));
|
|
29
|
+
return path.basename(String(f.name)) === f.name && existsSync(full) && statSync(full).size === f.bytes;
|
|
30
|
+
});
|
|
31
|
+
return listed ? { ...plate, dir: sub } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export const rasterLimits = Object.freeze({ files: 24, bytes: 8 * 1024 * 1024, sameSizeFlipbook: 10 });
|
|
19
35
|
// XML namespace identifiers are names, not requests.
|
|
20
36
|
const namespaceUris = /https?:\/\/www\.w3\.org\/(?:2000\/svg|1999\/xlink|1999\/xhtml|XML\/1998\/namespace|2000\/xmlns\/?|1998\/Math\/MathML)/gu;
|
|
@@ -113,9 +129,12 @@ export function scanStage(stageDir) {
|
|
|
113
129
|
if (!files.includes("index.html")) throw contract("stage/index.html is missing");
|
|
114
130
|
const rasters = [];
|
|
115
131
|
let rasterBytes = 0;
|
|
132
|
+
const plates = ["plate", "plate-preview"].map((sub) => readPlate(stageDir, sub)).filter(Boolean);
|
|
133
|
+
const plated = new Set(plates.flatMap((plate) => plate.frames.map((f) => `${plate.dir}/${f.name}`)));
|
|
116
134
|
for (const rel of files) {
|
|
117
135
|
const ext = path.extname(rel).toLowerCase();
|
|
118
136
|
const full = path.join(stageDir, rel);
|
|
137
|
+
if (rel === "scene.py" || plated.has(rel)) continue;
|
|
119
138
|
if (!Object.hasOwn(contentTypes, ext)) throw contract(`${rel}: ${ext || "extensionless"} files are not served on the stage`);
|
|
120
139
|
if (rasterExtensions.has(ext)) {
|
|
121
140
|
const bytes = readFileSync(full);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// LitOpenCode stage kit, served at /lit/stage-kit.js. Motion primitives only: eases, a spring,
|
|
2
2
|
// keyframes, timing helpers, a seeded random source, text splitting, SVG path drawing, a single-path
|
|
3
|
-
// morph, mask and clip helpers, colour mixing and
|
|
4
|
-
// no scene, object
|
|
5
|
-
// a pure function of its arguments and the time the
|
|
3
|
+
// morph, mask and clip helpers, colour mixing, the shot and zone readers for the house style, and the
|
|
4
|
+
// text registration the QA replay reads. It ships no scene, object or copy; the page draws whatever
|
|
5
|
+
// its treatment calls for. Every function is a pure function of its arguments and the time the
|
|
6
|
+
// renderer passes to render(t).
|
|
6
7
|
(function () {
|
|
7
8
|
"use strict";
|
|
8
9
|
|
|
@@ -289,6 +290,86 @@
|
|
|
289
290
|
registered.push({ content: String(content), x, y, w, h, decor: Boolean(decor) });
|
|
290
291
|
}
|
|
291
292
|
|
|
293
|
+
// ---- the house style and the shot timeline ----
|
|
294
|
+
// The renderer serves both as /lit/style.js (window.LitStyle, window.LitTimeline), written from the
|
|
295
|
+
// treatment, so the page and the gate read one timeline and one set of zones. Read lazily: the page
|
|
296
|
+
// may load style.js before or after this kit.
|
|
297
|
+
const styleData = () => {
|
|
298
|
+
if (!window.LitStyle) throw new Error("LitStage: load /lit/style.js before the style helpers");
|
|
299
|
+
return window.LitStyle;
|
|
300
|
+
};
|
|
301
|
+
const timelineData = () => {
|
|
302
|
+
if (!window.LitTimeline) throw new Error("LitStage: load /lit/style.js before the shot helpers");
|
|
303
|
+
return window.LitTimeline;
|
|
304
|
+
};
|
|
305
|
+
const tokenCache = new WeakMap();
|
|
306
|
+
// The style's motion tokens as functions: eases, durations, staggers and a spring per object class
|
|
307
|
+
// from its damping ratio and frequency.
|
|
308
|
+
function tokens() {
|
|
309
|
+
const style = styleData();
|
|
310
|
+
if (tokenCache.has(style)) return tokenCache.get(style);
|
|
311
|
+
const m = style.motion;
|
|
312
|
+
const springOf = ({ zeta, hz }) => {
|
|
313
|
+
const k = (2 * Math.PI * hz) ** 2;
|
|
314
|
+
return spring({ stiffness: k, damping: 2 * zeta * Math.sqrt(k), mass: 1 });
|
|
315
|
+
};
|
|
316
|
+
const value = Object.freeze({
|
|
317
|
+
ease: Object.freeze({ enter: bezier(...m.ease.enter), move: bezier(...m.ease.move), exit: bezier(...m.ease.exit) }),
|
|
318
|
+
dur: Object.freeze({ ...m.dur }), stagger: Object.freeze({ ...m.stagger }),
|
|
319
|
+
spring: Object.freeze(Object.fromEntries(Object.entries(m.spring).map(([k, v]) => [k, springOf(v)]))),
|
|
320
|
+
secondary: Object.freeze({ ...m.secondary }), ambient: Object.freeze({ ...m.ambient }), push: m.push
|
|
321
|
+
});
|
|
322
|
+
tokenCache.set(style, value);
|
|
323
|
+
return value;
|
|
324
|
+
}
|
|
325
|
+
// The shot at time t: index, beat, layout, local seconds and progress, plus an entrance and an exit
|
|
326
|
+
// on the style's eases (enter(delay, dur) rises 0 -> 1, exit(dur) rises 0 -> 1 at the shot's end).
|
|
327
|
+
function shot(t) {
|
|
328
|
+
const beats = timelineData().beats;
|
|
329
|
+
let i = beats.findIndex((b) => t >= b.t0 && t < b.t1);
|
|
330
|
+
if (i < 0) i = t < beats[0].t0 ? 0 : beats.length - 1;
|
|
331
|
+
const b = beats[i];
|
|
332
|
+
const length = b.t1 - b.t0;
|
|
333
|
+
const tk = tokens();
|
|
334
|
+
return {
|
|
335
|
+
index: i, beat: b, layout: b.layout, transition: b.transition, local: t - b.t0, length, p: clamp((t - b.t0) / length),
|
|
336
|
+
enter: (delay = 0, dur = tk.dur.hero) => at(t, b.t0 + delay, dur, tk.ease.enter),
|
|
337
|
+
exit: (dur = tk.dur.exit) => at(t, b.t1 - dur, dur, tk.ease.exit)
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// A layout zone in pixels ({ x, y, w, h }) for this film's format, and placing an element in it.
|
|
341
|
+
function zone(layout, part = "type") {
|
|
342
|
+
const z = styleData().zones[layout];
|
|
343
|
+
if (!z) throw new Error(`LitStage.zone: unknown layout ${layout}`);
|
|
344
|
+
if (!z[part]) throw new Error(`LitStage.zone: the ${layout} layout has no ${part} zone`);
|
|
345
|
+
return { ...z[part] };
|
|
346
|
+
}
|
|
347
|
+
function place(el, layout, part = "type") {
|
|
348
|
+
const z = zone(layout, part);
|
|
349
|
+
Object.assign(el.style, { position: "absolute", left: `${z.x}px`, top: `${z.y}px`, width: `${z.w}px`, height: `${z.h}px`, boxSizing: "border-box" });
|
|
350
|
+
return z;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// The Blender plate (render3d: "blender"): a full-frame <img> behind the type showing the plate frame for t.
|
|
354
|
+
// The element is replaced when the frame changes, and the renderer waits for its decode before the capture.
|
|
355
|
+
// Returns update(t); call it from render(t).
|
|
356
|
+
function plate({ parent, fps = 30, count, dir = "plate" } = {}) {
|
|
357
|
+
if (!parent || !(count > 0)) throw new Error("LitStage.plate: needs { parent, count } (count: the frames in plate/plate.json)");
|
|
358
|
+
let shown = -1, el = null;
|
|
359
|
+
return (t) => {
|
|
360
|
+
const i = Math.min(count - 1, Math.max(0, Math.floor(t * fps + 1e-6)));
|
|
361
|
+
if (i === shown) return i;
|
|
362
|
+
const next = document.createElement("img");
|
|
363
|
+
next.src = `${dir}/${String(i + 1).padStart(6, "0")}.png`;
|
|
364
|
+
next.alt = "";
|
|
365
|
+
Object.assign(next.style, { position: "absolute", inset: "0", width: "100%", height: "100%", objectFit: "cover" });
|
|
366
|
+
if (el) el.replaceWith(next); else parent.prepend(next);
|
|
367
|
+
el = next;
|
|
368
|
+
shown = i;
|
|
369
|
+
return i;
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
292
373
|
// ---- the stage contract ----
|
|
293
374
|
let definition = null;
|
|
294
375
|
function define(spec) {
|
|
@@ -301,7 +382,7 @@
|
|
|
301
382
|
const seeded = typeof window.__litStage?.seed === "number" ? window.__litStage.seed : 1;
|
|
302
383
|
window.LitStage = {
|
|
303
384
|
define, get definition() { return definition ?? window.litStage ?? null; },
|
|
304
|
-
clamp, lerp, mapRange, ease, bezier, spring, kf, at, stagger, seq,
|
|
385
|
+
clamp, lerp, mapRange, ease, bezier, spring, kf, at, stagger, seq, shot, zone, place, plate, tokens,
|
|
305
386
|
rand: Object.assign((seed) => rand(seed === undefined ? seeded : seed), { seed: seeded }),
|
|
306
387
|
splitText, drawPath, morph, clip, mask, mix, text: Object.assign((arg, opts) => (arg instanceof Element ? text(arg, opts) : registerText(arg)), { registered }),
|
|
307
388
|
get registeredText() { return registered; },
|
|
@@ -18,7 +18,7 @@ export function stillsPlan({ beats, cuts, frameCount, fps }) {
|
|
|
18
18
|
return { beatFrames, strips, sheet, all };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function tile(images, cols, tileW, tileH, gap) {
|
|
21
|
+
export function tile(images, cols, tileW, tileH, gap) {
|
|
22
22
|
const rows = Math.ceil(images.length / cols);
|
|
23
23
|
const width = cols * tileW + (cols + 1) * gap, height = rows * tileH + (rows + 1) * gap;
|
|
24
24
|
const out = Buffer.alloc(width * height * 4);
|