@prestyj/cli 5.28.0 → 5.28.1

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.
Files changed (143) hide show
  1. package/README.md +2 -2
  2. package/assets/motion/THIRD-PARTY.md +14 -19
  3. package/assets/motion/bin/flash-check.mjs +314 -0
  4. package/assets/motion/bin/fonts.mjs +17 -13
  5. package/assets/motion/bin/library.mjs +8 -8
  6. package/assets/motion/bin/motion-check.mjs +12 -6
  7. package/assets/motion/fonts/finger-paint/OFL.txt +93 -0
  8. package/assets/motion/fonts/finger-paint/finger-paint-normal.woff2 +0 -0
  9. package/assets/motion/fonts/fonts.json +56 -0
  10. package/assets/motion/fonts/short-stack/OFL.txt +94 -0
  11. package/assets/motion/fonts/short-stack/short-stack-normal.woff2 +0 -0
  12. package/assets/motion/fonts/sora/OFL.txt +93 -0
  13. package/assets/motion/fonts/sora/sora-normal.woff2 +0 -0
  14. package/assets/motion/fonts/specimen.jpg +0 -0
  15. package/assets/motion/fonts/unbounded/OFL.txt +93 -0
  16. package/assets/motion/fonts/unbounded/unbounded-normal.woff2 +0 -0
  17. package/assets/motion/library/README.md +2 -0
  18. package/assets/motion/plugin.json +1 -1
  19. package/assets/motion/references/motion-language.md +128 -0
  20. package/assets/motion/references/runtime/determinism-rules.md +3 -3
  21. package/assets/motion/references/runtime/gsap-easing-and-stagger.md +19 -19
  22. package/assets/motion/references/runtime/gsap.md +3 -3
  23. package/assets/motion/references/runtime/inputs-and-assets.md +19 -9
  24. package/assets/motion/references/runtime/minimal-composition.md +6 -0
  25. package/assets/motion/skills/brand-kit/SKILL.md +16 -15
  26. package/assets/motion/skills/motion/SKILL.md +166 -90
  27. package/assets/motion/skills/source-ingest/SKILL.md +7 -10
  28. package/assets/motion/skills/video-qa/SKILL.md +43 -20
  29. package/dist/cli.js +4 -3
  30. package/dist/cli.js.map +1 -1
  31. package/dist/core/agent-session.d.ts +13 -3
  32. package/dist/core/agent-session.js +32 -6
  33. package/dist/core/agent-session.js.map +1 -1
  34. package/dist/core/agents.js +12 -3
  35. package/dist/core/agents.js.map +1 -1
  36. package/dist/core/auth-providers.js +1 -1
  37. package/dist/core/auth-providers.js.map +1 -1
  38. package/dist/core/autopilot-verdict.d.ts +5 -1
  39. package/dist/core/autopilot-verdict.js +30 -13
  40. package/dist/core/autopilot-verdict.js.map +1 -1
  41. package/dist/core/fast-apply-benchmark.d.ts +1 -1
  42. package/dist/core/fast-apply-benchmark.js +2 -2
  43. package/dist/core/fast-apply-benchmark.js.map +1 -1
  44. package/dist/core/ideal-review-subagent.d.ts +16 -2
  45. package/dist/core/ideal-review-subagent.js +19 -2
  46. package/dist/core/ideal-review-subagent.js.map +1 -1
  47. package/dist/core/nolan-context.d.ts +7 -5
  48. package/dist/core/nolan-context.js +106 -16
  49. package/dist/core/nolan-context.js.map +1 -1
  50. package/dist/core/nolan-prompt.js +24 -21
  51. package/dist/core/nolan-prompt.js.map +1 -1
  52. package/dist/core/project-discovery.js +77 -26
  53. package/dist/core/project-discovery.js.map +1 -1
  54. package/dist/core/semantic-search-benchmark.d.ts +1 -1
  55. package/dist/core/semantic-search-benchmark.js +2 -2
  56. package/dist/core/semantic-search-benchmark.js.map +1 -1
  57. package/dist/core/skills.js +13 -3
  58. package/dist/core/skills.js.map +1 -1
  59. package/dist/core/subagent-manager.d.ts +3 -1
  60. package/dist/core/subagent-manager.js +3 -1
  61. package/dist/core/subagent-manager.js.map +1 -1
  62. package/dist/core/subagent-policy.js +1 -1
  63. package/dist/core/subagent-policy.js.map +1 -1
  64. package/dist/core/worktree-setup.d.ts +23 -0
  65. package/dist/core/worktree-setup.js +128 -9
  66. package/dist/core/worktree-setup.js.map +1 -1
  67. package/dist/core/worktree.d.ts +20 -2
  68. package/dist/core/worktree.js +74 -31
  69. package/dist/core/worktree.js.map +1 -1
  70. package/dist/modes/json-mode.js +11 -2
  71. package/dist/modes/json-mode.js.map +1 -1
  72. package/dist/modes/subagent-worker-mode.d.ts +36 -1
  73. package/dist/modes/subagent-worker-mode.js +68 -38
  74. package/dist/modes/subagent-worker-mode.js.map +1 -1
  75. package/dist/motion-agent/motion-agent.d.ts +1 -1
  76. package/dist/motion-agent/motion-agent.js +1 -1
  77. package/dist/motion-agent/motion-check-tool.d.ts +15 -1
  78. package/dist/motion-agent/motion-check-tool.js +345 -37
  79. package/dist/motion-agent/motion-check-tool.js.map +1 -1
  80. package/dist/motion-agent/motion-prompt.d.ts +1 -1
  81. package/dist/motion-agent/motion-prompt.js +16 -20
  82. package/dist/motion-agent/motion-prompt.js.map +1 -1
  83. package/dist/motion-agent/motion-review-session.js +1 -1
  84. package/dist/motion-agent/motion-review-session.js.map +1 -1
  85. package/dist/motion-agent/motion-studio-context.js +1 -1
  86. package/dist/motion-agent/motion-studio-context.js.map +1 -1
  87. package/dist/system-prompt.d.ts +2 -1
  88. package/dist/system-prompt.js +12 -3
  89. package/dist/system-prompt.js.map +1 -1
  90. package/dist/tools/bash.js +64 -0
  91. package/dist/tools/bash.js.map +1 -1
  92. package/dist/tools/edit.js +1 -1
  93. package/dist/tools/edit.js.map +1 -1
  94. package/dist/tools/goals.d.ts +1 -1
  95. package/dist/tools/index.d.ts +6 -0
  96. package/dist/tools/index.js +2 -1
  97. package/dist/tools/index.js.map +1 -1
  98. package/dist/tools/read-tracker.d.ts +30 -2
  99. package/dist/tools/read-tracker.js +92 -5
  100. package/dist/tools/read-tracker.js.map +1 -1
  101. package/dist/tools/read.js +36 -5
  102. package/dist/tools/read.js.map +1 -1
  103. package/dist/tools/subagent-shared.d.ts +19 -0
  104. package/dist/tools/subagent-shared.js +30 -0
  105. package/dist/tools/subagent-shared.js.map +1 -1
  106. package/dist/tools/write.js +4 -3
  107. package/dist/tools/write.js.map +1 -1
  108. package/dist/ui/components/Footer.js +1 -1
  109. package/dist/ui/components/Footer.js.map +1 -1
  110. package/dist/ui/render.d.ts +2 -0
  111. package/dist/ui/render.js +3 -0
  112. package/dist/ui/render.js.map +1 -1
  113. package/dist/utils/text.d.ts +12 -0
  114. package/dist/utils/text.js +11 -0
  115. package/dist/utils/text.js.map +1 -1
  116. package/package.json +5 -5
  117. package/assets/motion/skills/mixkit-split-text-617/SKILL.md +0 -115
  118. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-480.json +0 -1
  119. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-494.json +0 -1
  120. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-5.json +0 -1
  121. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-508.json +0 -1
  122. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6525.json +0 -1
  123. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6539.json +0 -1
  124. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6647.json +0 -1
  125. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6663.json +0 -1
  126. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6677.json +0 -1
  127. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6691.json +0 -1
  128. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6706.json +0 -1
  129. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6722.json +0 -1
  130. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6736.json +0 -1
  131. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6750.json +0 -1
  132. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6764.json +0 -1
  133. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6780.json +0 -1
  134. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6794.json +0 -1
  135. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6810.json +0 -1
  136. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6823.json +0 -1
  137. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6872.json +0 -1
  138. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-6936.json +0 -1
  139. package/assets/motion/skills/mixkit-split-text-617/data/compositions/comp-76.json +0 -1
  140. package/assets/motion/skills/mixkit-split-text-617/data/manifest.json +0 -495
  141. package/assets/motion/skills/mixkit-split-text-617/references/RECONSTRUCTION.md +0 -201
  142. package/assets/motion/skills/mixkit-split-text-617/references/VERIFICATION.md +0 -107
  143. package/assets/motion/skills/mixkit-split-text-617/tools/inspect_motion.py +0 -130
package/README.md CHANGED
@@ -71,7 +71,7 @@ Switch mid-conversation with `/model`. Not locked to anyone.
71
71
  | Provider | Models | Auth |
72
72
  | ----------------- | ---------------------------------------------------------------------------- | ---------------- |
73
73
  | **Anthropic** | Claude Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 4.5 | OAuth |
74
- | **OpenAI** | GPT-6 Astra, GPT-6 Sol, GPT-6 Luna | OAuth |
74
+ | **OpenAI** | GPT-6 Astra, GPT-6.1 Sol, GPT-6 Luna | OAuth |
75
75
  | **Moonshot** | Kimi K3, Kimi K2.8 Preview, Kimi K2.7 Code | OAuth or API key |
76
76
  | **Z.AI (GLM)** | GLM-5.3, GLM-5.3-Flash (image) | API key |
77
77
  | **MiniMax** | MiniMax M3 (image + video) | API key |
@@ -177,7 +177,7 @@ Plus the [Grep MCP](https://grep.dev) for searching across 1M+ public GitHub rep
177
177
 
178
178
  `subagent` remains blocking. The async suite launches persistent NDJSON worker processes, so a parent can start up to four active child turns, keep working, steer them, and wait for any or all results. Up to eight idle workers remain available for follow-up; bounded snapshots retain the latest 20 agents.
179
179
 
180
- Only GPT-6 Astra/Sol at **Ultra** delegates proactively. Lower Astra/Sol levels use async agents only when the user or project/skill instructions request delegation; other models receive no proactive policy.
180
+ Only GPT-6 Astra and GPT-6.1 Sol at **Ultra** delegate proactively. Lower Astra/Sol levels use async agents only when the user or project/skill instructions request delegation; other models receive no proactive policy.
181
181
 
182
182
  Children share the parent working directory by default, so parallel writes must target disjoint files or subsystems. Pass `isolate: true` to `subagent` to give a child its own copy of the repo on a new branch instead — the right choice when several children edit at once, or when a child's changes should land on a branch of their own. The copy is reclaimed automatically when the child finishes without committing; a child that did commit keeps its branch, because that branch is the deliverable. `/integrate` then merges those branches one at a time, running the suite between each. Async fan-out is one level deep, child output is bounded, idle workers reap after 10 minutes, and workers are not resumable after a CLI/app restart.
183
183
 
@@ -1,10 +1,10 @@
1
1
  # Third-party content in EZ Motion
2
2
 
3
- Motion exposes GG-authored support skills and explicitly selected, source-backed
4
- recipes. The old upstream creative/workflow skills and guidance adapters have
5
- been removed. Retained technical references are adapted for GG's pinned launcher,
6
- local-only safety boundaries and recipe-led workflow; do not restore entire
7
- upstream skill folders when updating the runtime.
3
+ Motion exposes GG-authored skills and craft references. The old upstream
4
+ creative/workflow skills and guidance adapters have been removed. Retained
5
+ technical references are adapted for GG's pinned launcher and local-only safety
6
+ boundaries; do not restore entire upstream skill folders when updating the
7
+ runtime.
8
8
 
9
9
  | Component | Source | Pinned at | License |
10
10
  |---|---|---|---|
@@ -14,17 +14,11 @@ upstream skill folders when updating the runtime.
14
14
  | Style-library pieces marked MIT (`library/pieces/<id>/`) | Magic UI, https://github.com/magicuidesign/magicui | converted 2026-09-28; upstream file URL in each `meta.json` | MIT (`LICENSE` beside each piece) |
15
15
  | Fonts (`fonts/<family>/`) | Google Fonts / https://github.com/google/fonts | fetched 2026-09-28 by `scripts/fetch-motion-fonts.mjs` | SIL OFL 1.1 (`OFL.txt` beside each family) |
16
16
 
17
- ## Recipe and evidence boundaries
17
+ ## Content boundaries
18
18
 
19
- The Mixkit 617 recipe records the user's supplied project provenance and limits
20
- in its own skill/manifest. Extraction or purchase is not a redistribution license;
21
- its source/derivative-data rights require review before a public release. No AEP,
22
- source preview video or source font binary is bundled with that recipe.
23
-
24
- Private extraction tools, source projects, working exports and authoring-method
25
- documents are Git-ignored and excluded from release packaging. They are not
26
- runtime dependencies or additional callable skills. The normal video agent uses
27
- installed recipes; it does not run a parser or install authoring dependencies.
19
+ Motion ships no templates derived from third-party After Effects or other
20
+ motion projects. The earlier Mixkit, Mobile Notification and Kinetic Text
21
+ reconstructions were removed on 2026-09-29 and are not part of any release.
28
22
 
29
23
  Existing `references/index.json`, signal sources and compact temporal JPEGs are
30
24
  GG studies, not private user videos or a universal style. They remain optional
@@ -34,14 +28,15 @@ licenses remain with their sources.
34
28
 
35
29
  The Motion-only preference reader and review tool do not load Coder instructions.
36
30
  Review remains bounded and tied to current source/render evidence. It checks the
37
- selected recipe and approved inputs, not a competing art direction, and does not
31
+ video's plan and approved inputs, not a competing art direction, and does not
38
32
  claim full playback or aesthetic certification from model image critique.
39
33
 
40
34
  ## Fonts
41
35
 
42
- 15 families, listed in `fonts/fonts.json`. Latin-subset variable woff2 as served
43
- by Google Fonts, except Mona Sans and Hubot Sans: they carry OFL Reserved Font
44
- Names and ship as complete upstream fonts, recompressed to woff2. `fonts.mjs add`
36
+ 19 families, listed in `fonts/fonts.json`. Latin-subset variable woff2 as served
37
+ by Google Fonts, except Mona Sans, Hubot Sans, Short Stack and Finger Paint: they
38
+ carry OFL Reserved Font Names and ship as complete upstream fonts, recompressed
39
+ to woff2. Unbounded, Sora, Short Stack and Finger Paint were added 2026-09-29. `fonts.mjs add`
45
40
  copies each family's `OFL.txt` into the video project with its font files.
46
41
 
47
42
  ## Shared audio retained from brag
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env node
2
+ // Photosensitivity screen on decoded pixels against the WCAG 2.3.1 general and red flash
3
+ // thresholds. As in EA's open-source IRIS tool, an area's mean change only counts on frames
4
+ // where at least a quarter of the area changes, so moving content mostly cancels out rather
5
+ // than reading as flashing. IRIS applies this to the whole screen; here it applies to every
6
+ // WCAG 10° field (a third of the frame each way), so a flash filling part of the frame is
7
+ // caught too. A transition is a move of 0.1 relative luminance (or 20 red units) away from the
8
+ // last peak or valley, as WCAG defines it; more than six in any second fails. Renders above
9
+ // 60 fps are sampled at 60. A screen, not a certification.
10
+ import { spawn } from "node:child_process";
11
+ import { createReadStream } from "node:fs";
12
+ import { stat } from "node:fs/promises";
13
+ import { createHash } from "node:crypto";
14
+ import { once } from "node:events";
15
+ import { resolve } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { ffmpegBinary } from "./media-binaries.mjs";
18
+
19
+ /** Cells per side of the analysis grid; every aspect ratio maps onto it. */
20
+ export const GRID = 48;
21
+ /** Frames per analysed second; slower renders are repeated, so one second is always 60 frames. */
22
+ export const ANALYSIS_FPS = 60;
23
+ /** WCAG approximates the 10° field as 341×256 px of a 1024×768 screen: a third each way. */
24
+ const FIELD = GRID / 3;
25
+ const FIELD_CELLS = FIELD * FIELD;
26
+ /** A field's change counts once a quarter of it changes. */
27
+ const AREA = 0.25 * FIELD_CELLS;
28
+ const WINDOWS_PER_SIDE = GRID - FIELD + 1;
29
+ const WINDOWS = WINDOWS_PER_SIDE * WINDOWS_PER_SIDE;
30
+ /** More than three flashes (six opposing transitions) within any one second fails. */
31
+ const MAX_TRANSITIONS = 6;
32
+ /** Transitions per field within the trailing second never exceed one per analysed frame. */
33
+ const RING = 64;
34
+ const CELLS = GRID * GRID;
35
+ const FRAME_BYTES = CELLS * 3;
36
+ const CHANNELS = {
37
+ // Relative luminance 0–1: a 0.1 swing, unless even the darker state is 0.8 or brighter.
38
+ // A cell has changed once it moves by more than encoding noise (about 1/255 linear).
39
+ general: { threshold: 0.1, darkLimit: 0.8, noise: 0.004 },
40
+ // WCAG's red measure: (R − G − B) × 320 where R / (R + G + B) ≥ 0.8, else 0.
41
+ red: { threshold: 20, darkLimit: Infinity, noise: 1 },
42
+ };
43
+ /** sRGB to linear light, applied before averaging so mixed cells keep their real brightness. */
44
+ const LINEAR = "if(lte(val,10.31475),val/12.92,255*pow((val/255+0.055)/1.055,2.4))";
45
+
46
+ function createChannel({ threshold, darkLimit, noise }) {
47
+ const side = GRID + 1;
48
+ const changed = new Float64Array(side * side);
49
+ const change = new Float64Array(side * side);
50
+ const level = new Float64Array(side * side);
51
+ // Per field: the accumulated counted change, its last peak and valley, and the field's
52
+ // mean at each, for WCAG's "darker image" rule.
53
+ const signal = new Float64Array(WINDOWS);
54
+ const peakValue = new Float64Array(WINDOWS);
55
+ const peakLevel = new Float64Array(WINDOWS);
56
+ const valleyValue = new Float64Array(WINDOWS);
57
+ const valleyLevel = new Float64Array(WINDOWS);
58
+ /** 1 after a rise, -1 after a fall, 0 before the first transition. */
59
+ const direction = new Int8Array(WINDOWS);
60
+ const ring = new Int32Array(WINDOWS * RING);
61
+ const head = new Int32Array(WINDOWS);
62
+ const tail = new Int32Array(WINDOWS);
63
+ const failures = [];
64
+ let peak = 0;
65
+
66
+ return {
67
+ update(frame, previous, current) {
68
+ // Summed-area tables, so each field's totals cost four lookups.
69
+ for (let y = 0; y < GRID; y++) {
70
+ let rowChanged = 0;
71
+ let rowChange = 0;
72
+ let rowLevel = 0;
73
+ for (let x = 0; x < GRID; x++) {
74
+ const cell = y * GRID + x;
75
+ const delta = current[cell] - previous[cell];
76
+ if (Math.abs(delta) > noise) rowChanged++;
77
+ rowChange += delta;
78
+ rowLevel += current[cell];
79
+ const at = (y + 1) * side + x + 1;
80
+ changed[at] = changed[at - side] + rowChanged;
81
+ change[at] = change[at - side] + rowChange;
82
+ level[at] = level[at - side] + rowLevel;
83
+ }
84
+ }
85
+ let most = 0;
86
+ for (let wy = 0; wy < WINDOWS_PER_SIDE; wy++) {
87
+ for (let wx = 0; wx < WINDOWS_PER_SIDE; wx++) {
88
+ const window = wy * WINDOWS_PER_SIDE + wx;
89
+ const a = wy * side + wx;
90
+ const b = a + FIELD;
91
+ const c = a + FIELD * side;
92
+ const d = c + FIELD;
93
+ const area = changed[d] - changed[b] - changed[c] + changed[a];
94
+ const step = (change[d] - change[b] - change[c] + change[a]) / FIELD_CELLS;
95
+ const mean = (level[d] - level[b] - level[c] + level[a]) / FIELD_CELLS;
96
+ if (frame === 1) {
97
+ peakLevel[window] = mean - step;
98
+ valleyLevel[window] = mean - step;
99
+ }
100
+ const value = signal[window] + (area >= AREA ? step : 0);
101
+ signal[window] = value;
102
+ let transition = false;
103
+ if (direction[window] >= 0) {
104
+ if (value > peakValue[window]) {
105
+ peakValue[window] = value;
106
+ peakLevel[window] = mean;
107
+ } else if (peakValue[window] - value >= threshold) {
108
+ transition = Math.min(mean, peakLevel[window]) < darkLimit;
109
+ direction[window] = -1;
110
+ valleyValue[window] = value;
111
+ valleyLevel[window] = mean;
112
+ }
113
+ }
114
+ if (direction[window] <= 0) {
115
+ if (value < valleyValue[window]) {
116
+ valleyValue[window] = value;
117
+ valleyLevel[window] = mean;
118
+ } else if (value - valleyValue[window] >= threshold) {
119
+ transition = Math.min(mean, valleyLevel[window]) < darkLimit;
120
+ direction[window] = 1;
121
+ peakValue[window] = value;
122
+ peakLevel[window] = mean;
123
+ }
124
+ }
125
+ const base = window * RING;
126
+ while (
127
+ tail[window] < head[window] &&
128
+ frame - ring[base + (tail[window] % RING)] >= ANALYSIS_FPS
129
+ )
130
+ tail[window]++;
131
+ if (transition) {
132
+ ring[base + (head[window] % RING)] = frame;
133
+ head[window]++;
134
+ }
135
+ const count = head[window] - tail[window];
136
+ if (count > most) most = count;
137
+ }
138
+ }
139
+ peak = Math.max(peak, most);
140
+ if (most > MAX_TRANSITIONS) {
141
+ const start = Math.max(0, frame + 1 - ANALYSIS_FPS) / ANALYSIS_FPS;
142
+ const end = (frame + 1) / ANALYSIS_FPS;
143
+ const last = failures.at(-1);
144
+ if (last && start <= last.end) last.end = end;
145
+ else failures.push({ start, end });
146
+ }
147
+ },
148
+ summary() {
149
+ return {
150
+ maxFlashesPerSecond: peak / 2,
151
+ failures: failures.slice(0, 20).map(({ start, end }) => ({
152
+ start: Math.round(start * 100) / 100,
153
+ end: Math.round(end * 100) / 100,
154
+ })),
155
+ };
156
+ },
157
+ };
158
+ }
159
+
160
+ /** Pure analysis over linear-light RGB frames of GRID × GRID cells (3 bytes per cell). */
161
+ export function createFlashAnalyzer() {
162
+ const general = createChannel(CHANNELS.general);
163
+ const red = createChannel(CHANNELS.red);
164
+ let luminance = new Float64Array(CELLS);
165
+ let redness = new Float64Array(CELLS);
166
+ let previousLuminance = new Float64Array(CELLS);
167
+ let previousRedness = new Float64Array(CELLS);
168
+ let frames = 0;
169
+ return {
170
+ push(frame) {
171
+ if (!(frame instanceof Uint8Array) || frame.length !== FRAME_BYTES)
172
+ throw new Error(`Expected ${FRAME_BYTES}-byte analysis frames`);
173
+ for (let cell = 0; cell < CELLS; cell++) {
174
+ const r = frame[cell * 3] / 255;
175
+ const g = frame[cell * 3 + 1] / 255;
176
+ const b = frame[cell * 3 + 2] / 255;
177
+ luminance[cell] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
178
+ const total = r + g + b;
179
+ redness[cell] = total > 0 && r / total >= 0.8 ? Math.max(0, (r - g - b) * 320) : 0;
180
+ }
181
+ if (frames > 0) {
182
+ general.update(frames, previousLuminance, luminance);
183
+ red.update(frames, previousRedness, redness);
184
+ }
185
+ [previousLuminance, luminance] = [luminance, previousLuminance];
186
+ [previousRedness, redness] = [redness, previousRedness];
187
+ frames++;
188
+ },
189
+ result() {
190
+ const generalSummary = general.summary();
191
+ const redSummary = red.summary();
192
+ return {
193
+ ok: frames >= 2 && !generalSummary.failures.length && !redSummary.failures.length,
194
+ method: "rendered-pixels",
195
+ standard: "WCAG 2.3.1 general and red flash thresholds",
196
+ analysisFps: ANALYSIS_FPS,
197
+ frames,
198
+ duration: frames / ANALYSIS_FPS,
199
+ general: generalSummary,
200
+ red: redSummary,
201
+ };
202
+ },
203
+ };
204
+ }
205
+
206
+ /** Decode a video into linear-light analysis frames and return the flash report. */
207
+ export async function analyzeFlashes(file, signal) {
208
+ const binary = await ffmpegBinary();
209
+ const child = spawn(
210
+ binary,
211
+ [
212
+ "-hide_banner",
213
+ "-nostdin",
214
+ "-loglevel",
215
+ "error",
216
+ "-xerror",
217
+ "-protocol_whitelist",
218
+ "file,pipe",
219
+ "-i",
220
+ file,
221
+ "-map",
222
+ "0:v:0",
223
+ "-an",
224
+ "-vf",
225
+ [
226
+ // Linearise before any averaging: shrinking sRGB values first darkens fine detail
227
+ // by up to half the flash threshold.
228
+ "format=gbrp",
229
+ `lutrgb=r='${LINEAR}':g='${LINEAR}':b='${LINEAR}'`,
230
+ `scale=${GRID}:${GRID}:flags=area`,
231
+ `fps=${ANALYSIS_FPS}`,
232
+ "format=rgb24",
233
+ ].join(","),
234
+ "-f",
235
+ "rawvideo",
236
+ "-pix_fmt",
237
+ "rgb24",
238
+ "pipe:1",
239
+ ],
240
+ { stdio: ["ignore", "pipe", "pipe"], windowsHide: true, signal },
241
+ );
242
+ let stderr = "";
243
+ child.stderr.setEncoding("utf8");
244
+ child.stderr.on("data", (text) => {
245
+ if (stderr.length < 16_000) stderr += text;
246
+ });
247
+ const closed = once(child, "close");
248
+ closed.catch(() => {});
249
+ const analyzer = createFlashAnalyzer();
250
+ const frame = new Uint8Array(FRAME_BYTES);
251
+ let filled = 0;
252
+ for await (const chunk of child.stdout) {
253
+ let offset = 0;
254
+ while (offset < chunk.length) {
255
+ const take = Math.min(FRAME_BYTES - filled, chunk.length - offset);
256
+ frame.set(chunk.subarray(offset, offset + take), filled);
257
+ filled += take;
258
+ offset += take;
259
+ if (filled === FRAME_BYTES) {
260
+ analyzer.push(frame);
261
+ filled = 0;
262
+ }
263
+ }
264
+ }
265
+ const [code] = await closed;
266
+ if (code !== 0)
267
+ throw new Error(`FFmpeg could not decode the video: ${stderr.trim().slice(-2000)}`);
268
+ if (filled !== 0) throw new Error("Decoded video ended mid-frame; flashing is unverified");
269
+ const result = analyzer.result();
270
+ if (result.frames < 2) throw new Error("Too few decoded frames; flashing is unverified");
271
+ return result;
272
+ }
273
+
274
+ async function videoHash(file, signal) {
275
+ const hash = createHash("sha256");
276
+ for await (const chunk of createReadStream(file, { signal })) hash.update(chunk);
277
+ return hash.digest("hex");
278
+ }
279
+
280
+ async function main() {
281
+ const [video, ...extra] = process.argv.slice(2);
282
+ if (!video || extra.length) throw new Error("usage: flash-check.mjs <rendered-video>");
283
+ const file = resolve(video);
284
+ if (!(await stat(file)).isFile()) throw new Error("Expected a local rendered video file");
285
+ const controller = new AbortController();
286
+ const abort = () => controller.abort();
287
+ process.once("SIGTERM", abort);
288
+ process.once("SIGINT", abort);
289
+ const started = performance.now();
290
+ try {
291
+ const videoSha256 = await videoHash(file, controller.signal);
292
+ const result = await analyzeFlashes(file, controller.signal);
293
+ if ((await videoHash(file, controller.signal)) !== videoSha256)
294
+ throw new Error("Video changed during analysis; flashing is unverified");
295
+ process.stdout.write(
296
+ `${JSON.stringify({ ...result, file, videoSha256, elapsedMs: Math.round(performance.now() - started) })}\n`,
297
+ );
298
+ if (!result.ok) process.exitCode = 1;
299
+ } finally {
300
+ process.removeListener("SIGTERM", abort);
301
+ process.removeListener("SIGINT", abort);
302
+ }
303
+ }
304
+
305
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
306
+ try {
307
+ await main();
308
+ } catch (error) {
309
+ process.stderr.write(
310
+ `Flash verification failed: ${error instanceof Error ? error.message : "invalid input"}\n`,
311
+ );
312
+ process.exitCode = 1;
313
+ }
314
+ }
@@ -7,9 +7,11 @@
7
7
  // node fonts.mjs add <project-dir> <Family> [<Family> ...]
8
8
  //
9
9
  // `add` copies each family's woff2 files and OFL.txt into
10
- // <project>/assets/fonts/<family>/ and rewrites <project>/assets/fonts/fonts.css
11
- // with an @font-face for every bundled family installed there. Link it from
12
- // the composition <head>: <link rel="stylesheet" href="assets/fonts/fonts.css">.
10
+ // <project>/assets/fonts/<family>/, rewrites <project>/assets/fonts/fonts.css,
11
+ // and returns `head`: a <style> block with an @font-face for every bundled
12
+ // family installed there, using project-relative paths. Paste `head` into the
13
+ // composition <head>. `hf check` only sees @font-face written in the page, so a
14
+ // linked fonts.css renders but is reported as missing fonts.
13
15
  // Prints one JSON line: {"ok":true,...} or {"ok":false,"error":...}.
14
16
  import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
15
17
  import { dirname, join, resolve } from "node:path";
@@ -23,15 +25,16 @@ function done(result, code = 0) {
23
25
  }
24
26
  const fail = (error) => done({ ok: false, error }, 1);
25
27
 
26
- /** @returns {Promise<Array<{family:string,dir:string,license:string,files:Array<{file:string,style:string,weight:string,stretch:string|null}>}>>} */
28
+ /** @returns {Promise<Array<{family:string,dir:string,license:string,use?:string,files:Array<{file:string,style:string,weight:string,stretch:string|null}>}>>} */
27
29
  async function manifest() {
28
30
  return JSON.parse(await readFile(join(LIBRARY, "fonts.json"), "utf8"));
29
31
  }
30
32
 
31
- function fontFace(entry, file) {
33
+ /** `base` is the font folder as seen from the file that holds the rule. */
34
+ function fontFace(entry, file, base = "") {
32
35
  const lines = [
33
36
  ` font-family: "${entry.family}";`,
34
- ` src: url("${entry.dir}/${file.file}") format("woff2");`,
37
+ ` src: url("${base}${entry.dir}/${file.file}") format("woff2");`,
35
38
  ` font-style: ${file.style};`,
36
39
  ` font-weight: ${file.weight};`,
37
40
  ];
@@ -52,6 +55,7 @@ if (command === "list") {
52
55
  styles: f.files.map((file) => file.style),
53
56
  weight: f.files[0]?.weight,
54
57
  width: f.files[0]?.stretch ?? null,
58
+ ...(f.use ? { use: f.use } : {}),
55
59
  })),
56
60
  });
57
61
  }
@@ -93,15 +97,15 @@ for (const entry of fonts) {
93
97
  );
94
98
  if (present) installed.push(entry);
95
99
  }
96
- const css = [
97
- "/* Generated by EZ Motion fonts.mjs. SIL OFL 1.1: licenses sit beside each family. */",
98
- ...installed.flatMap((entry) => entry.files.map((file) => fontFace(entry, file))),
99
- ].join("\n\n");
100
- await writeFile(join(target, "fonts.css"), `${css}\n`);
100
+ const rules = (base) =>
101
+ installed.flatMap((entry) => entry.files.map((file) => fontFace(entry, file, base)));
102
+ const header =
103
+ "/* Generated by EZ Motion fonts.mjs. SIL OFL 1.1: licenses sit beside each family. */";
104
+ await writeFile(join(target, "fonts.css"), `${[header, ...rules("")].join("\n\n")}\n`);
101
105
 
102
106
  done({
103
107
  ok: true,
104
108
  installed: installed.map((f) => f.family),
105
- stylesheet: join(target, "fonts.css"),
106
- link: '<link rel="stylesheet" href="assets/fonts/fonts.css" />',
109
+ head: `<style>\n${[header, ...rules("assets/fonts/")].join("\n")}\n</style>`,
110
+ use: "Paste head into the composition <head>, replacing any earlier fonts block.",
107
111
  });
@@ -46,14 +46,16 @@ async function projectDir(arg) {
46
46
  return dir;
47
47
  }
48
48
 
49
+ /** Returns the installed families and fonts.mjs's page-ready <style> block. */
49
50
  function installFonts(project, families) {
50
- if (families.length === 0) return [];
51
+ if (families.length === 0) return { installed: [], head: [] };
51
52
  const out = execFileSync(process.execPath, [join(BIN, "fonts.mjs"), "add", project, ...families], {
52
53
  encoding: "utf8",
53
54
  });
54
55
  const result = JSON.parse(out);
55
56
  if (!result.ok) fail(`Font install failed: ${result.error}`);
56
- return result.installed;
57
+ // Inline, not a linked fonts.css: `hf check` only sees @font-face written in the page.
58
+ return { installed: result.installed, head: [result.head] };
57
59
  }
58
60
 
59
61
  const summary = (entry, type) => ({
@@ -135,11 +137,9 @@ if (command === "look") {
135
137
  ok: true,
136
138
  look: look.id,
137
139
  tokens: css,
138
- fonts,
139
- head: [
140
- '<link rel="stylesheet" href="assets/fonts/fonts.css" />',
141
- `<link rel="stylesheet" href="assets/looks/${look.id}.css" />`,
142
- ],
140
+ fonts: fonts.installed,
141
+ head: [...fonts.head, `<link rel="stylesheet" href="assets/looks/${look.id}.css" />`],
142
+ use: "Paste head into the composition <head>, replacing any earlier fonts block.",
143
143
  root: `add class="look-${look.id}" to the root composition element`,
144
144
  spec: join(LIBRARY, "looks", look.id, "look.md"),
145
145
  });
@@ -196,7 +196,7 @@ if (command === "add") {
196
196
  ok: true,
197
197
  added,
198
198
  kept,
199
- fonts,
199
+ fonts: fonts.installed,
200
200
  ...(importmap ? { importmap, note: "put importmap in index.html <head> before any module script" } : {}),
201
201
  mount: pieces.map(
202
202
  (p) =>
@@ -102,12 +102,18 @@ export function parseMotionOutput(text, slideshowRequested = false, holds = [])
102
102
  error:
103
103
  "Holds cannot exempt the entire video; explicitly requested slideshows use their own mode",
104
104
  };
105
- if (
106
- holds.some(
107
- (hold) => !freezes.some((freeze) => freeze.end > hold.start && freeze.start < hold.end),
108
- )
109
- )
110
- return { ok: false, error: "Stale hold declaration: no detected freeze overlaps its window" };
105
+ const staleHolds = holds.filter(
106
+ (hold) => !freezes.some((freeze) => freeze.end > hold.start && freeze.start < hold.end),
107
+ );
108
+ // Name the stale holds and where pixels actually freeze, so the plan is fixed in one
109
+ // step instead of guessed again against another full check.
110
+ if (staleHolds.length)
111
+ return {
112
+ ok: false,
113
+ error: "Stale hold declaration: no detected freeze overlaps its window",
114
+ staleHolds,
115
+ freezes,
116
+ };
111
117
  const unexpectedFreezes = freezes.filter(
112
118
  (freeze) =>
113
119
  !holds.some(
@@ -0,0 +1,93 @@
1
+ Copyright (c) 2011 by Ralph du Carrois, with Reserved Font Name 'Finger Paint'
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.