frameyard 0.8.0 → 0.10.0

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 (327) hide show
  1. package/CHANGELOG.md +177 -14
  2. package/GLOSSARY.md +112 -2
  3. package/README.md +302 -265
  4. package/dist/assets/integrations.d.ts +8 -4
  5. package/dist/assets/integrations.d.ts.map +1 -1
  6. package/dist/assets/integrations.js +8 -4
  7. package/dist/assets/integrations.js.map +1 -1
  8. package/dist/assets/provenance.d.ts +2 -2
  9. package/dist/assets/provenance.d.ts.map +1 -1
  10. package/dist/assets/provenance.js +5 -3
  11. package/dist/assets/provenance.js.map +1 -1
  12. package/dist/assets/tts-supertonic.d.ts +28 -0
  13. package/dist/assets/tts-supertonic.d.ts.map +1 -0
  14. package/dist/assets/tts-supertonic.js +156 -0
  15. package/dist/assets/tts-supertonic.js.map +1 -0
  16. package/dist/assets/tts.d.ts +18 -6
  17. package/dist/assets/tts.d.ts.map +1 -1
  18. package/dist/assets/tts.js +219 -97
  19. package/dist/assets/tts.js.map +1 -1
  20. package/dist/cli.js +1685 -93
  21. package/dist/cli.js.map +1 -1
  22. package/dist/composition/format.d.ts +1 -1
  23. package/dist/composition/format.d.ts.map +1 -1
  24. package/dist/composition/format.js +1 -1
  25. package/dist/composition/format.js.map +1 -1
  26. package/dist/context.d.ts +82 -0
  27. package/dist/context.d.ts.map +1 -0
  28. package/dist/context.js +277 -0
  29. package/dist/context.js.map +1 -0
  30. package/dist/contracts.d.ts +25 -0
  31. package/dist/contracts.d.ts.map +1 -1
  32. package/dist/contracts.js.map +1 -1
  33. package/dist/doctor.d.ts +14 -1
  34. package/dist/doctor.d.ts.map +1 -1
  35. package/dist/doctor.js +166 -7
  36. package/dist/doctor.js.map +1 -1
  37. package/dist/errors.d.ts +1 -1
  38. package/dist/errors.d.ts.map +1 -1
  39. package/dist/errors.js.map +1 -1
  40. package/dist/fonts/add.d.ts +41 -0
  41. package/dist/fonts/add.d.ts.map +1 -0
  42. package/dist/fonts/add.js +259 -0
  43. package/dist/fonts/add.js.map +1 -0
  44. package/dist/fonts/binary.d.ts +4 -0
  45. package/dist/fonts/binary.d.ts.map +1 -0
  46. package/dist/fonts/binary.js +21 -0
  47. package/dist/fonts/binary.js.map +1 -0
  48. package/dist/fonts/css.d.ts +4 -0
  49. package/dist/fonts/css.d.ts.map +1 -0
  50. package/dist/fonts/css.js +50 -0
  51. package/dist/fonts/css.js.map +1 -0
  52. package/dist/fonts/fontsource.d.ts +107 -0
  53. package/dist/fonts/fontsource.d.ts.map +1 -0
  54. package/dist/fonts/fontsource.js +565 -0
  55. package/dist/fonts/fontsource.js.map +1 -0
  56. package/dist/fonts/index.d.ts +11 -0
  57. package/dist/fonts/index.d.ts.map +1 -0
  58. package/dist/fonts/index.js +11 -0
  59. package/dist/fonts/index.js.map +1 -0
  60. package/dist/fonts/install.d.ts +24 -0
  61. package/dist/fonts/install.d.ts.map +1 -0
  62. package/dist/fonts/install.js +145 -0
  63. package/dist/fonts/install.js.map +1 -0
  64. package/dist/fonts/manifest.d.ts +21 -0
  65. package/dist/fonts/manifest.d.ts.map +1 -0
  66. package/dist/fonts/manifest.js +96 -0
  67. package/dist/fonts/manifest.js.map +1 -0
  68. package/dist/fonts/options.d.ts +9 -0
  69. package/dist/fonts/options.d.ts.map +1 -0
  70. package/dist/fonts/options.js +66 -0
  71. package/dist/fonts/options.js.map +1 -0
  72. package/dist/fonts/schema.d.ts +4 -0
  73. package/dist/fonts/schema.d.ts.map +1 -0
  74. package/dist/fonts/schema.js +316 -0
  75. package/dist/fonts/schema.js.map +1 -0
  76. package/dist/fonts/summary.d.ts +16 -0
  77. package/dist/fonts/summary.d.ts.map +1 -0
  78. package/dist/fonts/summary.js +22 -0
  79. package/dist/fonts/summary.js.map +1 -0
  80. package/dist/fonts/types.d.ts +56 -0
  81. package/dist/fonts/types.d.ts.map +1 -0
  82. package/dist/fonts/types.js +8 -0
  83. package/dist/fonts/types.js.map +1 -0
  84. package/dist/fonts/verify.d.ts +33 -0
  85. package/dist/fonts/verify.d.ts.map +1 -0
  86. package/dist/fonts/verify.js +200 -0
  87. package/dist/fonts/verify.js.map +1 -0
  88. package/dist/html/validate.d.ts.map +1 -1
  89. package/dist/html/validate.js +59 -0
  90. package/dist/html/validate.js.map +1 -1
  91. package/dist/index.d.ts +3 -3
  92. package/dist/index.d.ts.map +1 -1
  93. package/dist/index.js +1 -1
  94. package/dist/index.js.map +1 -1
  95. package/dist/inspect.d.ts +61 -0
  96. package/dist/inspect.d.ts.map +1 -0
  97. package/dist/inspect.js +325 -0
  98. package/dist/inspect.js.map +1 -0
  99. package/dist/lint.d.ts +63 -0
  100. package/dist/lint.d.ts.map +1 -0
  101. package/dist/lint.js +265 -0
  102. package/dist/lint.js.map +1 -0
  103. package/dist/policy.d.ts +69 -0
  104. package/dist/policy.d.ts.map +1 -0
  105. package/dist/policy.js +361 -0
  106. package/dist/policy.js.map +1 -0
  107. package/dist/preview/fonts/LICENSE-figtree.txt +93 -0
  108. package/dist/preview/fonts/LICENSE-space-mono.txt +93 -0
  109. package/dist/preview/fonts/README.md +23 -0
  110. package/dist/preview/fonts/figtree-latin-wght-normal.woff2 +0 -0
  111. package/dist/preview/fonts/space-mono-latin-400-normal.woff2 +0 -0
  112. package/dist/preview/fonts/space-mono-latin-700-normal.woff2 +0 -0
  113. package/dist/preview/main.js +5 -2
  114. package/dist/preview/main.js.map +1 -1
  115. package/dist/preview/timeline-data.d.ts +17 -69
  116. package/dist/preview/timeline-data.d.ts.map +1 -1
  117. package/dist/preview/timeline-host.d.ts.map +1 -1
  118. package/dist/preview/timeline-host.js +70 -45
  119. package/dist/preview/timeline-host.js.map +1 -1
  120. package/dist/preview/timeline-main.d.ts.map +1 -1
  121. package/dist/preview/timeline-main.js +88 -27
  122. package/dist/preview/timeline-main.js.map +1 -1
  123. package/dist/project.d.ts +10 -3
  124. package/dist/project.d.ts.map +1 -1
  125. package/dist/project.js +20 -17
  126. package/dist/project.js.map +1 -1
  127. package/dist/renderer/audio.d.ts +6 -0
  128. package/dist/renderer/audio.d.ts.map +1 -1
  129. package/dist/renderer/audio.js +3 -1
  130. package/dist/renderer/audio.js.map +1 -1
  131. package/dist/renderer/browser.d.ts.map +1 -1
  132. package/dist/renderer/browser.js +13 -0
  133. package/dist/renderer/browser.js.map +1 -1
  134. package/dist/renderer/concurrency.d.ts +6 -0
  135. package/dist/renderer/concurrency.d.ts.map +1 -0
  136. package/dist/renderer/concurrency.js +27 -0
  137. package/dist/renderer/concurrency.js.map +1 -0
  138. package/dist/renderer/ffmpeg.d.ts +104 -5
  139. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  140. package/dist/renderer/ffmpeg.js +580 -67
  141. package/dist/renderer/ffmpeg.js.map +1 -1
  142. package/dist/renderer/index.js +1 -1
  143. package/dist/renderer/index.js.map +1 -1
  144. package/dist/renderer/keyframe-expressions.d.ts +39 -0
  145. package/dist/renderer/keyframe-expressions.d.ts.map +1 -0
  146. package/dist/renderer/keyframe-expressions.js +180 -0
  147. package/dist/renderer/keyframe-expressions.js.map +1 -0
  148. package/dist/renderer/session-pool.d.ts +28 -0
  149. package/dist/renderer/session-pool.d.ts.map +1 -0
  150. package/dist/renderer/session-pool.js +91 -0
  151. package/dist/renderer/session-pool.js.map +1 -0
  152. package/dist/renderer/timeline.d.ts +2 -0
  153. package/dist/renderer/timeline.d.ts.map +1 -1
  154. package/dist/renderer/timeline.js +600 -147
  155. package/dist/renderer/timeline.js.map +1 -1
  156. package/dist/renderer/timings.d.ts +13 -0
  157. package/dist/renderer/timings.d.ts.map +1 -0
  158. package/dist/renderer/timings.js +29 -0
  159. package/dist/renderer/timings.js.map +1 -0
  160. package/dist/runtime/host.d.ts.map +1 -1
  161. package/dist/runtime/host.js +4 -0
  162. package/dist/runtime/host.js.map +1 -1
  163. package/dist/scaffold.d.ts +3 -0
  164. package/dist/scaffold.d.ts.map +1 -1
  165. package/dist/scaffold.js +48 -320
  166. package/dist/scaffold.js.map +1 -1
  167. package/dist/server/composition-html.d.ts +2 -0
  168. package/dist/server/composition-html.d.ts.map +1 -1
  169. package/dist/server/composition-html.js +7 -0
  170. package/dist/server/composition-html.js.map +1 -1
  171. package/dist/server/preview-html.d.ts.map +1 -1
  172. package/dist/server/preview-html.js +74 -188
  173. package/dist/server/preview-html.js.map +1 -1
  174. package/dist/server/preview-style.d.ts +11 -0
  175. package/dist/server/preview-style.d.ts.map +1 -0
  176. package/dist/server/preview-style.js +298 -0
  177. package/dist/server/preview-style.js.map +1 -0
  178. package/dist/server/server.d.ts.map +1 -1
  179. package/dist/server/server.js +31 -1
  180. package/dist/server/server.js.map +1 -1
  181. package/dist/server/timeline-preview-data.d.ts.map +1 -1
  182. package/dist/server/timeline-preview-data.js +12 -34
  183. package/dist/server/timeline-preview-data.js.map +1 -1
  184. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  185. package/dist/server/timeline-preview-html.js +140 -148
  186. package/dist/server/timeline-preview-html.js.map +1 -1
  187. package/dist/templates.d.ts +16 -0
  188. package/dist/templates.d.ts.map +1 -0
  189. package/dist/templates.js +186 -0
  190. package/dist/templates.js.map +1 -0
  191. package/dist/timeline/easing.d.ts +36 -0
  192. package/dist/timeline/easing.d.ts.map +1 -0
  193. package/dist/timeline/easing.js +225 -0
  194. package/dist/timeline/easing.js.map +1 -0
  195. package/dist/timeline/keyframes.d.ts +23 -0
  196. package/dist/timeline/keyframes.d.ts.map +1 -0
  197. package/dist/timeline/keyframes.js +95 -0
  198. package/dist/timeline/keyframes.js.map +1 -0
  199. package/dist/timeline/plan.d.ts +6 -12
  200. package/dist/timeline/plan.d.ts.map +1 -1
  201. package/dist/timeline/plan.js +17 -30
  202. package/dist/timeline/plan.js.map +1 -1
  203. package/dist/timeline/resolve.d.ts +12 -0
  204. package/dist/timeline/resolve.d.ts.map +1 -1
  205. package/dist/timeline/resolve.js +105 -9
  206. package/dist/timeline/resolve.js.map +1 -1
  207. package/dist/timeline/scene.d.ts +126 -0
  208. package/dist/timeline/scene.d.ts.map +1 -0
  209. package/dist/timeline/scene.js +109 -0
  210. package/dist/timeline/scene.js.map +1 -0
  211. package/dist/timeline/schema.d.ts +31 -0
  212. package/dist/timeline/schema.d.ts.map +1 -1
  213. package/dist/timeline/schema.js +240 -9
  214. package/dist/timeline/schema.js.map +1 -1
  215. package/dist/timeline/transitions.d.ts +21 -0
  216. package/dist/timeline/transitions.d.ts.map +1 -0
  217. package/dist/timeline/transitions.js +23 -0
  218. package/dist/timeline/transitions.js.map +1 -0
  219. package/dist/version.d.ts +1 -1
  220. package/dist/version.d.ts.map +1 -1
  221. package/dist/version.js +1 -1
  222. package/dist/version.js.map +1 -1
  223. package/docs/adr/0007-make-html-documents-the-composition-unit.md +1 -1
  224. package/docs/adr/0010-make-timelines-the-render-unit.md +3 -2
  225. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  226. package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +2 -1
  227. package/docs/adr/0015-inherit-composition-format-from-the-project.md +2 -2
  228. package/docs/adr/0016-author-compositions-as-html-fragments.md +1 -1
  229. package/docs/adr/0018-animate-timeline-clips-with-keyframes.md +35 -0
  230. package/docs/adr/0019-make-same-track-overlap-the-transition-window.md +36 -0
  231. package/docs/adr/0020-crossfade-audio-clips-on-audio-tracks.md +27 -0
  232. package/docs/adr/0021-project-timeline-scenes-once.md +32 -0
  233. package/docs/adr/0022-gate-heavy-operations-with-a-workspace-resource-policy.md +35 -0
  234. package/docs/adr/0023-pin-fonts-as-project-assets.md +98 -0
  235. package/docs/adr/0024-stream-composition-captures-and-skip-trivial-composites.md +86 -0
  236. package/docs/adr/0025-capture-timeline-layers-in-parallel-with-a-global-limit.md +67 -0
  237. package/docs/adr/0026-reuse-loaded-composition-sessions-across-segments.md +70 -0
  238. package/docs/adr/0027-encode-two-full-frame-layers-through-dual-pipes.md +76 -0
  239. package/docs/adr/0028-encode-full-frame-video-segments-directly.md +65 -0
  240. package/docs/migration-0.4.md +2 -2
  241. package/docs/preview-testing.md +27 -6
  242. package/docs/{v0.1-design.md → v0.x-design.md} +57 -32
  243. package/examples/blend/README.md +3 -3
  244. package/examples/demo/README.md +2 -2
  245. package/examples/intro/README.md +71 -0
  246. package/examples/intro/assets/fonts/figtree/LICENSE.txt +93 -0
  247. package/examples/intro/assets/fonts/figtree/figtree-latin-wght-normal.woff2 +0 -0
  248. package/examples/intro/assets/fonts/instrument-serif/LICENSE.txt +93 -0
  249. package/examples/intro/assets/fonts/instrument-serif/instrument-serif-latin-400-italic.woff2 +0 -0
  250. package/examples/intro/assets/fonts/space-mono/LICENSE.txt +93 -0
  251. package/examples/intro/assets/fonts/space-mono/space-mono-latin-400-normal.woff2 +0 -0
  252. package/examples/intro/assets/fonts/space-mono/space-mono-latin-700-normal.woff2 +0 -0
  253. package/examples/intro/assets/grain.png +0 -0
  254. package/examples/intro/assets/score.ogg +0 -0
  255. package/examples/intro/compositions/brand.html +412 -0
  256. package/examples/intro/compositions/chrome.controller.js +63 -0
  257. package/examples/intro/compositions/chrome.html +256 -0
  258. package/examples/intro/compositions/lattice.controller.js +148 -0
  259. package/examples/intro/compositions/lattice.html +308 -0
  260. package/examples/intro/compositions/opening.html +504 -0
  261. package/examples/intro/compositions/studio.html +930 -0
  262. package/examples/intro/compositions/theme.css +218 -0
  263. package/examples/intro/fy.config.js +13 -0
  264. package/examples/intro/fy.fonts.json +100 -0
  265. package/examples/intro/scripts/make-grain.mjs +79 -0
  266. package/examples/intro/scripts/make-score.mjs +702 -0
  267. package/examples/intro/timelines/intro.timeline.json +60 -0
  268. package/examples/keyframes/README.md +76 -0
  269. package/examples/keyframes/compositions/backdrop.html +60 -0
  270. package/examples/keyframes/compositions/card.html +99 -0
  271. package/examples/keyframes/fy.config.js +10 -0
  272. package/examples/keyframes/timelines/keyframes.timeline.json +60 -0
  273. package/examples/nested/README.md +112 -0
  274. package/examples/nested/assets/pulse.wav +0 -0
  275. package/examples/nested/compositions/backdrop.html +84 -0
  276. package/examples/nested/compositions/card.html +275 -0
  277. package/examples/nested/compositions/overlay.html +307 -0
  278. package/examples/nested/fy.config.js +13 -0
  279. package/examples/nested/scripts/make-pulse.sh +20 -0
  280. package/examples/nested/timelines/chapter-a.timeline.json +39 -0
  281. package/examples/nested/timelines/chapter-b.timeline.json +39 -0
  282. package/examples/nested/timelines/nested-reel.timeline.json +52 -0
  283. package/examples/static/README.md +2 -2
  284. package/examples/transitions/README.md +128 -0
  285. package/examples/transitions/assets/alpha.wav +0 -0
  286. package/examples/transitions/assets/beta.wav +0 -0
  287. package/examples/transitions/assets/gamma.wav +0 -0
  288. package/examples/transitions/compositions/alpha.html +56 -0
  289. package/examples/transitions/compositions/beta.html +56 -0
  290. package/examples/transitions/compositions/gamma.html +56 -0
  291. package/examples/transitions/fy.config.js +12 -0
  292. package/examples/transitions/timelines/audio-crossfade.timeline.json +44 -0
  293. package/examples/transitions/timelines/transitions.timeline.json +31 -0
  294. package/examples/video/README.md +4 -4
  295. package/examples/webgl/README.md +1 -1
  296. package/package.json +13 -6
  297. package/scaffolds/init/AGENTS.md +131 -0
  298. package/scaffolds/init/README.md +56 -0
  299. package/scaffolds/init/compositions/hello.html +79 -0
  300. package/scaffolds/init/fy.config.js +9 -0
  301. package/scaffolds/init/gitignore.template +1 -0
  302. package/scaffolds/init/package.json +11 -0
  303. package/scaffolds/init/props/hello.json +4 -0
  304. package/scaffolds/init/props/main.json +6 -0
  305. package/scaffolds/init/timelines/main.timeline.json +20 -0
  306. package/scaffolds/local-models/LOCAL-MODELS.md +77 -0
  307. package/scaffolds/local-models/fy.policy.json +5 -0
  308. package/scaffolds/local-models/opencode/opencode.jsonc +17 -0
  309. package/scaffolds/local-models/pi/APPEND_SYSTEM.md +21 -0
  310. package/scaffolds/local-models/pi/sandbox.json +15 -0
  311. package/scaffolds/local-models/pi/settings.json +15 -0
  312. package/skills/frameyard/README.md +1 -0
  313. package/skills/frameyard/SKILL.md +59 -61
  314. package/skills/frameyard/references/audio-assets.md +32 -3
  315. package/skills/frameyard/references/common-mistakes.md +53 -2
  316. package/skills/frameyard/references/deterministic-authoring.md +10 -3
  317. package/skills/frameyard/references/diagnostics.md +31 -1
  318. package/skills/frameyard/references/failure-recovery.md +32 -6
  319. package/skills/frameyard/references/fonts.md +132 -0
  320. package/skills/frameyard/references/json-results.md +119 -6
  321. package/skills/frameyard/references/quick-reference.md +222 -0
  322. package/skills/frameyard/references/timelines.md +97 -7
  323. /package/examples/blend/{video.config.js → fy.config.js} +0 -0
  324. /package/examples/demo/{video.config.js → fy.config.js} +0 -0
  325. /package/examples/static/{video.config.js → fy.config.js} +0 -0
  326. /package/examples/video/{video.config.js → fy.config.js} +0 -0
  327. /package/examples/webgl/{video.config.js → fy.config.js} +0 -0
package/dist/cli.js CHANGED
@@ -1,20 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  import { access, mkdir, readFile, rmdir, stat, writeFile } from "node:fs/promises";
3
+ import { cpus, freemem, loadavg, totalmem } from "node:os";
3
4
  import path from "node:path";
5
+ import { createInterface } from "node:readline/promises";
4
6
  import { isPlainObject } from "./contracts.js";
5
7
  import { FrameyardError, errorMessage, isFrameyardError } from "./errors.js";
6
8
  import { loadProject, resolveProjectRoot } from "./project.js";
9
+ import { assertOperationAllowed, assertOverwriteAllowed, assertPolicyTier, assertProfileLimit, assertStillFrameLimit, describePolicy, loadResourcePolicy, lowerProcessPriority, MAX_STILL_FRAMES, policyDetails, resolveProfile, SHARED_CPU_MAX_CONCURRENCY, SHARED_CPU_MAX_WORKERS, shouldLowerPriority, } from "./policy.js";
10
+ import { lintComposition, lintResolvedTimeline, lintTimeline } from "./lint.js";
11
+ import { buildProjectContext } from "./context.js";
12
+ import { analyzeRgba, compareRgba, decodeRgba, probeImage } from "./inspect.js";
13
+ import { findTemplate, listTemplates, templateRegistration } from "./templates.js";
14
+ import { planTimelineRender } from "./timeline/plan.js";
15
+ import { resolveProjectTimeline } from "./timeline/resolve.js";
7
16
  import { checkComposition, captureStills, MAX_RENDER_CONCURRENCY, renderComposition, validateRenderConcurrency, } from "./renderer/index.js";
8
17
  import { MAX_TIMELINE_WORKERS, captureTimelineStills, checkTimeline, renderTimeline, } from "./renderer/timeline.js";
9
18
  import { assertContainerCodec, CONTAINER_EXTENSION, defaultCodecForContainer, encoderNameFor, } from "./renderer/ffmpeg.js";
10
19
  import { startPreviewServer } from "./server/server.js";
11
- import { scaffoldFiles, SCAFFOLD_NEXT } from "./scaffold.js";
20
+ import { localModelScaffoldFiles, scaffoldFiles, SCAFFOLD_NEXT } from "./scaffold.js";
12
21
  import { VERSION } from "./version.js";
13
22
  import { transcribeAudio } from "./assets/transcribe.js";
14
23
  import { synthesizeSpeech } from "./assets/tts.js";
24
+ import { isSupertonicLanguage, parseSupertonicSpeaker, resolveSupertonicModelFiles, SUPERTONIC_LANGUAGES, } from "./assets/tts-supertonic.js";
15
25
  import { resolveManifestPath } from "./assets/provenance.js";
16
26
  import { runDoctor } from "./doctor.js";
17
- const COMMANDS = new Set(["init", "dev", "check", "still", "render", "tts", "transcribe", "doctor", "help"]);
27
+ import { addLocalFont, removeFont } from "./fonts/add.js";
28
+ import { addFontsource, addUrlFont, searchFontsource } from "./fonts/fontsource.js";
29
+ import { fontManifestPath, readFontManifest } from "./fonts/manifest.js";
30
+ import { parseFontStyle, parseFontWeight } from "./fonts/options.js";
31
+ import { summarizeFont } from "./fonts/summary.js";
32
+ import { assertFontIntegrity, fontStatuses, verifyFontManifest } from "./fonts/verify.js";
33
+ import { emptyFontManifest } from "./fonts/types.js";
34
+ const COMMANDS = new Set([
35
+ "init",
36
+ "dev",
37
+ "context",
38
+ "lint",
39
+ "check",
40
+ "still",
41
+ "inspect",
42
+ "compare",
43
+ "render",
44
+ "tts",
45
+ "transcribe",
46
+ "add",
47
+ "font",
48
+ "doctor",
49
+ "help",
50
+ ]);
18
51
  const VALUE_FLAGS = new Set([
19
52
  "project",
20
53
  "props",
@@ -29,6 +62,8 @@ const VALUE_FLAGS = new Set([
29
62
  "container",
30
63
  "text-file",
31
64
  "speaker",
65
+ "engine",
66
+ "model-dir",
32
67
  "talker-model",
33
68
  "codec-model",
34
69
  "style",
@@ -39,10 +74,38 @@ const VALUE_FLAGS = new Set([
39
74
  "export",
40
75
  "manifest",
41
76
  "qwen-tts",
77
+ "sherpa-onnx-tts",
42
78
  "whisper-cli",
79
+ "profile",
80
+ "template",
81
+ "file",
82
+ "family",
83
+ "id",
84
+ "weight",
85
+ "subset",
86
+ "unicode-range",
87
+ "license",
88
+ "license-file",
89
+ "category",
90
+ "limit",
91
+ "url",
92
+ "sha256",
93
+ ]);
94
+ const BOOLEAN_FLAGS = new Set([
95
+ "json",
96
+ "overwrite",
97
+ "force",
98
+ "words",
99
+ "dry-run",
100
+ "low-priority",
101
+ "list",
102
+ "local-models",
103
+ "keep-files",
104
+ "static",
105
+ "variable",
106
+ "help",
107
+ "version",
43
108
  ]);
44
- const BOOLEAN_FLAGS = new Set(["json", "overwrite", "force", "words", "help", "version"]);
45
- const MAX_STILL_FRAMES = 32;
46
109
  function usage(message = "Invalid command line.") {
47
110
  throw new FrameyardError("USAGE", message);
48
111
  }
@@ -108,15 +171,47 @@ function parseArgs(argv) {
108
171
  }
109
172
  function allowedFlags(command) {
110
173
  const common = new Set(["project", "json", "help"]);
174
+ const resource = ["profile", "low-priority"];
111
175
  switch (command) {
112
176
  case "init":
113
- return new Set(["json", "help"]);
177
+ return new Set(["json", "local-models", "help"]);
114
178
  case "dev":
115
179
  return new Set(["project", "port", "help"]);
116
- case "check":
180
+ case "context":
181
+ return new Set(common);
182
+ case "lint":
117
183
  return new Set([...common, "props"]);
184
+ case "inspect":
185
+ return new Set(["json", "help"]);
186
+ case "compare":
187
+ return new Set(["json", "help"]);
188
+ case "add":
189
+ return new Set([...common, "template", "list"]);
190
+ case "font":
191
+ return new Set([
192
+ ...common,
193
+ "file",
194
+ "family",
195
+ "id",
196
+ "weight",
197
+ "style",
198
+ "subset",
199
+ "unicode-range",
200
+ "license",
201
+ "license-file",
202
+ "category",
203
+ "limit",
204
+ "url",
205
+ "sha256",
206
+ "static",
207
+ "variable",
208
+ "force",
209
+ "keep-files",
210
+ ]);
211
+ case "check":
212
+ return new Set([...common, "props", ...resource]);
118
213
  case "still":
119
- return new Set([...common, "props", "out", "frame", "overwrite"]);
214
+ return new Set([...common, "props", "out", "frame", "overwrite", ...resource]);
120
215
  case "render":
121
216
  return new Set([
122
217
  ...common,
@@ -129,12 +224,16 @@ function allowedFlags(command) {
129
224
  "encoder",
130
225
  "codec",
131
226
  "container",
227
+ "dry-run",
228
+ ...resource,
132
229
  ]);
133
230
  case "tts":
134
231
  return new Set([
135
232
  ...common,
136
233
  "text-file",
137
234
  "speaker",
235
+ "engine",
236
+ "model-dir",
138
237
  "talker-model",
139
238
  "codec-model",
140
239
  "out",
@@ -144,11 +243,35 @@ function allowedFlags(command) {
144
243
  "executable",
145
244
  "manifest",
146
245
  "force",
246
+ "dry-run",
247
+ ...resource,
147
248
  ]);
148
249
  case "transcribe":
149
- return new Set([...common, "out", "model", "words", "export", "language", "executable", "manifest", "force"]);
250
+ return new Set([
251
+ ...common,
252
+ "out",
253
+ "model",
254
+ "words",
255
+ "export",
256
+ "language",
257
+ "executable",
258
+ "manifest",
259
+ "force",
260
+ "dry-run",
261
+ ...resource,
262
+ ]);
150
263
  case "doctor":
151
- return new Set([...common, "manifest", "qwen-tts", "whisper-cli", "talker-model", "codec-model", "model"]);
264
+ return new Set([
265
+ ...common,
266
+ "manifest",
267
+ "qwen-tts",
268
+ "sherpa-onnx-tts",
269
+ "whisper-cli",
270
+ "talker-model",
271
+ "codec-model",
272
+ "model-dir",
273
+ "model",
274
+ ]);
152
275
  case "help":
153
276
  return new Set(["help"]);
154
277
  default:
@@ -170,7 +293,7 @@ function validateArgs(parsed) {
170
293
  usage(`Option --${flag} is not valid for "${parsed.command}".`);
171
294
  }
172
295
  }
173
- if (["dev", "help", "doctor", "tts"].includes(parsed.command)) {
296
+ if (["dev", "help", "doctor", "tts", "context"].includes(parsed.command)) {
174
297
  if (parsed.positionals.length > 0) {
175
298
  usage(`"${parsed.command}" does not take a positional argument.`);
176
299
  }
@@ -180,13 +303,218 @@ function validateArgs(parsed) {
180
303
  usage(`"${parsed.command}" accepts at most 1 positional argument.`);
181
304
  }
182
305
  }
306
+ else if (parsed.command === "inspect") {
307
+ if (parsed.positionals.length !== 1) {
308
+ usage('"inspect" requires an image path.');
309
+ }
310
+ }
311
+ else if (parsed.command === "compare") {
312
+ if (parsed.positionals.length !== 2) {
313
+ usage('"compare" requires two image paths.');
314
+ }
315
+ }
316
+ else if (parsed.command === "add") {
317
+ if (parsed.flags.has("list")) {
318
+ if (parsed.positionals.length > 0) {
319
+ usage('"add --list" does not take positional arguments.');
320
+ }
321
+ if (parsed.flags.has("template")) {
322
+ usage("--template is only valid when adding an entry.");
323
+ }
324
+ }
325
+ else if (parsed.positionals[0] === "local-models") {
326
+ if (parsed.positionals.length !== 1) {
327
+ usage('"add local-models" does not take an id.');
328
+ }
329
+ if (parsed.flags.has("template")) {
330
+ usage('--template is not valid for "add local-models".');
331
+ }
332
+ }
333
+ else {
334
+ if (parsed.positionals.length !== 2) {
335
+ usage('"add" requires <composition|timeline> <id> and --template <name>, or "add local-models".');
336
+ }
337
+ const kind = parsed.positionals[0];
338
+ if (kind !== "composition" && kind !== "timeline") {
339
+ usage('"add" kind must be "composition", "timeline", or "local-models".');
340
+ }
341
+ const templateName = parsed.flags.get("template");
342
+ if (typeof templateName !== "string") {
343
+ usage('"add" requires --template <name>.');
344
+ }
345
+ const template = findTemplate(templateName);
346
+ if (!template) {
347
+ usage(`Unknown template "${templateName}". Available: ${listTemplates()
348
+ .map((entry) => entry.name)
349
+ .join(", ")}.`);
350
+ }
351
+ if (template.kind !== kind) {
352
+ usage(`Template "${templateName}" is a ${template.kind} template, not ${kind}.`);
353
+ }
354
+ }
355
+ }
356
+ else if (parsed.command === "font") {
357
+ const action = parsed.positionals[0];
358
+ if (action === "search") {
359
+ if (parsed.positionals.length > 2) {
360
+ usage('"font search" accepts at most one query.');
361
+ }
362
+ const query = parsed.positionals[1] ?? "";
363
+ const hasFilter = parsed.flags.has("category") ||
364
+ parsed.flags.has("subset") ||
365
+ parsed.flags.has("style") ||
366
+ parsed.flags.has("variable");
367
+ if (query.length === 0 && !hasFilter) {
368
+ usage('"font search" requires a query or a --category, --subset, --style, or --variable filter.');
369
+ }
370
+ for (const flag of [
371
+ "file",
372
+ "family",
373
+ "id",
374
+ "weight",
375
+ "url",
376
+ "license",
377
+ "license-file",
378
+ "sha256",
379
+ "force",
380
+ "keep-files",
381
+ "static",
382
+ ]) {
383
+ if (parsed.flags.has(flag)) {
384
+ usage(`--${flag} is not valid for "font search".`);
385
+ }
386
+ }
387
+ const limit = parsed.flags.get("limit");
388
+ if (limit !== undefined &&
389
+ (typeof limit !== "string" || !/^\d+$/.test(limit) || Number(limit) < 1 || Number(limit) > 100)) {
390
+ usage("--limit must be an integer between 1 and 100.");
391
+ }
392
+ }
393
+ else if (action === "add") {
394
+ const file = typeof parsed.flags.get("file") === "string";
395
+ const url = typeof parsed.flags.get("url") === "string";
396
+ if (file && url) {
397
+ usage('"font add" accepts only one source: --file, --url, or a Fontsource id.');
398
+ }
399
+ if (parsed.flags.has("category") || parsed.flags.has("limit") || parsed.flags.has("variable")) {
400
+ usage('--category, --limit, and --variable are only valid for "font search".');
401
+ }
402
+ if (parsed.flags.has("keep-files")) {
403
+ usage('--keep-files is only valid for "font remove".');
404
+ }
405
+ if (parsed.flags.has("static") && file) {
406
+ usage("--static is only valid for a Fontsource id.");
407
+ }
408
+ if (parsed.flags.has("static") && url) {
409
+ usage("--static is only valid for a Fontsource id.");
410
+ }
411
+ if (file) {
412
+ if (parsed.positionals.length !== 1) {
413
+ usage('"font add --file" does not take an id; use --id to override the derived font id.');
414
+ }
415
+ if (typeof parsed.flags.get("family") !== "string") {
416
+ usage('"font add --file" requires --family <name>.');
417
+ }
418
+ if (parsed.flags.has("sha256")) {
419
+ usage("--sha256 is only valid with --url.");
420
+ }
421
+ }
422
+ else if (url) {
423
+ if (parsed.positionals.length !== 1) {
424
+ usage('"font add --url" does not take an id; use --id to override the derived font id.');
425
+ }
426
+ if (typeof parsed.flags.get("family") !== "string") {
427
+ usage('"font add --url" requires --family <name>.');
428
+ }
429
+ }
430
+ else {
431
+ if (parsed.positionals.length !== 2) {
432
+ usage('"font add" requires a Fontsource id, or --file/--url with --family.');
433
+ }
434
+ if (parsed.flags.has("id")) {
435
+ usage("The Fontsource id is positional; --id is only valid with --file or --url.");
436
+ }
437
+ if (parsed.flags.has("license") || parsed.flags.has("license-file")) {
438
+ usage("Fontsource supplies the license; --license/--license-file are only valid with --file or --url.");
439
+ }
440
+ if (parsed.flags.has("sha256")) {
441
+ usage("--sha256 is only valid with --url.");
442
+ }
443
+ if (parsed.flags.has("unicode-range")) {
444
+ usage("--unicode-range is only valid with --file or --url.");
445
+ }
446
+ }
447
+ }
448
+ else if (action === "list" || action === "verify") {
449
+ if (parsed.positionals.length !== 1) {
450
+ usage(`"font ${action}" does not take positional arguments.`);
451
+ }
452
+ for (const flag of [
453
+ "file",
454
+ "family",
455
+ "id",
456
+ "weight",
457
+ "style",
458
+ "subset",
459
+ "unicode-range",
460
+ "license",
461
+ "license-file",
462
+ "category",
463
+ "limit",
464
+ "url",
465
+ "sha256",
466
+ "static",
467
+ "variable",
468
+ "force",
469
+ "keep-files",
470
+ ]) {
471
+ if (parsed.flags.has(flag)) {
472
+ usage(`--${flag} is not valid for "font ${action}".`);
473
+ }
474
+ }
475
+ }
476
+ else if (action === "remove") {
477
+ if (parsed.positionals.length !== 2) {
478
+ usage('"font remove" requires a font id.');
479
+ }
480
+ for (const flag of [
481
+ "file",
482
+ "family",
483
+ "id",
484
+ "weight",
485
+ "style",
486
+ "subset",
487
+ "unicode-range",
488
+ "license",
489
+ "license-file",
490
+ "category",
491
+ "limit",
492
+ "url",
493
+ "sha256",
494
+ "static",
495
+ "variable",
496
+ "force",
497
+ ]) {
498
+ if (parsed.flags.has(flag)) {
499
+ usage(`--${flag} is not valid for "font remove".`);
500
+ }
501
+ }
502
+ }
503
+ else {
504
+ usage('"font" requires one of: search, add, list, verify, remove.');
505
+ }
506
+ }
183
507
  else if (parsed.positionals.length !== 1) {
184
508
  usage(`"${parsed.command}" requires a ${parsed.command === "transcribe"
185
509
  ? "source path"
186
- : parsed.command === "render"
510
+ : parsed.command === "render" || parsed.command === "lint"
187
511
  ? "composition or timeline id"
188
512
  : "composition id"}.`);
189
513
  }
514
+ const profile = parsed.flags.get("profile");
515
+ if (profile !== undefined && profile !== "shared-cpu") {
516
+ usage('--profile must be "shared-cpu".');
517
+ }
190
518
  if (parsed.command === "still" && !parsed.flags.has("frame")) {
191
519
  usage('"still" requires --frame <spec>.');
192
520
  }
@@ -194,9 +522,47 @@ function validateArgs(parsed) {
194
522
  validateRenderConcurrency(Number(parsed.flags.get("concurrency")));
195
523
  }
196
524
  if (parsed.command === "tts") {
197
- for (const required of ["speaker", "talker-model", "codec-model", "out"]) {
198
- if (!parsed.flags.has(required)) {
199
- usage(`"tts" requires --${required}.`);
525
+ const requestedEngine = parsed.flags.get("engine");
526
+ let engine = "qwen";
527
+ if (requestedEngine !== undefined) {
528
+ if (typeof requestedEngine !== "string" || (requestedEngine !== "qwen" && requestedEngine !== "supertonic")) {
529
+ usage('--engine must be "qwen" or "supertonic".');
530
+ }
531
+ engine = requestedEngine;
532
+ }
533
+ if (engine === "supertonic") {
534
+ for (const required of ["speaker", "model-dir", "language", "out"]) {
535
+ if (!parsed.flags.has(required)) {
536
+ usage(`"tts" requires --${required} with --engine supertonic.`);
537
+ }
538
+ }
539
+ for (const forbidden of ["talker-model", "codec-model", "style", "device"]) {
540
+ if (parsed.flags.has(forbidden)) {
541
+ usage(`--${forbidden} is not valid with --engine supertonic.`);
542
+ }
543
+ }
544
+ const speaker = parsed.flags.get("speaker");
545
+ if (typeof speaker === "string") {
546
+ try {
547
+ parseSupertonicSpeaker(speaker);
548
+ }
549
+ catch (error) {
550
+ usage(error instanceof Error ? error.message : String(error));
551
+ }
552
+ }
553
+ const language = parsed.flags.get("language");
554
+ if (typeof language === "string" && !isSupertonicLanguage(language)) {
555
+ usage(`--language must be one of: ${SUPERTONIC_LANGUAGES.join(", ")}.`);
556
+ }
557
+ }
558
+ else {
559
+ for (const required of ["speaker", "talker-model", "codec-model", "out"]) {
560
+ if (!parsed.flags.has(required)) {
561
+ usage(`"tts" requires --${required}.`);
562
+ }
563
+ }
564
+ if (parsed.flags.has("model-dir")) {
565
+ usage("--model-dir is only valid with --engine supertonic.");
200
566
  }
201
567
  }
202
568
  }
@@ -234,14 +600,28 @@ function printHelp() {
234
600
  Local-first, deterministic video compositions authored with HTML, CSS, and JavaScript.
235
601
 
236
602
  Usage:
237
- frameyard init [directory]
603
+ frameyard init [directory] [--local-models]
238
604
  frameyard dev [--port <port>] [--project <dir>]
239
- frameyard check <composition-id> [--props <file>] [--project <dir>] [--json]
240
- frameyard still <composition-or-timeline-id> --frame <spec> [--out <file-or-dir>] [--props <file>] [--project <dir>] [--json]
241
- frameyard render <composition-or-timeline-id> [--out <file>] [--props <file>] [--project <dir>] [--concurrency <n>] [--workers <n>] [--encoder <name>] [--codec <name>] [--container <name>] [--scratch <dir>] [--overwrite] [--json]
242
- frameyard tts --speaker <name> --talker-model <gguf> --codec-model <gguf> --out <file.wav> [--text-file <file>] [--style <text>] [--language <lang>] [--device <name>] [--executable <path>] [--manifest <file>] [--force] [--json]
243
- frameyard transcribe <source> --model <gguf> --out <file.json> [--words] [--export srt,vtt] [--language <lang>] [--executable <path>] [--manifest <file>] [--force] [--json]
244
- frameyard doctor [--qwen-tts <path>] [--whisper-cli <path>] [--talker-model <gguf>] [--codec-model <gguf>] [--model <gguf>] [--manifest <file>] [--project <dir>] [--json]
605
+ frameyard context [--project <dir>] [--json]
606
+ frameyard lint <composition-or-timeline-id> [--props <file>] [--project <dir>] [--json]
607
+ frameyard check <composition-id> [--props <file>] [--project <dir>] [--profile <name>] [--low-priority] [--json]
608
+ frameyard still <composition-or-timeline-id> --frame <spec> [--out <file-or-dir>] [--props <file>] [--project <dir>] [--profile <name>] [--low-priority] [--json]
609
+ frameyard inspect <image> [--json]
610
+ frameyard compare <first-image> <second-image> [--json]
611
+ frameyard render <composition-or-timeline-id> [--out <file>] [--props <file>] [--project <dir>] [--concurrency <n>] [--workers <n>] [--encoder <name>] [--codec <name>] [--container <name>] [--scratch <dir>] [--overwrite] [--dry-run] [--profile <name>] [--low-priority] [--json]
612
+ frameyard tts [--engine qwen] --speaker <name> --talker-model <gguf> --codec-model <gguf> --out <file.wav> [--text-file <file>] [--style <text>] [--language <lang>] [--device <name>] [--executable <path>] [--manifest <file>] [--force] [--dry-run] [--profile <name>] [--low-priority] [--json]
613
+ frameyard tts --engine supertonic --speaker <sid> --model-dir <dir> --language <code> --out <file.wav> [--text-file <file>] [--executable <path>] [--manifest <file>] [--force] [--dry-run] [--profile <name>] [--low-priority] [--json]
614
+ frameyard transcribe <source> --model <gguf> --out <file.json> [--words] [--export srt,vtt] [--language <lang>] [--executable <path>] [--manifest <file>] [--force] [--dry-run] [--profile <name>] [--low-priority] [--json]
615
+ frameyard add --list [--json]
616
+ frameyard add local-models [--project <dir>] [--json]
617
+ frameyard add <composition|timeline> <id> --template <name> [--project <dir>] [--json]
618
+ frameyard font search [query] [--category <category>] [--subset <subset>] [--style <normal|italic>] [--variable] [--limit <n>] [--json]
619
+ frameyard font add <fontsource-id> [--family <alias>] [--subset <subset>] [--style <normal|italic>] [--weight <n>] [--static] [--force] [--project <dir>] [--json]
620
+ frameyard font add --file <path> --family <name> [--id <id>] [--weight <n|n-n>] [--style normal|italic] [--subset <name>] [--license <id>] [--license-file <path>] [--force] [--project <dir>] [--json]
621
+ frameyard font add --url <https-url> --family <name> [--id <id>] [--weight <n|n-n>] [--style normal|italic] [--license <id>] [--license-file <path>] [--sha256 <hash>] [--force] [--project <dir>] [--json]
622
+ frameyard font list|verify [--project <dir>] [--json]
623
+ frameyard font remove <id> [--keep-files] [--project <dir>] [--json]
624
+ frameyard doctor [--qwen-tts <path>] [--sherpa-onnx-tts <path>] [--whisper-cli <path>] [--talker-model <gguf>] [--codec-model <gguf>] [--model-dir <dir>] [--model <gguf>] [--manifest <file>] [--project <dir>] [--json]
245
625
 
246
626
  Options:
247
627
  --project <dir> Use a project other than the current directory.
@@ -254,61 +634,106 @@ Options:
254
634
  --codec <name> Delivery codec for render: h264, hevc, or vp9 (default: h264; vp9 for webm).
255
635
  --container <name> Delivery container: mp4, mov, mkv, or webm (default: mp4).
256
636
  --scratch <dir> Scratch directory for timeline intermediates (default: system temp).
257
- --overwrite Replace an existing still or render output.
258
- --force Replace existing generated assets and manifest entries.
637
+ --overwrite Replace an existing still or render output when the resource policy allows it.
638
+ --force Replace existing generated assets and manifest entries when the resource policy allows it.
639
+ --dry-run Validate and print the plan and handoff command without rendering, synthesizing, transcribing, or writing outputs.
640
+ --profile <name> Resource profile for shared machines. shared-cpu caps workers at 2, concurrency at 1, and lowers process priority.
641
+ --low-priority Lower the process priority without changing worker caps.
642
+ --local-models With init, also install the optional local-model host scaffold.
643
+ --template <name> Template for "add": static-poster, title-card, lower-third, or slideshow.
644
+ --list List add templates and scaffolds without writing files.
645
+ --file <path> Local font file for "font add": .woff2, .woff, .ttf, or .otf.
646
+ --url <https-url> Download a font from HTTPS (loopback HTTP is allowed for tests).
647
+ --family <name> CSS font-family name or alias for "font add".
648
+ --id <id> Override the font manifest id derived from the family name.
649
+ --weight <value> Font weight or variable range; comma-separated weights for Fontsource --static.
650
+ --style <value> qwen style instruction, or normal|italic (comma list for Fontsource).
651
+ --subset <name> Font subset label; comma-separated for Fontsource.
652
+ --unicode-range <r> CSS unicode-range for a local or URL font face.
653
+ --license <id> License identifier for --file/--url; warns when omitted.
654
+ --license-file <path> Copy a license file into the font directory.
655
+ --sha256 <hash> Expected SHA-256 for --url; the computed hash is always recorded.
656
+ --category <name> Fontsource category filter for "font search".
657
+ --limit <n> Fontsource result limit for "font search", 1-100 (default: 20).
658
+ --variable Fontsource search filter: only variable fonts.
659
+ --static Install static Fontsource weights instead of the variable face.
660
+ --keep-files Keep font files when removing a font from the manifest.
259
661
  --text-file <file> Text to synthesize. Reads stdin when omitted.
260
- --speaker <name> Named speaker for CustomVoice speech synthesis.
261
- --talker-model <gguf> Talker GGUF model path.
262
- --codec-model <gguf> Codec GGUF model path.
263
- --style <text> Optional style instruction for speech synthesis.
264
- --language <lang> Spoken language, when known.
662
+ --speaker <value> Named speaker for qwen, or the numeric speaker id for Supertonic.
663
+ --engine <name> Speech engine: qwen or supertonic (default: qwen).
664
+ --model-dir <dir> Supertonic model directory from the sherpa-onnx archive.
665
+ --talker-model <gguf> Talker GGUF model path for qwen.
666
+ --codec-model <gguf> Codec GGUF model path for qwen.
667
+ --style <value> Optional qwen style instruction, or normal|italic for "font add".
668
+ --language <lang> Spoken language. Supertonic requires one of its 31 upstream language codes.
265
669
  --device <name> Compute device for qwen-tts, such as CPU or CUDA0.
266
670
  --model <gguf> Whisper GGML model path.
267
671
  --words Request measured word timings during transcription.
268
672
  --export srt,vtt Derived subtitle exports next to the transcript.
269
- --executable <path> Explicit qwen-tts or whisper-cli executable.
270
- --manifest <file> Provenance manifest path (default: frameyard.assets.json).
673
+ --executable <path> Explicit qwen-tts, sherpa-onnx-offline-tts, or whisper-cli executable.
674
+ --manifest <file> Provenance manifest path (default: fy.assets.json).
271
675
  --qwen-tts <path> qwen-tts executable to inspect with doctor.
676
+ --sherpa-onnx-tts <path> sherpa-onnx-offline-tts executable to inspect with doctor.
272
677
  --whisper-cli <path> whisper-cli executable to inspect with doctor.
273
678
  --json Print a structured result to stdout.
274
679
  --help, -h Show this help.
275
680
  --version, -v Print the version.
681
+
682
+ Resource policy: the nearest fy.policy.json from the working directory upward sets light, authoring, or full; FRAMEYARD_POLICY overrides it. tts, transcribe, and render are blocked outside full. font add requires authoring or full; font remove and font add --force require full. Use --dry-run to validate and produce a handoff command without doing heavy work.
276
683
  `);
277
684
  }
278
685
  function printCommandHelp(command) {
279
686
  switch (command) {
280
687
  case "init":
281
- process.stdout.write("Usage: frameyard init [directory]\nScaffold a minimal Frameyard project.\n");
688
+ process.stdout.write("Usage: frameyard init [directory] [--local-models]\nScaffold a minimal Frameyard project. Without a directory, init asks for a project name (default: my-video) and creates that directory; non-interactive and --json runs use the default. --local-models also installs the optional policy, OpenCode, and Pi scaffold.\n");
282
689
  return;
283
690
  case "dev":
284
691
  process.stdout.write("Usage: frameyard dev [--port <port>] [--project <dir>]\nStart the browser preview server.\n");
285
692
  return;
693
+ case "context":
694
+ process.stdout.write("Usage: frameyard context [--project <dir>] [--json]\nPrint a compact digest of the project, resolved timelines, assets, diagnostics, active policy, and next commands. It never launches Chromium.\n");
695
+ return;
696
+ case "lint":
697
+ process.stdout.write("Usage: frameyard lint <composition-or-timeline-id> [--props <file>] [--project <dir>] [--json]\nRun static validation, timeline resolution, scene and keyframe projection for every timeline frame, and a controller determinism scan without launching Chromium. It does not run validateProps or capture frames; use check for the full gate.\n");
698
+ return;
286
699
  case "check":
287
- process.stdout.write("Usage: frameyard check <composition-id> [--props <file>] [--project <dir>] [--json]\nValidate props, assets, audio, and every frame without encoding.\n");
700
+ process.stdout.write("Usage: frameyard check <composition-id> [--props <file>] [--project <dir>] [--profile <name>] [--low-priority] [--json]\nValidate props, assets, audio, and every frame without encoding. The authoring policy lowers process priority for check.\n");
288
701
  return;
289
702
  case "still":
290
- process.stdout.write("Usage: frameyard still <composition-or-timeline-id> --frame <spec> [--out <path>] [--props <file>] [--project <dir>] [--overwrite] [--json]\nCapture one or more frames through the production rendering path. <spec> is a frame number, an inclusive range like 2-12, or a comma-separated list, with at most 32 unique frames per invocation. With one frame, --out is a .png file; with multiple frames, --out is a directory. A static composition exposes only frame 0.\n");
703
+ process.stdout.write("Usage: frameyard still <composition-or-timeline-id> --frame <spec> [--out <path>] [--props <file>] [--project <dir>] [--overwrite] [--profile <name>] [--low-priority] [--json]\nCapture one or more frames through the production rendering path. <spec> is a frame number, an inclusive range like 2-12, or a comma-separated list; the policy caps unique frames per invocation. With one frame, --out is a .png file; with multiple frames, --out is a directory. A static composition exposes only frame 0.\n");
704
+ return;
705
+ case "inspect":
706
+ process.stdout.write("Usage: frameyard inspect <image> [--json]\nDecode the first frame and report dimensions, alpha coverage, blank detection, background and dominant colors, content bbox, a coarse luminance grid, and edge density. It never launches Chromium.\n");
707
+ return;
708
+ case "compare":
709
+ process.stdout.write("Usage: frameyard compare <first-image> <second-image> [--json]\nCompare two decoded images pixel by pixel and by 3×3 region. Exits 1 when the images differ; the JSON result still has ok: true and identical: false.\n");
291
710
  return;
292
711
  case "render":
293
- process.stdout.write(`Usage: frameyard render <composition-or-timeline-id> [--out <file>] [--props <file>] [--project <dir>] [--concurrency <n>] [--workers <n>] [--encoder <name>] [--codec <name>] [--container <name>] [--scratch <dir>] [--overwrite] [--json]\nRender an MP4, MOV, MKV, or WebM with H.264, HEVC, or VP9 video and AAC or Opus audio, or a PNG for a static composition. The default output is out/<id>.<extension> for video and out/<id>.png for a static composition. Animated composition concurrency is 1-${MAX_RENDER_CONCURRENCY}; timeline workers are 1-${MAX_TIMELINE_WORKERS}. Encoder choices are auto, software, nvenc, and qsv; the default auto probes NVENC, then Quick Sync, then the codec's software encoder. WebM and VP9 use Opus audio, and HEVC in MP4/MOV keeps the hvc1 sample entry. A static composition rejects --concurrency, --container, --codec, and --encoder.\n`);
712
+ process.stdout.write(`Usage: frameyard render <composition-or-timeline-id> [--out <file>] [--props <file>] [--project <dir>] [--concurrency <n>] [--workers <n>] [--encoder <name>] [--codec <name>] [--container <name>] [--scratch <dir>] [--overwrite] [--dry-run] [--profile <name>] [--low-priority] [--json]\nRender an MP4, MOV, MKV, or WebM with H.264, HEVC, or VP9 video and AAC or Opus audio, or a PNG for a static composition. The default output is out/<id>.<extension> for video and out/<id>.png for a static composition. Animated composition concurrency is 1-${MAX_RENDER_CONCURRENCY}; timeline workers are 1-${MAX_TIMELINE_WORKERS}. The shared-cpu profile caps workers at 2 and concurrency at 1. Encoder choices are auto, software, nvenc, and qsv; the default auto probes NVENC, then Quick Sync, then the codec's software encoder. WebM and VP9 use Opus audio, and HEVC in MP4/MOV keeps the hvc1 sample entry. A static composition rejects --concurrency, --container, --codec, and --encoder. With --dry-run, validates and prints the plan and handoff command without writing outputs or launching Chromium.\n`);
294
713
  return;
295
714
  case "tts":
296
- process.stdout.write("Usage: frameyard tts --speaker <name> --talker-model <gguf> --codec-model <gguf> --out <file.wav> [--text-file <file>] [--style <text>] [--language <lang>] [--device <name>] [--executable <path>] [--manifest <file>] [--force] [--json]\nProduce one frozen 24 kHz mono WAV with local qwentts.cpp CustomVoice synthesis.\n");
715
+ process.stdout.write("Usage: frameyard tts [--engine qwen] --speaker <name> --talker-model <gguf> --codec-model <gguf> --out <file.wav> [--text-file <file>] [--style <text>] [--language <lang>] [--device <name>] [--executable <path>] [--manifest <file>] [--force] [--dry-run] [--profile <name>] [--low-priority] [--json]\n frameyard tts --engine supertonic --speaker <sid> --model-dir <dir> --language <code> --out <file.wav> [--text-file <file>] [--executable <path>] [--manifest <file>] [--force] [--dry-run] [--profile <name>] [--low-priority] [--json]\nProduce one frozen 24 kHz mono WAV with local qwentts.cpp CustomVoice or sherpa-onnx Supertonic 3 synthesis. Supertonic language codes: ar, bg, cs, da, de, el, en, es, et, fi, fr, hi, hr, hu, id, it, ja, ko, lt, lv, nl, pl, pt, ro, ru, sk, sl, sv, tr, uk, vi. With --dry-run, validates inputs and prints the plan and command without loading a model.\n");
297
716
  return;
298
717
  case "transcribe":
299
- process.stdout.write("Usage: frameyard transcribe <source> --model <gguf> --out <file.json> [--words] [--export srt,vtt] [--language <lang>] [--executable <path>] [--manifest <file>] [--force] [--json]\nProduce a canonical Frameyard transcript, optionally with word timings and subtitle exports.\n");
718
+ process.stdout.write("Usage: frameyard transcribe <source> --model <gguf> --out <file.json> [--words] [--export srt,vtt] [--language <lang>] [--executable <path>] [--manifest <file>] [--force] [--dry-run] [--profile <name>] [--low-priority] [--json]\nProduce a canonical Frameyard transcript, optionally with word timings and subtitle exports. With --dry-run, validates inputs and prints the plan and command without loading a model.\n");
719
+ return;
720
+ case "add":
721
+ process.stdout.write("Usage: frameyard add --list [--json]\n frameyard add local-models [--project <dir>] [--json]\n frameyard add <composition|timeline> <id> --template <name> [--project <dir>] [--json]\nWrite a valid template entry and print the exact fy.config.js registration snippet, or install the optional local-model host scaffold. Templates are static-poster, title-card, lower-third, and slideshow. The command refuses to overwrite an existing file or an already registered id.\n");
722
+ return;
723
+ case "font":
724
+ process.stdout.write("Usage: frameyard font search [query] [--category <name>] [--subset <name>] [--style normal|italic] [--variable] [--limit <n>] [--json]\n frameyard font add <fontsource-id> [--family <alias>] [--subset <name>] [--style normal|italic] [--weight <n>] [--static] [--force] [--project <dir>] [--json]\n frameyard font add --file <path> --family <name> [--id <id>] [--weight <n|n-n>] [--style normal|italic] [--subset <name>] [--unicode-range <range>] [--license <id>] [--license-file <path>] [--force] [--project <dir>] [--json]\n frameyard font add --url <https-url> --family <name> [--id <id>] [--weight <n|n-n>] [--style normal|italic] [--subset <name>] [--unicode-range <range>] [--license <id>] [--license-file <path>] [--sha256 <hash>] [--force] [--project <dir>] [--json]\n frameyard font list|verify [--project <dir>] [--json]\n frameyard font remove <id> [--keep-files] [--project <dir>] [--json]\nSearch Fontsource, vendor a Fontsource/local/URL font into assets/fonts/<id>/, and manage fy.fonts.json. The server serves the generated @font-face sheet at /__frameyard/fonts.css. Project policy allows search/list/verify everywhere, add in authoring and full, and remove or --force only in full. A --url import without --license also requires full.\n");
300
725
  return;
301
726
  case "doctor":
302
- process.stdout.write("Usage: frameyard doctor [--qwen-tts <path>] [--whisper-cli <path>] [--talker-model <gguf>] [--codec-model <gguf>] [--model <gguf>] [--manifest <file>] [--project <dir>] [--json]\nInspect core requirements, optional integrations, model readability, and provenance integrity without changing the environment.\n");
727
+ process.stdout.write("Usage: frameyard doctor [--qwen-tts <path>] [--sherpa-onnx-tts <path>] [--whisper-cli <path>] [--talker-model <gguf>] [--codec-model <gguf>] [--model-dir <dir>] [--model <gguf>] [--manifest <file>] [--project <dir>] [--json]\nInspect core requirements, optional integrations, model readability, and provenance integrity without changing the environment.\n");
303
728
  return;
304
729
  default:
305
730
  printHelp();
306
731
  }
307
732
  }
308
- async function projectFromFlag(flags) {
733
+ async function projectFromFlag(flags, options = {}) {
309
734
  const requested = flags.get("project");
310
735
  const root = typeof requested === "string" ? path.resolve(process.cwd(), requested) : await resolveProjectRoot(process.cwd());
311
- return loadProject(root);
736
+ return loadProject(root, options);
312
737
  }
313
738
  /** Resolves the directory that anchors the project-local provenance manifest. */
314
739
  async function assetRootFromFlag(flags) {
@@ -452,10 +877,138 @@ function printDiagnostics(diagnostics) {
452
877
  function printJson(value) {
453
878
  process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
454
879
  }
880
+ async function pathExists(filePath) {
881
+ try {
882
+ await stat(filePath);
883
+ return true;
884
+ }
885
+ catch {
886
+ return false;
887
+ }
888
+ }
889
+ function shellQuote(value) {
890
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
891
+ }
892
+ function handoffCommand(args) {
893
+ return ["npx", "frameyard", ...args].map(shellQuote).join(" ");
894
+ }
895
+ function collectPrerequisites(timeline) {
896
+ const values = new Set();
897
+ const visit = (current) => {
898
+ for (const clip of current.clips) {
899
+ const source = clip.source;
900
+ if (source.kind === "video" || source.kind === "image" || source.kind === "audio") {
901
+ values.add(source.src);
902
+ }
903
+ else if (source.kind === "composition") {
904
+ values.add(source.entry);
905
+ for (const audio of source.audioClips) {
906
+ values.add(audio.src);
907
+ }
908
+ for (const transcript of source.transcriptClips) {
909
+ values.add(transcript.src);
910
+ }
911
+ }
912
+ else if (source.kind === "timeline") {
913
+ visit(source.timeline);
914
+ }
915
+ }
916
+ };
917
+ visit(timeline);
918
+ return [...values].sort();
919
+ }
920
+ function printDryRun(label, plan, commandLine, prerequisites, blockers) {
921
+ process.stdout.write(`${label} dry run\n`);
922
+ process.stdout.write(` plan: ${JSON.stringify(plan)}\n`);
923
+ process.stdout.write(` command: ${commandLine}\n`);
924
+ if (prerequisites.length > 0) {
925
+ process.stdout.write(` prerequisites: ${prerequisites.join(", ")}\n`);
926
+ }
927
+ for (const blocker of blockers) {
928
+ process.stdout.write(` blocker [${blocker.code}]: ${blocker.message}\n`);
929
+ }
930
+ }
931
+ async function prerequisiteBlockers(items) {
932
+ const blockers = [];
933
+ for (const item of items) {
934
+ let info;
935
+ try {
936
+ info = await stat(item.path);
937
+ }
938
+ catch {
939
+ blockers.push({
940
+ code: "ASSET_NOT_FOUND",
941
+ message: `${item.label} does not exist: ${item.path}`,
942
+ suggestion: "Check the path before running the heavy operation.",
943
+ });
944
+ continue;
945
+ }
946
+ if (item.directory === true ? !info.isDirectory() : !info.isFile()) {
947
+ blockers.push({
948
+ code: "ASSET_NOT_FOUND",
949
+ message: `${item.label} is not a ${item.directory === true ? "directory" : "file"}: ${item.path}`,
950
+ suggestion: "Check the path before running the heavy operation.",
951
+ });
952
+ }
953
+ }
954
+ return blockers;
955
+ }
956
+ const DEFAULT_INIT_PROJECT_NAME = "my-video";
957
+ function assertProjectDirectoryName(name) {
958
+ if (name === "." || name === ".." || name.includes("/") || name.includes("\\") || name.includes("\0")) {
959
+ usage(`Project name must be a directory name without path separators; received "${name}".`);
960
+ }
961
+ }
962
+ /**
963
+ * Asks for the project name on an interactive terminal. EOF accepts the
964
+ * default; Ctrl+C cancels the command.
965
+ */
966
+ async function promptProjectName(defaultName) {
967
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
968
+ let interrupted = false;
969
+ rl.on("SIGINT", () => {
970
+ interrupted = true;
971
+ rl.close();
972
+ });
973
+ try {
974
+ const closed = new Promise((resolve) => {
975
+ rl.once("close", () => resolve(null));
976
+ });
977
+ const answer = await Promise.race([rl.question(`Project name (${defaultName}): `), closed]);
978
+ if (interrupted) {
979
+ throw new FrameyardError("CANCELLED", "Project creation cancelled.");
980
+ }
981
+ const name = (answer ?? "").trim();
982
+ return name === "" ? defaultName : name;
983
+ }
984
+ finally {
985
+ rl.close();
986
+ }
987
+ }
988
+ /**
989
+ * `init <directory>` scaffolds into that directory. A bare `init` asks for a
990
+ * project name on an interactive terminal and creates a directory with it;
991
+ * non-interactive and `--json` runs use the default name without prompting.
992
+ */
993
+ async function resolveInitTarget(parsed) {
994
+ const positional = parsed.positionals[0];
995
+ if (positional !== undefined) {
996
+ return path.resolve(process.cwd(), positional);
997
+ }
998
+ const interactive = parsed.flags.has("json") !== true && process.stdin.isTTY === true;
999
+ const name = interactive ? await promptProjectName(DEFAULT_INIT_PROJECT_NAME) : DEFAULT_INIT_PROJECT_NAME;
1000
+ assertProjectDirectoryName(name);
1001
+ return path.resolve(process.cwd(), name);
1002
+ }
455
1003
  async function runInit(parsed) {
456
- const target = path.resolve(process.cwd(), parsed.positionals[0] ?? ".");
1004
+ const target = await resolveInitTarget(parsed);
457
1005
  const json = parsed.flags.has("json") === true;
458
- const files = scaffoldFiles(path.basename(target) || "frameyard-project");
1006
+ const localModels = parsed.flags.has("local-models") === true;
1007
+ const files = {
1008
+ ...scaffoldFiles(path.basename(target) || "frameyard-project"),
1009
+ ...(localModels ? localModelScaffoldFiles() : {}),
1010
+ };
1011
+ const next = localModels ? [...SCAFFOLD_NEXT, "Review .pi/settings.json and run pi --approve"] : [...SCAFFOLD_NEXT];
459
1012
  for (const relativePath of Object.keys(files)) {
460
1013
  try {
461
1014
  await access(path.join(target, relativePath));
@@ -481,7 +1034,8 @@ async function runInit(parsed) {
481
1034
  command: "init",
482
1035
  project: target,
483
1036
  files: Object.keys(files),
484
- next: [...SCAFFOLD_NEXT],
1037
+ next,
1038
+ ...(localModels ? { localModels: true } : {}),
485
1039
  };
486
1040
  if (json) {
487
1041
  printJson(result);
@@ -491,7 +1045,7 @@ async function runInit(parsed) {
491
1045
  for (const file of Object.keys(files)) {
492
1046
  process.stdout.write(` ${file}\n`);
493
1047
  }
494
- process.stdout.write("Next: " + SCAFFOLD_NEXT.slice(0, 2).join(" && ") + "\n");
1048
+ process.stdout.write("Next: " + next.slice(0, 2).join(" && ") + "\n");
495
1049
  }
496
1050
  return 0;
497
1051
  }
@@ -519,8 +1073,573 @@ async function runDev(parsed) {
519
1073
  await server.close();
520
1074
  return 0;
521
1075
  }
522
- async function runCheck(parsed, signal) {
1076
+ async function runContext(parsed, signal, policy) {
1077
+ assertOperationAllowed(policy, "context");
1078
+ const project = await projectFromFlag(parsed.flags);
1079
+ const result = await buildProjectContext({ project, policy, signal });
1080
+ const json = parsed.flags.has("json") === true;
1081
+ if (json) {
1082
+ printJson({ ok: true, command: "context", ...result });
1083
+ return 0;
1084
+ }
1085
+ process.stdout.write(`Frameyard project: ${result.root}\n`);
1086
+ if (result.format !== undefined) {
1087
+ process.stdout.write(`format: ${result.format.width}×${result.format.height} @ ${result.format.fps} fps\n`);
1088
+ }
1089
+ process.stdout.write(`policy: ${describePolicy(policy)}\n`);
1090
+ process.stdout.write(`compositions: ${result.compositions.length}\n`);
1091
+ for (const composition of result.compositions) {
1092
+ const metadata = composition.metadata;
1093
+ const detail = metadata === undefined
1094
+ ? composition.kind
1095
+ : metadata.static === true
1096
+ ? `static ${metadata.width}×${metadata.height}`
1097
+ : `${metadata.width}×${metadata.height} @ ${metadata.fps} fps, ${metadata.durationInFrames} frames`;
1098
+ process.stdout.write(` ${composition.id}: ${detail}, ${composition.props.length} props, ${composition.diagnostics.errors} errors/${composition.diagnostics.warnings} warnings\n`);
1099
+ }
1100
+ process.stdout.write(`timelines: ${result.timelines.length}\n`);
1101
+ for (const timeline of result.timelines) {
1102
+ const detail = timeline.durationInFrames === undefined
1103
+ ? "unresolved"
1104
+ : `${timeline.durationInFrames} frames (${timeline.durationSeconds?.toFixed(1)}s), ${timeline.tracks} tracks, ${timeline.clips?.length ?? 0} clips, ${timeline.transitions} transitions, ${timeline.audio} audio, ${timeline.transcripts} transcripts`;
1105
+ process.stdout.write(` ${timeline.id}: ${detail}, ${timeline.diagnostics.errors} errors/${timeline.diagnostics.warnings} warnings\n`);
1106
+ }
1107
+ process.stdout.write(`assets: ${result.assets.length}\n`);
1108
+ process.stdout.write(`fonts: ${result.fonts.length}\n`);
1109
+ for (const font of result.fonts) {
1110
+ const subsets = font.subsets.length === 0 ? "no subset" : font.subsets.join(",");
1111
+ process.stdout.write(` ${font.id}: ${font.family} (${font.provider}${font.version ? ` ${font.version}` : ""}), ${subsets}, ${font.styles.join("/")}, ${font.license}, ${font.files} files, ${font.status}\n`);
1112
+ }
1113
+ process.stdout.write(`next:\n`);
1114
+ for (const command of result.next) {
1115
+ process.stdout.write(` ${command}\n`);
1116
+ }
1117
+ return 0;
1118
+ }
1119
+ async function runInspect(parsed) {
1120
+ const startedAt = performance.now();
1121
+ const filePath = path.resolve(process.cwd(), parsed.positionals[0]);
1122
+ const probed = await probeImage(filePath);
1123
+ const pixels = await decodeRgba(filePath, probed.width, probed.height);
1124
+ const stats = analyzeRgba(pixels, probed.width, probed.height);
1125
+ const json = parsed.flags.has("json") === true;
1126
+ const result = {
1127
+ ok: true,
1128
+ command: "inspect",
1129
+ path: filePath,
1130
+ bytes: probed.bytes,
1131
+ ...stats,
1132
+ elapsedMs: performance.now() - startedAt,
1133
+ };
1134
+ if (json) {
1135
+ printJson(result);
1136
+ return 0;
1137
+ }
1138
+ process.stdout.write(`Inspected ${filePath} (${stats.width}×${stats.height}, ${probed.bytes} bytes)\n`);
1139
+ process.stdout.write(` blank: ${stats.blank}; alpha ${stats.alpha.min}..${stats.alpha.max}, coverage ${(stats.alpha.coverage * 100).toFixed(1)}%\n`);
1140
+ process.stdout.write(` background: ${stats.background.hex} (${(stats.background.share * 100).toFixed(1)}%); content: ${stats.content.pixels} px (${(stats.content.share * 100).toFixed(1)}%)${stats.content.bbox === null ? "" : ` at ${stats.content.bbox.x},${stats.content.bbox.y} ${stats.content.bbox.width}×${stats.content.bbox.height}`}\n`);
1141
+ process.stdout.write(` colors: ${stats.colors.map((color) => `${color.hex} ${(color.share * 100).toFixed(1)}%`).join(", ") || "(none)"}\n`);
1142
+ process.stdout.write(` edge density: ${stats.edgeDensity}\n`);
1143
+ return 0;
1144
+ }
1145
+ async function runCompare(parsed) {
1146
+ const startedAt = performance.now();
1147
+ const firstPath = path.resolve(process.cwd(), parsed.positionals[0]);
1148
+ const secondPath = path.resolve(process.cwd(), parsed.positionals[1]);
1149
+ const [first, second] = await Promise.all([probeImage(firstPath), probeImage(secondPath)]);
1150
+ if (first.width !== second.width || first.height !== second.height) {
1151
+ throw new FrameyardError("IMAGE_INVALID", `Images have different dimensions: ${firstPath} is ${first.width}×${first.height}, ${secondPath} is ${second.width}×${second.height}.`, { details: { first: firstPath, second: secondPath } });
1152
+ }
1153
+ const [firstPixels, secondPixels] = await Promise.all([
1154
+ decodeRgba(firstPath, first.width, first.height),
1155
+ decodeRgba(secondPath, second.width, second.height),
1156
+ ]);
1157
+ const stats = compareRgba(firstPixels, secondPixels, first.width, first.height);
1158
+ const json = parsed.flags.has("json") === true;
1159
+ const result = {
1160
+ ok: true,
1161
+ command: "compare",
1162
+ first: firstPath,
1163
+ second: secondPath,
1164
+ ...stats,
1165
+ elapsedMs: performance.now() - startedAt,
1166
+ };
1167
+ if (json) {
1168
+ printJson(result);
1169
+ }
1170
+ else if (stats.identical) {
1171
+ process.stdout.write(`${firstPath} and ${secondPath} are pixel-identical (${stats.width}×${stats.height}).\n`);
1172
+ }
1173
+ else {
1174
+ process.stdout.write(`${firstPath} and ${secondPath} differ:\n`);
1175
+ process.stdout.write(` dimensions: ${stats.width}×${stats.height}\n`);
1176
+ process.stdout.write(` differing pixels: ${stats.differingPixels} / ${stats.totalPixels} (${stats.percent}%)\n`);
1177
+ process.stdout.write(` max channel delta: ${stats.maxChannelDelta}; mean ${stats.meanChannelDelta}\n`);
1178
+ process.stdout.write(` bounding box: ${stats.bbox === null ? "(none)" : `x=${stats.bbox.x}..${stats.bbox.x + stats.bbox.width - 1}, y=${stats.bbox.y}..${stats.bbox.y + stats.bbox.height - 1}`}\n`);
1179
+ }
1180
+ return stats.identical ? 0 : 1;
1181
+ }
1182
+ async function runAdd(parsed) {
1183
+ const json = parsed.flags.has("json") === true;
1184
+ if (parsed.flags.has("list")) {
1185
+ const templates = listTemplates().map((template) => ({
1186
+ name: template.name,
1187
+ kind: template.kind,
1188
+ description: template.description,
1189
+ ...(template.prerequisites !== undefined ? { prerequisites: template.prerequisites } : {}),
1190
+ }));
1191
+ const scaffolds = [
1192
+ {
1193
+ name: "local-models",
1194
+ description: "Optional policy, OpenCode permissions, and Pi settings for a shared local model.",
1195
+ },
1196
+ ];
1197
+ if (json) {
1198
+ printJson({ ok: true, command: "add", templates, scaffolds });
1199
+ }
1200
+ else {
1201
+ for (const template of templates) {
1202
+ process.stdout.write(`${template.kind}\t${template.name}\t${template.description}\n`);
1203
+ }
1204
+ for (const scaffold of scaffolds) {
1205
+ process.stdout.write(`scaffold\t${scaffold.name}\t${scaffold.description}\n`);
1206
+ }
1207
+ }
1208
+ return 0;
1209
+ }
1210
+ if (parsed.positionals[0] === "local-models") {
1211
+ const root = await assetRootFromFlag(parsed.flags);
1212
+ const files = localModelScaffoldFiles();
1213
+ const conflicts = [];
1214
+ for (const relative of Object.keys(files)) {
1215
+ if (await pathExists(path.join(root, relative))) {
1216
+ conflicts.push(relative);
1217
+ }
1218
+ }
1219
+ if (conflicts.length > 0) {
1220
+ throw new FrameyardError("OUTPUT_EXISTS", `Refusing to overwrite local-model scaffold files: ${conflicts.join(", ")}.`, { details: { conflicts } });
1221
+ }
1222
+ for (const [relative, content] of Object.entries(files)) {
1223
+ const target = path.join(root, relative);
1224
+ await mkdir(path.dirname(target), { recursive: true });
1225
+ await writeFile(target, content, { flag: "wx" });
1226
+ }
1227
+ const result = {
1228
+ ok: true,
1229
+ command: "add",
1230
+ kind: "scaffold",
1231
+ scaffold: "local-models",
1232
+ path: root,
1233
+ files: Object.keys(files),
1234
+ next: [
1235
+ "Review .opencode/opencode.jsonc and .pi/settings.json",
1236
+ "Run pi --approve in the project",
1237
+ "Run npx frameyard context --json from the project",
1238
+ ],
1239
+ };
1240
+ if (json) {
1241
+ printJson(result);
1242
+ }
1243
+ else {
1244
+ process.stdout.write(`Installed local-model scaffold in ${root}\n`);
1245
+ for (const file of result.files) {
1246
+ process.stdout.write(` ${file}\n`);
1247
+ }
1248
+ process.stdout.write(`Next: ${result.next[0]}\n`);
1249
+ }
1250
+ return 0;
1251
+ }
1252
+ const kind = parsed.positionals[0];
1253
+ const id = parsed.positionals[1];
1254
+ const templateName = flagString(parsed.flags, "template");
1255
+ const template = findTemplate(templateName);
1256
+ if (template === undefined) {
1257
+ usage(`Unknown template "${templateName}".`);
1258
+ }
1259
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) {
1260
+ throw new FrameyardError("CONFIG_INVALID", `Invalid id "${id}". Use letters, digits, dots, underscores, or hyphens, starting with a letter or digit.`);
1261
+ }
1262
+ const project = await projectFromFlag(parsed.flags);
1263
+ if (project.compositions.has(id) || project.timelines.has(id)) {
1264
+ throw new FrameyardError("OUTPUT_EXISTS", `Project id "${id}" is already registered in fy.config.js.`);
1265
+ }
1266
+ if (project.format === undefined) {
1267
+ throw new FrameyardError("CONFIG_INVALID", "Project templates inherit the project format. Add format { width, height, fps } to fy.config.js first.");
1268
+ }
1269
+ const entry = template.entry(id);
1270
+ const targetPath = path.resolve(project.root, entry);
1271
+ if (await pathExists(targetPath)) {
1272
+ throw new FrameyardError("OUTPUT_EXISTS", `Refusing to overwrite ${targetPath}.`);
1273
+ }
1274
+ try {
1275
+ await mkdir(path.dirname(targetPath), { recursive: true });
1276
+ await writeFile(targetPath, template.content, { flag: "wx" });
1277
+ }
1278
+ catch (error) {
1279
+ throw new FrameyardError("OUTPUT_FAILED", `Could not write ${targetPath}: ${errorMessage(error)}`, {
1280
+ cause: error,
1281
+ });
1282
+ }
1283
+ const registration = templateRegistration(template, id);
1284
+ const register = Object.fromEntries(Object.entries(registration).filter(([, value]) => value !== undefined));
1285
+ const result = {
1286
+ ok: true,
1287
+ command: "add",
1288
+ kind,
1289
+ id,
1290
+ template: template.name,
1291
+ entry,
1292
+ path: targetPath,
1293
+ register,
1294
+ prerequisites: template.prerequisites ?? [],
1295
+ next: ["Add the register entry to fy.config.js", `npx frameyard lint ${id} --json`],
1296
+ };
1297
+ if (json) {
1298
+ printJson(result);
1299
+ }
1300
+ else {
1301
+ process.stdout.write(`Created ${targetPath}\n`);
1302
+ const key = kind === "composition" ? "compositions" : "timelines";
1303
+ process.stdout.write(`Add to fy.config.js:\n ${key}: { "${id}": "./${entry}" }\n`);
1304
+ if ((template.prerequisites ?? []).length > 0) {
1305
+ process.stdout.write(`Provide: ${(template.prerequisites ?? []).join(", ")}\n`);
1306
+ }
1307
+ }
1308
+ return 0;
1309
+ }
1310
+ function splitFontList(value, flag) {
1311
+ if (value === undefined) {
1312
+ return undefined;
1313
+ }
1314
+ const parts = value
1315
+ .split(",")
1316
+ .map((part) => part.trim())
1317
+ .filter((part) => part.length > 0);
1318
+ if (parts.length === 0) {
1319
+ usage(`--${flag} requires at least one value.`);
1320
+ }
1321
+ return parts;
1322
+ }
1323
+ function parseFontsourceWeights(value) {
1324
+ const parts = splitFontList(value, "weight");
1325
+ if (parts === undefined) {
1326
+ return undefined;
1327
+ }
1328
+ return parts.map((part) => {
1329
+ const weight = Number(part);
1330
+ if (!Number.isInteger(weight) || weight < 1 || weight > 1000) {
1331
+ usage(`--weight entries must be integers between 1 and 1000; received "${part}".`);
1332
+ }
1333
+ return weight;
1334
+ });
1335
+ }
1336
+ function parseFontsourceStyles(value) {
1337
+ const parts = splitFontList(value, "style");
1338
+ return parts?.map((part) => parseFontStyle(part));
1339
+ }
1340
+ function printFontAddResult(result, json) {
1341
+ if (json) {
1342
+ printJson({ ok: true, command: "font", action: "add", ...result });
1343
+ return;
1344
+ }
1345
+ for (const warning of result.warnings) {
1346
+ process.stderr.write(`⚠ ${warning.code}: ${warning.message}\n`);
1347
+ }
1348
+ process.stdout.write(`✓ font ${result.font.id}: ${result.font.family} (${result.font.provider}${result.font.version ? ` ${result.font.version}` : ""}), ${result.font.files} file${result.font.files === 1 ? "" : "s"}, ${result.font.bytes} bytes\n`);
1349
+ process.stdout.write(` manifest: ${result.manifest}\n`);
1350
+ process.stdout.write(` css: /__frameyard/fonts.css\n`);
1351
+ process.stdout.write(` use: font-family: "${result.font.family}", system-ui, sans-serif\n`);
1352
+ }
1353
+ async function runFont(parsed, signal, policy) {
1354
+ const action = parsed.positionals[0];
1355
+ const json = parsed.flags.has("json") === true;
1356
+ assertOperationAllowed(policy, "font");
1357
+ if (action === "search") {
1358
+ const query = parsed.positionals[1] ?? "";
1359
+ const category = flagString(parsed.flags, "category");
1360
+ const subset = flagString(parsed.flags, "subset");
1361
+ const styleValue = flagString(parsed.flags, "style");
1362
+ const style = styleValue === undefined ? undefined : parseFontStyle(styleValue);
1363
+ const variable = parsed.flags.has("variable") ? true : undefined;
1364
+ const limitValue = flagString(parsed.flags, "limit");
1365
+ const limit = limitValue === undefined ? undefined : Number(limitValue);
1366
+ const result = await searchFontsource({
1367
+ query,
1368
+ ...(category !== undefined ? { category } : {}),
1369
+ ...(subset !== undefined ? { subset } : {}),
1370
+ ...(style !== undefined ? { style } : {}),
1371
+ ...(variable !== undefined ? { variable } : {}),
1372
+ ...(limit !== undefined ? { limit } : {}),
1373
+ signal,
1374
+ });
1375
+ if (json) {
1376
+ printJson({ ok: true, command: "font", action: "search", ...result });
1377
+ }
1378
+ else {
1379
+ process.stdout.write(`fontsource: ${result.results.length} result${result.results.length === 1 ? "" : "s"}${result.truncated ? " (truncated)" : ""}\n`);
1380
+ for (const item of result.results) {
1381
+ process.stdout.write(` ${item.id}: ${item.family} [${item.category}]${item.variable ? " variable" : ""}, weights ${item.weights.join(",")}, ${item.styles.join("/")}, subsets ${item.subsets.join(",")}, ${item.license}\n`);
1382
+ }
1383
+ }
1384
+ return 0;
1385
+ }
1386
+ if (action === "add") {
1387
+ assertPolicyTier(policy, "authoring", "font add");
1388
+ if (typeof parsed.flags.get("url") === "string" &&
1389
+ !parsed.flags.has("license") &&
1390
+ !parsed.flags.has("license-file")) {
1391
+ assertPolicyTier(policy, "full", "font add --url without --license");
1392
+ }
1393
+ }
1394
+ if (action === "remove") {
1395
+ assertPolicyTier(policy, "full", "font remove");
1396
+ }
1397
+ if (parsed.flags.has("force")) {
1398
+ assertOverwriteAllowed(policy, "force");
1399
+ }
1400
+ const project = await projectFromFlag(parsed.flags, { fonts: false });
1401
+ let manifest;
1402
+ try {
1403
+ manifest = await readFontManifest(project.root);
1404
+ }
1405
+ catch (error) {
1406
+ if (action === "verify" && isFrameyardError(error)) {
1407
+ const verification = {
1408
+ manifest: fontManifestPath(project.root),
1409
+ fonts: 0,
1410
+ files: 0,
1411
+ bytes: 0,
1412
+ diagnostics: [
1413
+ {
1414
+ severity: "error",
1415
+ code: error.code,
1416
+ message: error.message,
1417
+ },
1418
+ ],
1419
+ valid: false,
1420
+ };
1421
+ if (json) {
1422
+ printJson({ ok: true, command: "font", action: "verify", ...verification });
1423
+ }
1424
+ else {
1425
+ process.stderr.write(`✗ ${error.code}: ${error.message}\n`);
1426
+ }
1427
+ return 1;
1428
+ }
1429
+ throw error;
1430
+ }
1431
+ if (action === "add") {
1432
+ const file = flagString(parsed.flags, "file");
1433
+ const url = flagString(parsed.flags, "url");
1434
+ const family = flagString(parsed.flags, "family");
1435
+ const id = flagString(parsed.flags, "id");
1436
+ const weightValue = flagString(parsed.flags, "weight");
1437
+ const styleValue = flagString(parsed.flags, "style");
1438
+ const subsetValue = flagString(parsed.flags, "subset");
1439
+ const force = parsed.flags.has("force") === true;
1440
+ if (file !== undefined) {
1441
+ printFontAddResult(await addLocalFont({
1442
+ projectRoot: project.root,
1443
+ ...(manifest !== undefined ? { manifest } : {}),
1444
+ file,
1445
+ family: family,
1446
+ ...(id !== undefined ? { id } : {}),
1447
+ weight: weightValue === undefined ? 400 : parseFontWeight(weightValue),
1448
+ style: styleValue === undefined ? "normal" : parseFontStyle(styleValue),
1449
+ ...(subsetValue !== undefined ? { subset: subsetValue } : {}),
1450
+ ...(flagString(parsed.flags, "unicode-range") !== undefined
1451
+ ? { unicodeRange: flagString(parsed.flags, "unicode-range") }
1452
+ : {}),
1453
+ ...(flagString(parsed.flags, "license") !== undefined
1454
+ ? { license: flagString(parsed.flags, "license") }
1455
+ : {}),
1456
+ ...(flagString(parsed.flags, "license-file") !== undefined
1457
+ ? { licenseFile: flagString(parsed.flags, "license-file") }
1458
+ : {}),
1459
+ force,
1460
+ }), json);
1461
+ return 0;
1462
+ }
1463
+ if (url !== undefined) {
1464
+ printFontAddResult(await addUrlFont({
1465
+ projectRoot: project.root,
1466
+ ...(manifest !== undefined ? { manifest } : {}),
1467
+ url,
1468
+ family: family,
1469
+ ...(id !== undefined ? { id } : {}),
1470
+ weight: weightValue === undefined ? 400 : parseFontWeight(weightValue),
1471
+ style: styleValue === undefined ? "normal" : parseFontStyle(styleValue),
1472
+ ...(subsetValue !== undefined ? { subset: subsetValue } : {}),
1473
+ ...(flagString(parsed.flags, "unicode-range") !== undefined
1474
+ ? { unicodeRange: flagString(parsed.flags, "unicode-range") }
1475
+ : {}),
1476
+ ...(flagString(parsed.flags, "license") !== undefined
1477
+ ? { license: flagString(parsed.flags, "license") }
1478
+ : {}),
1479
+ ...(flagString(parsed.flags, "license-file") !== undefined
1480
+ ? { licenseFile: flagString(parsed.flags, "license-file") }
1481
+ : {}),
1482
+ ...(flagString(parsed.flags, "sha256") !== undefined
1483
+ ? { sha256: flagString(parsed.flags, "sha256") }
1484
+ : {}),
1485
+ force,
1486
+ signal,
1487
+ }), json);
1488
+ return 0;
1489
+ }
1490
+ printFontAddResult(await addFontsource({
1491
+ projectRoot: project.root,
1492
+ ...(manifest !== undefined ? { manifest } : {}),
1493
+ id: parsed.positionals[1],
1494
+ ...(family !== undefined ? { family } : {}),
1495
+ ...(subsetValue !== undefined ? { subsets: splitFontList(subsetValue, "subset") } : {}),
1496
+ ...(styleValue !== undefined ? { styles: parseFontsourceStyles(styleValue) } : {}),
1497
+ ...(weightValue !== undefined ? { weights: parseFontsourceWeights(weightValue) } : {}),
1498
+ static: parsed.flags.has("static") === true,
1499
+ force,
1500
+ signal,
1501
+ }), json);
1502
+ return 0;
1503
+ }
1504
+ if (action === "list") {
1505
+ const verification = manifest === undefined
1506
+ ? undefined
1507
+ : await verifyFontManifest({
1508
+ projectRoot: project.root,
1509
+ manifest,
1510
+ hashes: false,
1511
+ licenses: false,
1512
+ orphans: false,
1513
+ });
1514
+ const statuses = verification === undefined ? new Map() : fontStatuses(verification);
1515
+ const fonts = (manifest?.fonts ?? [])
1516
+ .map((font) => summarizeFont(font, statuses.get(font.id) ?? "ok"))
1517
+ .sort((left, right) => left.family.localeCompare(right.family));
1518
+ const result = {
1519
+ ok: true,
1520
+ command: "font",
1521
+ action: "list",
1522
+ manifest: fontManifestPath(project.root),
1523
+ fonts,
1524
+ };
1525
+ if (json) {
1526
+ printJson(result);
1527
+ }
1528
+ else {
1529
+ if (fonts.length === 0) {
1530
+ process.stdout.write("No project fonts are installed.\n");
1531
+ }
1532
+ else {
1533
+ process.stdout.write(`fonts: ${fonts.length}\n`);
1534
+ for (const font of fonts) {
1535
+ const subsets = font.subsets.length === 0 ? "no subset" : font.subsets.join(", ");
1536
+ process.stdout.write(` ${font.id}: ${font.family} (${font.provider}${font.version ? ` ${font.version}` : ""}), ${subsets}, ${font.styles.join("/")}, ${font.license}, ${font.files} files, ${font.bytes} bytes, ${font.status}\n`);
1537
+ }
1538
+ }
1539
+ }
1540
+ return 0;
1541
+ }
1542
+ if (action === "verify") {
1543
+ const verification = await verifyFontManifest({
1544
+ projectRoot: project.root,
1545
+ manifest: manifest ?? emptyFontManifest(),
1546
+ hashes: true,
1547
+ licenses: true,
1548
+ orphans: true,
1549
+ });
1550
+ if (json) {
1551
+ printJson({ ok: true, command: "font", action: "verify", ...verification });
1552
+ }
1553
+ else {
1554
+ if (verification.fonts === 0) {
1555
+ process.stdout.write("No project fonts are installed.\n");
1556
+ }
1557
+ else {
1558
+ process.stdout.write(`${verification.valid ? "✓" : "✗"} font manifest: ${verification.fonts} fonts, ${verification.files} files, ${verification.bytes} bytes\n`);
1559
+ process.stdout.write(` ${verification.manifest}\n`);
1560
+ }
1561
+ for (const diagnostic of verification.diagnostics) {
1562
+ const marker = diagnostic.severity === "error" ? "✗" : "⚠";
1563
+ process.stderr.write(`${marker} ${diagnostic.code}: ${diagnostic.message}${diagnostic.file !== undefined ? ` (${diagnostic.file})` : ""}\n`);
1564
+ }
1565
+ }
1566
+ return verification.valid ? 0 : 1;
1567
+ }
1568
+ if (action === "remove") {
1569
+ const result = await removeFont({
1570
+ projectRoot: project.root,
1571
+ ...(manifest !== undefined ? { manifest } : {}),
1572
+ id: parsed.positionals[1],
1573
+ keepFiles: parsed.flags.has("keep-files") === true,
1574
+ });
1575
+ if (json) {
1576
+ printJson({ ok: true, command: "font", action: "remove", ...result });
1577
+ }
1578
+ else {
1579
+ process.stdout.write(`Removed font ${result.id} (${result.family}).\n`);
1580
+ if (result.keptFiles) {
1581
+ process.stdout.write(` kept files: ${result.files.join(", ")}\n`);
1582
+ }
1583
+ }
1584
+ return 0;
1585
+ }
1586
+ usage(`Unknown font action "${action}". Use add, list, verify, or remove.`);
1587
+ }
1588
+ async function runLint(parsed, signal, policy) {
1589
+ const project = await projectFromFlag(parsed.flags);
1590
+ await assertFontIntegrity(project.root, project.fonts, { hashes: false });
1591
+ const props = await readPropsFile(parsed.flags);
1592
+ const targetId = parsed.positionals[0];
1593
+ const json = parsed.flags.has("json") === true;
1594
+ if (project.timelines.has(targetId)) {
1595
+ if (props !== undefined && !isPlainObject(props)) {
1596
+ usage("Timeline --props must be a JSON object keyed by composition clip id.");
1597
+ }
1598
+ const result = await lintTimeline({
1599
+ project,
1600
+ timelineId: targetId,
1601
+ ...(props !== undefined ? { propsOverrides: props } : {}),
1602
+ signal,
1603
+ });
1604
+ printDiagnostics(result.diagnostics);
1605
+ if (json) {
1606
+ printJson({ ok: true, command: "lint", policy: policyDetails(policy), ...result });
1607
+ }
1608
+ else {
1609
+ process.stdout.write(`✓ lint timeline ${targetId}: ${result.framesProjected} frames projected, ${result.segments} segment${result.segments === 1 ? "" : "s"}, ${result.tracks} track${result.tracks === 1 ? "" : "s"}, ${result.clips} clip${result.clips === 1 ? "" : "s"}\n`);
1610
+ process.stdout.write(` policy: ${describePolicy(policy)}; lint does not replace check (props hooks and frame runtime are not evaluated)\n`);
1611
+ }
1612
+ return 0;
1613
+ }
1614
+ const compositionId = targetId;
1615
+ const result = await lintComposition({
1616
+ project,
1617
+ compositionId,
1618
+ props,
1619
+ signal,
1620
+ });
1621
+ printDiagnostics(result.diagnostics);
1622
+ if (json) {
1623
+ printJson({ ok: true, command: "lint", policy: policyDetails(policy), ...result });
1624
+ }
1625
+ else if (result.metadata.static === true) {
1626
+ process.stdout.write(`✓ lint ${compositionId}: static ${result.metadata.width}×${result.metadata.height}; controller and document validated\n`);
1627
+ process.stdout.write(` policy: ${describePolicy(policy)}; lint does not replace check\n`);
1628
+ }
1629
+ else {
1630
+ process.stdout.write(`✓ lint ${compositionId}: document and controller validated for ${result.metadata.durationInFrames} frames; frame runtime is not evaluated\n`);
1631
+ process.stdout.write(` policy: ${describePolicy(policy)}; use check for props hooks and every frame\n`);
1632
+ }
1633
+ return 0;
1634
+ }
1635
+ async function runCheck(parsed, signal, policy) {
1636
+ assertOperationAllowed(policy, "check");
1637
+ const profile = resolveProfile(policy, flagString(parsed.flags, "profile"));
1638
+ if (shouldLowerPriority(policy, profile, parsed.flags.has("low-priority") === true)) {
1639
+ lowerProcessPriority();
1640
+ }
523
1641
  const project = await projectFromFlag(parsed.flags);
1642
+ await assertFontIntegrity(project.root, project.fonts, { hashes: true });
524
1643
  const props = await readPropsFile(parsed.flags);
525
1644
  const targetId = parsed.positionals[0];
526
1645
  const json = parsed.flags.has("json") === true;
@@ -540,7 +1659,7 @@ async function runCheck(parsed, signal) {
540
1659
  printJson({ ok: true, command: "check", ...result });
541
1660
  }
542
1661
  else {
543
- process.stdout.write(`✓ timeline ${targetId}: ${result.framesChecked} frames, ${result.format.width}×${result.format.height} @ ${result.format.fps} fps (${formatDuration(result.durationInFrames / result.format.fps)}), ${result.tracks} track${result.tracks === 1 ? "" : "s"}, ${result.clips} clip${result.clips === 1 ? "" : "s"}\n`);
1662
+ process.stdout.write(`✓ timeline ${targetId}: ${result.framesChecked} frames, ${result.format.width}×${result.format.height} @ ${result.format.fps} fps (${formatDuration(result.durationInFrames / result.format.fps)}), ${result.tracks} track${result.tracks === 1 ? "" : "s"}, ${result.clips} clip${result.clips === 1 ? "" : "s"}, ${result.transitions} transition${result.transitions === 1 ? "" : "s"}\n`);
544
1663
  process.stdout.write(` audio: ${result.audio.clips} clip${result.audio.clips === 1 ? "" : "s"}\n`);
545
1664
  if (result.transcripts.clips > 0) {
546
1665
  process.stdout.write(` transcripts: ${result.transcripts.clips} asset${result.transcripts.clips === 1 ? "" : "s"}\n`);
@@ -698,8 +1817,17 @@ function printStillResult(result, targetId, timeline, json) {
698
1817
  process.stdout.write(`Wrote ${result.frames.length} frames of ${target} to ${directory} (${result.width}×${result.height})\n`);
699
1818
  }
700
1819
  }
701
- async function runStill(parsed, signal) {
1820
+ async function runStill(parsed, signal, policy) {
1821
+ assertOperationAllowed(policy, "still");
1822
+ if (parsed.flags.has("overwrite")) {
1823
+ assertOverwriteAllowed(policy, "overwrite");
1824
+ }
1825
+ const profile = resolveProfile(policy, flagString(parsed.flags, "profile"));
1826
+ if (shouldLowerPriority(policy, profile, parsed.flags.has("low-priority") === true)) {
1827
+ lowerProcessPriority();
1828
+ }
702
1829
  const project = await projectFromFlag(parsed.flags);
1830
+ await assertFontIntegrity(project.root, project.fonts, { hashes: true });
703
1831
  const props = await readPropsFile(parsed.flags);
704
1832
  const targetId = parsed.positionals[0];
705
1833
  const frameValue = parsed.flags.get("frame");
@@ -707,6 +1835,7 @@ async function runStill(parsed, signal) {
707
1835
  usage('"still" requires --frame <spec>.');
708
1836
  }
709
1837
  const frames = parseFrameSpec(frameValue);
1838
+ assertStillFrameLimit(policy, frames.length);
710
1839
  const outputPlan = await resolveStillOutputs({
711
1840
  targetId,
712
1841
  frames,
@@ -751,8 +1880,17 @@ async function runStill(parsed, signal) {
751
1880
  throw error;
752
1881
  }
753
1882
  }
754
- async function runRender(parsed, signal) {
1883
+ async function runRender(parsed, signal, policy) {
1884
+ const dryRun = parsed.flags.has("dry-run") === true;
1885
+ const profile = resolveProfile(policy, flagString(parsed.flags, "profile"));
1886
+ if (!dryRun) {
1887
+ assertOperationAllowed(policy, "render");
1888
+ }
1889
+ if (parsed.flags.has("overwrite")) {
1890
+ assertOverwriteAllowed(policy, "overwrite");
1891
+ }
755
1892
  const project = await projectFromFlag(parsed.flags);
1893
+ await assertFontIntegrity(project.root, project.fonts, { hashes: true });
756
1894
  const props = await readPropsFile(parsed.flags);
757
1895
  const encoder = encoderChoice(parsed.flags);
758
1896
  const containerValue = containerChoice(parsed.flags);
@@ -787,11 +1925,113 @@ async function runRender(parsed, signal) {
787
1925
  }
788
1926
  const scratchValue = flagString(parsed.flags, "scratch");
789
1927
  const scratchDir = scratchValue === undefined ? undefined : path.resolve(process.cwd(), scratchValue);
1928
+ let effectiveWorkers = workers;
1929
+ if (profile === "shared-cpu") {
1930
+ if (workers !== undefined) {
1931
+ assertProfileLimit(profile, "workers", workers, SHARED_CPU_MAX_WORKERS);
1932
+ }
1933
+ effectiveWorkers = workers ?? SHARED_CPU_MAX_WORKERS;
1934
+ }
1935
+ if (dryRun) {
1936
+ const projectTimeline = project.timelines.get(targetId);
1937
+ if (!projectTimeline) {
1938
+ throw new FrameyardError("TIMELINE_NOT_FOUND", `Timeline "${targetId}" is not listed in fy.config.js.`);
1939
+ }
1940
+ const resolved = await resolveProjectTimeline({
1941
+ project,
1942
+ timeline: projectTimeline,
1943
+ ...(props !== undefined ? { propsOverrides: props } : {}),
1944
+ signal,
1945
+ });
1946
+ const firstError = resolved.diagnostics.find((diagnostic) => diagnostic.severity === "error");
1947
+ if (!resolved.timeline || firstError) {
1948
+ throw new FrameyardError(firstError?.code ?? "TIMELINE_INVALID", firstError?.message ?? "Timeline resolution failed.", { diagnostics: resolved.diagnostics });
1949
+ }
1950
+ const lintResult = await lintResolvedTimeline({
1951
+ timeline: resolved.timeline,
1952
+ projectRoot: project.root,
1953
+ diagnostics: resolved.diagnostics,
1954
+ signal,
1955
+ });
1956
+ const lintError = lintResult.diagnostics.find((diagnostic) => diagnostic.severity === "error");
1957
+ if (lintError) {
1958
+ throw new FrameyardError(lintError.code, lintError.message, { diagnostics: lintResult.diagnostics });
1959
+ }
1960
+ const plan = planTimelineRender(resolved.timeline);
1961
+ const planWorkers = effectiveWorkers ?? 8;
1962
+ const planDetails = {
1963
+ kind: "video",
1964
+ frames: resolved.timeline.durationInFrames,
1965
+ durationSeconds: resolved.timeline.durationInFrames / resolved.timeline.format.fps,
1966
+ segments: plan.segments.length,
1967
+ workers: planWorkers,
1968
+ encoder: encoder ?? "auto",
1969
+ codec,
1970
+ container,
1971
+ probed: false,
1972
+ ...(scratchDir !== undefined ? { scratch: scratchDir } : {}),
1973
+ };
1974
+ const outputExists = await pathExists(timelineOutPath);
1975
+ const blockers = outputExists
1976
+ ? [
1977
+ {
1978
+ code: "OUTPUT_EXISTS",
1979
+ message: `Output already exists: ${timelineOutPath}.`,
1980
+ suggestion: policy.allowOverwrite
1981
+ ? "Pass --overwrite to replace it."
1982
+ : "Choose a new --out path or ask the user to run with FRAMEYARD_POLICY=full.",
1983
+ },
1984
+ ]
1985
+ : [];
1986
+ const prerequisites = collectPrerequisites(resolved.timeline);
1987
+ const args = ["render", targetId];
1988
+ if (outValue !== undefined)
1989
+ args.push("--out", outValue);
1990
+ const propsValue = flagString(parsed.flags, "props");
1991
+ if (propsValue !== undefined)
1992
+ args.push("--props", propsValue);
1993
+ args.push("--workers", String(planWorkers));
1994
+ if (encoder !== undefined)
1995
+ args.push("--encoder", encoder);
1996
+ args.push("--codec", codec, "--container", container);
1997
+ if (scratchValue !== undefined)
1998
+ args.push("--scratch", scratchValue);
1999
+ if (profile !== undefined)
2000
+ args.push("--profile", profile);
2001
+ if (parsed.flags.has("low-priority"))
2002
+ args.push("--low-priority");
2003
+ const prefix = policy.tier === "full" ? "" : "FRAMEYARD_POLICY=full ";
2004
+ const commandLine = prefix + handoffCommand(args);
2005
+ printDiagnostics(lintResult.diagnostics);
2006
+ if (json) {
2007
+ printJson({
2008
+ ok: true,
2009
+ command: "render",
2010
+ dryRun: true,
2011
+ deferred: true,
2012
+ policy: policyDetails(policy),
2013
+ ...(profile !== undefined ? { profile } : {}),
2014
+ plan: planDetails,
2015
+ commandLine,
2016
+ prerequisites,
2017
+ blockers,
2018
+ diagnostics: lintResult.diagnostics,
2019
+ output: { path: timelineOutPath, exists: outputExists },
2020
+ });
2021
+ }
2022
+ else {
2023
+ printDryRun("render", planDetails, commandLine, prerequisites, blockers);
2024
+ }
2025
+ return 0;
2026
+ }
2027
+ if (shouldLowerPriority(policy, profile, parsed.flags.has("low-priority") === true)) {
2028
+ lowerProcessPriority();
2029
+ }
790
2030
  const result = await renderTimeline({
791
2031
  project,
792
2032
  timelineId: targetId,
793
2033
  outPath: timelineOutPath,
794
- ...(workers !== undefined ? { workers } : {}),
2034
+ ...(effectiveWorkers !== undefined ? { workers: effectiveWorkers } : {}),
795
2035
  ...(scratchDir !== undefined ? { scratchDir } : {}),
796
2036
  ...(encoder !== undefined ? { encoder } : {}),
797
2037
  codec,
@@ -816,6 +2056,101 @@ async function runRender(parsed, signal) {
816
2056
  const compositionId = targetId;
817
2057
  const concurrencyValue = parsed.flags.get("concurrency");
818
2058
  const concurrency = typeof concurrencyValue === "string" ? validateRenderConcurrency(Number(concurrencyValue)) : undefined;
2059
+ let effectiveConcurrency = concurrency;
2060
+ if (profile === "shared-cpu") {
2061
+ if (concurrency !== undefined) {
2062
+ assertProfileLimit(profile, "concurrency", concurrency, SHARED_CPU_MAX_CONCURRENCY);
2063
+ }
2064
+ effectiveConcurrency = concurrency ?? SHARED_CPU_MAX_CONCURRENCY;
2065
+ }
2066
+ if (dryRun) {
2067
+ const lintResult = await lintComposition({ project, compositionId, props, signal });
2068
+ const metadata = lintResult.metadata;
2069
+ const staticTarget = metadata.static === true;
2070
+ if (staticTarget &&
2071
+ (parsed.flags.has("concurrency") ||
2072
+ parsed.flags.has("container") ||
2073
+ parsed.flags.has("codec") ||
2074
+ parsed.flags.has("encoder"))) {
2075
+ usage("A static composition render rejects --concurrency, --container, --codec, and --encoder.");
2076
+ }
2077
+ const compositionOutPath = explicitOutPath ??
2078
+ path.join(project.root, "out", staticTarget ? `${compositionId}.png` : `${compositionId}.${CONTAINER_EXTENSION[container]}`);
2079
+ const outputExists = await pathExists(compositionOutPath);
2080
+ const blockers = outputExists
2081
+ ? [
2082
+ {
2083
+ code: "OUTPUT_EXISTS",
2084
+ message: `Output already exists: ${compositionOutPath}.`,
2085
+ suggestion: policy.allowOverwrite
2086
+ ? "Pass --overwrite to replace it."
2087
+ : "Choose a new --out path or ask the user to run with FRAMEYARD_POLICY=full.",
2088
+ },
2089
+ ]
2090
+ : [];
2091
+ const planDetails = metadata.static === true
2092
+ ? {
2093
+ kind: "image",
2094
+ width: metadata.width,
2095
+ height: metadata.height,
2096
+ frames: 1,
2097
+ }
2098
+ : {
2099
+ kind: "video",
2100
+ frames: metadata.durationInFrames,
2101
+ durationSeconds: metadata.durationInFrames / metadata.fps,
2102
+ concurrency: effectiveConcurrency ?? 1,
2103
+ encoder: encoder ?? "auto",
2104
+ codec,
2105
+ container,
2106
+ probed: false,
2107
+ };
2108
+ const args = ["render", compositionId];
2109
+ if (outValue !== undefined)
2110
+ args.push("--out", outValue);
2111
+ const propsValue = flagString(parsed.flags, "props");
2112
+ if (propsValue !== undefined)
2113
+ args.push("--props", propsValue);
2114
+ if (!staticTarget) {
2115
+ args.push("--concurrency", String(effectiveConcurrency ?? 1));
2116
+ if (encoder !== undefined)
2117
+ args.push("--encoder", encoder);
2118
+ if (codecValue !== undefined)
2119
+ args.push("--codec", codecValue);
2120
+ if (containerValue !== undefined)
2121
+ args.push("--container", containerValue);
2122
+ }
2123
+ if (profile !== undefined)
2124
+ args.push("--profile", profile);
2125
+ if (parsed.flags.has("low-priority"))
2126
+ args.push("--low-priority");
2127
+ const prefix = policy.tier === "full" ? "" : "FRAMEYARD_POLICY=full ";
2128
+ const commandLine = prefix + handoffCommand(args);
2129
+ printDiagnostics(lintResult.diagnostics);
2130
+ if (json) {
2131
+ printJson({
2132
+ ok: true,
2133
+ command: "render",
2134
+ dryRun: true,
2135
+ deferred: true,
2136
+ policy: policyDetails(policy),
2137
+ ...(profile !== undefined ? { profile } : {}),
2138
+ plan: planDetails,
2139
+ commandLine,
2140
+ prerequisites: [],
2141
+ blockers,
2142
+ diagnostics: lintResult.diagnostics,
2143
+ output: { path: compositionOutPath, exists: outputExists },
2144
+ });
2145
+ }
2146
+ else {
2147
+ printDryRun("render", planDetails, commandLine, [], blockers);
2148
+ }
2149
+ return 0;
2150
+ }
2151
+ if (shouldLowerPriority(policy, profile, parsed.flags.has("low-priority") === true)) {
2152
+ lowerProcessPriority();
2153
+ }
819
2154
  const result = await renderComposition({
820
2155
  project,
821
2156
  compositionId,
@@ -841,15 +2176,22 @@ async function runRender(parsed, signal) {
841
2176
  }
842
2177
  return 0;
843
2178
  }
844
- async function runTts(parsed, signal) {
2179
+ async function runTts(parsed, signal, policy) {
2180
+ const dryRun = parsed.flags.has("dry-run") === true;
2181
+ const profile = resolveProfile(policy, flagString(parsed.flags, "profile"));
2182
+ if (!dryRun) {
2183
+ assertOperationAllowed(policy, "tts");
2184
+ }
2185
+ if (parsed.flags.has("force")) {
2186
+ assertOverwriteAllowed(policy, "force");
2187
+ }
845
2188
  const projectRoot = await assetRootFromFlag(parsed.flags);
846
2189
  const json = parsed.flags.has("json") === true;
2190
+ const engine = (flagString(parsed.flags, "engine") ?? "qwen");
847
2191
  const speaker = flagString(parsed.flags, "speaker");
848
- const talkerModel = flagString(parsed.flags, "talker-model");
849
- const codecModel = flagString(parsed.flags, "codec-model");
850
2192
  const outValue = flagString(parsed.flags, "out");
851
- if (!speaker || !talkerModel || !codecModel || !outValue) {
852
- usage('"tts" requires --speaker, --talker-model, --codec-model, and --out.');
2193
+ if (!speaker || !outValue) {
2194
+ usage('"tts" requires --speaker and --out.');
853
2195
  }
854
2196
  const textFile = flagString(parsed.flags, "text-file");
855
2197
  let text;
@@ -868,39 +2210,157 @@ async function runTts(parsed, signal) {
868
2210
  }
869
2211
  text = await readStdin();
870
2212
  }
871
- const result = await synthesizeSpeech({
872
- projectRoot,
873
- text,
874
- speaker,
875
- talkerModelPath: path.resolve(process.cwd(), talkerModel),
876
- codecModelPath: path.resolve(process.cwd(), codecModel),
877
- outPath: path.resolve(process.cwd(), outValue),
878
- ...(flagString(parsed.flags, "style") !== undefined ? { style: flagString(parsed.flags, "style") } : {}),
879
- ...(flagString(parsed.flags, "language") !== undefined
880
- ? { language: flagString(parsed.flags, "language") }
881
- : {}),
882
- ...(flagString(parsed.flags, "device") !== undefined
883
- ? { device: flagString(parsed.flags, "device") }
884
- : {}),
885
- ...(flagString(parsed.flags, "executable") !== undefined
886
- ? { executable: flagString(parsed.flags, "executable") }
887
- : {}),
888
- ...(flagString(parsed.flags, "manifest") !== undefined
889
- ? { manifestPath: flagString(parsed.flags, "manifest") }
890
- : {}),
891
- force: parsed.flags.has("force") === true,
892
- signal,
893
- });
2213
+ const outPath = path.resolve(process.cwd(), outValue);
2214
+ const force = parsed.flags.has("force") === true;
2215
+ const manifestPath = resolveManifestPath(projectRoot, flagString(parsed.flags, "manifest"));
2216
+ if (dryRun) {
2217
+ if (text.trim().length === 0) {
2218
+ throw new FrameyardError("TTS_FAILED", "Speech synthesis requires non-empty text.");
2219
+ }
2220
+ if (!outPath.toLowerCase().endsWith(".wav")) {
2221
+ throw new FrameyardError("TTS_FAILED", `Speech output must use a .wav extension: ${outPath}`);
2222
+ }
2223
+ const executableValue = flagString(parsed.flags, "executable");
2224
+ const prerequisites = engine === "supertonic"
2225
+ ? [
2226
+ {
2227
+ label: "Supertonic model directory",
2228
+ path: path.resolve(process.cwd(), flagString(parsed.flags, "model-dir")),
2229
+ directory: true,
2230
+ },
2231
+ ...(executableValue !== undefined ? [{ label: "TTS executable", path: executableValue }] : []),
2232
+ ]
2233
+ : [
2234
+ {
2235
+ label: "talker model",
2236
+ path: path.resolve(process.cwd(), flagString(parsed.flags, "talker-model")),
2237
+ },
2238
+ {
2239
+ label: "codec model",
2240
+ path: path.resolve(process.cwd(), flagString(parsed.flags, "codec-model")),
2241
+ },
2242
+ ...(executableValue !== undefined ? [{ label: "TTS executable", path: executableValue }] : []),
2243
+ ];
2244
+ const blockers = await prerequisiteBlockers(prerequisites);
2245
+ if (engine === "supertonic") {
2246
+ try {
2247
+ await resolveSupertonicModelFiles(path.resolve(process.cwd(), flagString(parsed.flags, "model-dir")));
2248
+ }
2249
+ catch (error) {
2250
+ blockers.push({
2251
+ code: isFrameyardError(error) ? error.code : "TTS_FAILED",
2252
+ message: errorMessage(error),
2253
+ suggestion: "Check --model-dir and the seven Supertonic model files.",
2254
+ });
2255
+ }
2256
+ }
2257
+ const outputExists = await pathExists(outPath);
2258
+ if (outputExists) {
2259
+ blockers.push({
2260
+ code: "OUTPUT_EXISTS",
2261
+ message: `Output already exists: ${outPath}.`,
2262
+ suggestion: policy.allowOverwrite
2263
+ ? "Pass --force to replace it."
2264
+ : "Choose a new --out path or ask the user to run with FRAMEYARD_POLICY=full.",
2265
+ });
2266
+ }
2267
+ const plan = {
2268
+ kind: "tts",
2269
+ engine,
2270
+ speaker,
2271
+ textCharacters: text.length,
2272
+ out: outPath,
2273
+ manifest: manifestPath,
2274
+ probed: false,
2275
+ };
2276
+ const args = process.argv.slice(2).filter((argument) => argument !== "--dry-run" && argument !== "--json");
2277
+ const prefix = policy.tier === "full" ? "" : "FRAMEYARD_POLICY=full ";
2278
+ const commandLine = prefix + handoffCommand(args);
2279
+ if (json) {
2280
+ printJson({
2281
+ ok: true,
2282
+ command: "tts",
2283
+ dryRun: true,
2284
+ deferred: true,
2285
+ policy: policyDetails(policy),
2286
+ ...(profile !== undefined ? { profile } : {}),
2287
+ plan,
2288
+ commandLine,
2289
+ prerequisites: prerequisites.map((item) => item.path),
2290
+ blockers,
2291
+ output: { path: outPath, exists: outputExists },
2292
+ });
2293
+ }
2294
+ else {
2295
+ printDryRun("tts", plan, commandLine, prerequisites.map((item) => item.path), blockers);
2296
+ }
2297
+ return 0;
2298
+ }
2299
+ if (shouldLowerPriority(policy, profile, parsed.flags.has("low-priority") === true)) {
2300
+ lowerProcessPriority();
2301
+ }
2302
+ const result = engine === "supertonic"
2303
+ ? await synthesizeSpeech({
2304
+ engine: "supertonic",
2305
+ projectRoot,
2306
+ text,
2307
+ speaker,
2308
+ modelDir: path.resolve(process.cwd(), flagString(parsed.flags, "model-dir")),
2309
+ language: flagString(parsed.flags, "language"),
2310
+ outPath,
2311
+ ...(flagString(parsed.flags, "executable") !== undefined
2312
+ ? { executable: flagString(parsed.flags, "executable") }
2313
+ : {}),
2314
+ ...(flagString(parsed.flags, "manifest") !== undefined
2315
+ ? { manifestPath: flagString(parsed.flags, "manifest") }
2316
+ : {}),
2317
+ force,
2318
+ signal,
2319
+ })
2320
+ : await synthesizeSpeech({
2321
+ engine: "qwen",
2322
+ projectRoot,
2323
+ text,
2324
+ speaker,
2325
+ talkerModelPath: path.resolve(process.cwd(), flagString(parsed.flags, "talker-model")),
2326
+ codecModelPath: path.resolve(process.cwd(), flagString(parsed.flags, "codec-model")),
2327
+ outPath,
2328
+ ...(flagString(parsed.flags, "style") !== undefined
2329
+ ? { style: flagString(parsed.flags, "style") }
2330
+ : {}),
2331
+ ...(flagString(parsed.flags, "language") !== undefined
2332
+ ? { language: flagString(parsed.flags, "language") }
2333
+ : {}),
2334
+ ...(flagString(parsed.flags, "device") !== undefined
2335
+ ? { device: flagString(parsed.flags, "device") }
2336
+ : {}),
2337
+ ...(flagString(parsed.flags, "executable") !== undefined
2338
+ ? { executable: flagString(parsed.flags, "executable") }
2339
+ : {}),
2340
+ ...(flagString(parsed.flags, "manifest") !== undefined
2341
+ ? { manifestPath: flagString(parsed.flags, "manifest") }
2342
+ : {}),
2343
+ force,
2344
+ signal,
2345
+ });
894
2346
  if (json) {
895
2347
  printJson({ ok: true, command: "tts", ...result });
896
2348
  }
897
2349
  else {
898
- process.stdout.write(`Wrote 24 kHz mono WAV to ${result.path} (${(result.bytes / (1024 * 1024)).toFixed(2)} MB)\n`);
2350
+ process.stdout.write(`Wrote 24 kHz mono WAV to ${result.path} (${(result.bytes / (1024 * 1024)).toFixed(2)} MB, ${result.engine})\n`);
899
2351
  process.stdout.write(`Updated provenance manifest ${result.manifest}\n`);
900
2352
  }
901
2353
  return 0;
902
2354
  }
903
- async function runTranscribe(parsed, signal) {
2355
+ async function runTranscribe(parsed, signal, policy) {
2356
+ const dryRun = parsed.flags.has("dry-run") === true;
2357
+ const profile = resolveProfile(policy, flagString(parsed.flags, "profile"));
2358
+ if (!dryRun) {
2359
+ assertOperationAllowed(policy, "transcribe");
2360
+ }
2361
+ if (parsed.flags.has("force")) {
2362
+ assertOverwriteAllowed(policy, "force");
2363
+ }
904
2364
  const projectRoot = await assetRootFromFlag(parsed.flags);
905
2365
  const json = parsed.flags.has("json") === true;
906
2366
  const outValue = flagString(parsed.flags, "out");
@@ -908,13 +2368,82 @@ async function runTranscribe(parsed, signal) {
908
2368
  if (!outValue || !modelValue) {
909
2369
  usage('"transcribe" requires --out and --model.');
910
2370
  }
2371
+ const outPath = path.resolve(process.cwd(), outValue);
2372
+ const sourcePath = path.resolve(process.cwd(), parsed.positionals[0]);
2373
+ const modelPath = path.resolve(process.cwd(), modelValue);
2374
+ const formats = parseSubtitleFormats(parsed.flags.get("export"));
2375
+ const exports = formats.map((format) => ({
2376
+ format,
2377
+ path: `${outPath.toLowerCase().endsWith(".json") ? outPath.slice(0, -5) : outPath}.${format}`,
2378
+ }));
2379
+ const force = parsed.flags.has("force") === true;
2380
+ const manifestPath = resolveManifestPath(projectRoot, flagString(parsed.flags, "manifest"));
2381
+ if (dryRun) {
2382
+ if (!outPath.toLowerCase().endsWith(".json")) {
2383
+ throw new FrameyardError("TRANSCRIPT_INVALID", `Transcript output must use a .json extension: ${outPath}`);
2384
+ }
2385
+ const executableValue = flagString(parsed.flags, "executable");
2386
+ const prerequisites = [
2387
+ { label: "transcription source", path: sourcePath },
2388
+ { label: "Whisper model", path: modelPath },
2389
+ ...(executableValue !== undefined ? [{ label: "transcribe executable", path: executableValue }] : []),
2390
+ ];
2391
+ const blockers = await prerequisiteBlockers(prerequisites);
2392
+ for (const destination of [outPath, ...exports.map((entry) => entry.path)]) {
2393
+ if (await pathExists(destination)) {
2394
+ blockers.push({
2395
+ code: "OUTPUT_EXISTS",
2396
+ message: `Output already exists: ${destination}.`,
2397
+ suggestion: policy.allowOverwrite
2398
+ ? "Pass --force to replace it."
2399
+ : "Choose a new --out path or ask the user to run with FRAMEYARD_POLICY=full.",
2400
+ });
2401
+ }
2402
+ }
2403
+ const plan = {
2404
+ kind: "transcribe",
2405
+ source: sourcePath,
2406
+ model: modelPath,
2407
+ words: parsed.flags.has("words") === true,
2408
+ exports: formats,
2409
+ out: outPath,
2410
+ manifest: manifestPath,
2411
+ probed: false,
2412
+ };
2413
+ const args = process.argv.slice(2).filter((argument) => argument !== "--dry-run" && argument !== "--json");
2414
+ const prefix = policy.tier === "full" ? "" : "FRAMEYARD_POLICY=full ";
2415
+ const commandLine = prefix + handoffCommand(args);
2416
+ if (json) {
2417
+ printJson({
2418
+ ok: true,
2419
+ command: "transcribe",
2420
+ dryRun: true,
2421
+ deferred: true,
2422
+ policy: policyDetails(policy),
2423
+ ...(profile !== undefined ? { profile } : {}),
2424
+ plan,
2425
+ commandLine,
2426
+ prerequisites: prerequisites.map((item) => item.path),
2427
+ blockers,
2428
+ output: { path: outPath, exists: await pathExists(outPath) },
2429
+ exports,
2430
+ });
2431
+ }
2432
+ else {
2433
+ printDryRun("transcribe", plan, commandLine, prerequisites.map((item) => item.path), blockers);
2434
+ }
2435
+ return 0;
2436
+ }
2437
+ if (shouldLowerPriority(policy, profile, parsed.flags.has("low-priority") === true)) {
2438
+ lowerProcessPriority();
2439
+ }
911
2440
  const result = await transcribeAudio({
912
2441
  projectRoot,
913
- sourcePath: path.resolve(process.cwd(), parsed.positionals[0]),
914
- outPath: path.resolve(process.cwd(), outValue),
915
- modelPath: path.resolve(process.cwd(), modelValue),
2442
+ sourcePath,
2443
+ outPath,
2444
+ modelPath,
916
2445
  words: parsed.flags.has("words") === true,
917
- exports: parseSubtitleFormats(parsed.flags.get("export")),
2446
+ exports: formats,
918
2447
  ...(flagString(parsed.flags, "language") !== undefined
919
2448
  ? { language: flagString(parsed.flags, "language") }
920
2449
  : {}),
@@ -924,7 +2453,7 @@ async function runTranscribe(parsed, signal) {
924
2453
  ...(flagString(parsed.flags, "manifest") !== undefined
925
2454
  ? { manifestPath: flagString(parsed.flags, "manifest") }
926
2455
  : {}),
927
- force: parsed.flags.has("force") === true,
2456
+ force,
928
2457
  signal,
929
2458
  });
930
2459
  if (json) {
@@ -940,6 +2469,19 @@ async function runTranscribe(parsed, signal) {
940
2469
  return 0;
941
2470
  }
942
2471
  async function runDoctorCommand(parsed, signal) {
2472
+ let policy;
2473
+ let policyError;
2474
+ try {
2475
+ policy = await loadResourcePolicy({ cwd: process.cwd(), env: process.env });
2476
+ }
2477
+ catch (error) {
2478
+ if (isFrameyardError(error) && error.code === "POLICY_INVALID") {
2479
+ policyError = error;
2480
+ }
2481
+ else {
2482
+ throw error;
2483
+ }
2484
+ }
943
2485
  const projectRoot = await assetRootFromFlag(parsed.flags);
944
2486
  const manifestPath = resolveManifestPath(projectRoot, flagString(parsed.flags, "manifest"));
945
2487
  const json = parsed.flags.has("json") === true;
@@ -949,6 +2491,9 @@ async function runDoctorCommand(parsed, signal) {
949
2491
  ...(flagString(parsed.flags, "qwen-tts") !== undefined
950
2492
  ? { qwenTts: flagString(parsed.flags, "qwen-tts") }
951
2493
  : {}),
2494
+ ...(flagString(parsed.flags, "sherpa-onnx-tts") !== undefined
2495
+ ? { sherpaOnnxTts: flagString(parsed.flags, "sherpa-onnx-tts") }
2496
+ : {}),
952
2497
  ...(flagString(parsed.flags, "whisper-cli") !== undefined
953
2498
  ? { whisperCli: flagString(parsed.flags, "whisper-cli") }
954
2499
  : {}),
@@ -958,13 +2503,32 @@ async function runDoctorCommand(parsed, signal) {
958
2503
  ...(flagString(parsed.flags, "codec-model") !== undefined
959
2504
  ? { codecModel: flagString(parsed.flags, "codec-model") }
960
2505
  : {}),
2506
+ ...(flagString(parsed.flags, "model-dir") !== undefined
2507
+ ? { supertonicModelDir: flagString(parsed.flags, "model-dir") }
2508
+ : {}),
961
2509
  ...(flagString(parsed.flags, "model") !== undefined
962
2510
  ? { whisperModel: flagString(parsed.flags, "model") }
963
2511
  : {}),
964
2512
  signal,
965
2513
  });
2514
+ const policyReport = policy !== undefined
2515
+ ? { status: "ok", ...policyDetails(policy) }
2516
+ : {
2517
+ status: "error",
2518
+ code: policyError?.code ?? "POLICY_INVALID",
2519
+ detail: policyError?.message ?? "Resource policy could not be loaded.",
2520
+ };
2521
+ const resources = {
2522
+ platform: process.platform,
2523
+ arch: process.arch,
2524
+ cpus: cpus().length,
2525
+ loadAverage: loadavg(),
2526
+ totalMemoryBytes: totalmem(),
2527
+ freeMemoryBytes: freemem(),
2528
+ };
2529
+ const report = { ...result, policy: policyReport, resources };
966
2530
  if (json) {
967
- printJson({ ok: true, command: "doctor", ...result });
2531
+ printJson({ ok: true, command: "doctor", ...report });
968
2532
  }
969
2533
  else {
970
2534
  for (const check of result.checks) {
@@ -972,7 +2536,11 @@ async function runDoctorCommand(parsed, signal) {
972
2536
  const suffix = check.required ? "" : " (optional)";
973
2537
  process.stdout.write(`${mark} ${check.name}${suffix}: ${check.detail}\n`);
974
2538
  }
975
- for (const integration of [result.integrations.tts, result.integrations.transcribe]) {
2539
+ for (const integration of [
2540
+ result.integrations.tts,
2541
+ result.integrations.transcribe,
2542
+ result.integrations.sherpaOnnxTts,
2543
+ ]) {
976
2544
  const mark = integration.status === "ok" ? "✓" : integration.status === "missing" && !integration.requested ? "○" : "✗";
977
2545
  const detail = integration.detail ? ` — ${integration.detail}` : "";
978
2546
  const version = integration.version ? ` ${integration.version}` : "";
@@ -983,13 +2551,23 @@ async function runDoctorCommand(parsed, signal) {
983
2551
  const detail = model.detail ? ` — ${model.detail}` : "";
984
2552
  process.stdout.write(`${mark} model ${model.role}: ${model.path}${detail}\n`);
985
2553
  }
2554
+ const fontDetail = result.fonts.detail ? ` — ${result.fonts.detail}` : "";
2555
+ process.stdout.write(`${result.fonts.valid ? "✓" : "✗"} fonts: ${result.fonts.exists ? `${result.fonts.fonts} installed, ${result.fonts.files} files, ${result.fonts.bytes} bytes` : "none"}${fontDetail}\n`);
2556
+ if (policyReport.status === "ok") {
2557
+ process.stdout.write(`✓ policy: ${policyReport.policy}${policyReport.profile ? `, ${policyReport.profile}` : ""}, still ≤${policyReport.maxStillFrames}, ${policyReport.allowOverwrite ? "overwrite allowed" : "overwrite blocked"} (${policyReport.source})\n`);
2558
+ }
2559
+ else {
2560
+ process.stdout.write(`✗ policy: ${policyReport.detail}\n`);
2561
+ }
2562
+ process.stdout.write(`resources: ${resources.cpus} cpus, load ${resources.loadAverage.map((value) => value.toFixed(2)).join(" ")}, ${(resources.totalMemoryBytes / 1024 ** 3).toFixed(1)} GB RAM\n`);
986
2563
  process.stdout.write(`\ndoctor: ${result.healthy ? "healthy" : "problems found"}\n`);
987
2564
  }
988
2565
  return result.healthy ? 0 : 1;
989
2566
  }
990
2567
  function reportFailure(error, command, jsonRequested, signal) {
991
2568
  const aborted = signal?.aborted === true;
992
- const code = aborted ? "CANCELLED" : isFrameyardError(error) ? error.code : "INTERNAL";
2569
+ const cancelled = aborted || (isFrameyardError(error) && error.code === "CANCELLED");
2570
+ const code = cancelled ? "CANCELLED" : isFrameyardError(error) ? error.code : "INTERNAL";
993
2571
  const message = aborted ? "Operation cancelled." : error instanceof Error ? error.message : String(error);
994
2572
  const details = isFrameyardError(error) ? error.details : undefined;
995
2573
  const diagnostics = isFrameyardError(error) ? error.diagnostics : undefined;
@@ -1005,14 +2583,14 @@ function reportFailure(error, command, jsonRequested, signal) {
1005
2583
  ...(diagnostics !== undefined ? { diagnostics } : {}),
1006
2584
  });
1007
2585
  }
1008
- process.stderr.write(`${aborted ? "Cancelled" : `error [${code}]`}${aborted ? "" : `: ${message}`}\n`);
2586
+ process.stderr.write(`${cancelled ? "Cancelled" : `error [${code}]`}${cancelled ? "" : `: ${message}`}\n`);
1009
2587
  if (diagnostics !== undefined) {
1010
2588
  printDiagnostics(diagnostics);
1011
2589
  }
1012
2590
  if (!aborted && !isFrameyardError(error) && process.env.FRAMEYARD_DEBUG === "1" && error instanceof Error) {
1013
2591
  process.stderr.write(`${error.stack ?? ""}\n`);
1014
2592
  }
1015
- if (aborted) {
2593
+ if (cancelled) {
1016
2594
  return 130;
1017
2595
  }
1018
2596
  return code === "USAGE" ? 2 : 1;
@@ -1077,17 +2655,31 @@ async function main(argv) {
1077
2655
  process.on("SIGINT", interrupt);
1078
2656
  process.on("SIGTERM", interrupt);
1079
2657
  try {
2658
+ const needsPolicy = !["doctor", "inspect", "compare", "add"].includes(parsed.command);
2659
+ const policy = needsPolicy ? await loadResourcePolicy({ cwd: process.cwd(), env: process.env }) : undefined;
1080
2660
  switch (parsed.command) {
2661
+ case "context":
2662
+ return await runContext(parsed, controller.signal, policy);
2663
+ case "lint":
2664
+ return await runLint(parsed, controller.signal, policy);
1081
2665
  case "check":
1082
- return await runCheck(parsed, controller.signal);
2666
+ return await runCheck(parsed, controller.signal, policy);
1083
2667
  case "still":
1084
- return await runStill(parsed, controller.signal);
2668
+ return await runStill(parsed, controller.signal, policy);
2669
+ case "inspect":
2670
+ return await runInspect(parsed);
2671
+ case "compare":
2672
+ return await runCompare(parsed);
1085
2673
  case "render":
1086
- return await runRender(parsed, controller.signal);
2674
+ return await runRender(parsed, controller.signal, policy);
1087
2675
  case "tts":
1088
- return await runTts(parsed, controller.signal);
2676
+ return await runTts(parsed, controller.signal, policy);
1089
2677
  case "transcribe":
1090
- return await runTranscribe(parsed, controller.signal);
2678
+ return await runTranscribe(parsed, controller.signal, policy);
2679
+ case "font":
2680
+ return await runFont(parsed, controller.signal, policy);
2681
+ case "add":
2682
+ return await runAdd(parsed);
1091
2683
  case "doctor":
1092
2684
  return await runDoctorCommand(parsed, controller.signal);
1093
2685
  default: