frameyard 0.7.0 → 0.9.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 (334) hide show
  1. package/CHANGELOG.md +164 -7
  2. package/GLOSSARY.md +152 -18
  3. package/README.md +328 -285
  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 +1705 -106
  21. package/dist/cli.js.map +1 -1
  22. package/dist/composition/descriptor.d.ts +17 -3
  23. package/dist/composition/descriptor.d.ts.map +1 -1
  24. package/dist/composition/descriptor.js +51 -28
  25. package/dist/composition/descriptor.js.map +1 -1
  26. package/dist/composition/format.d.ts +12 -0
  27. package/dist/composition/format.d.ts.map +1 -0
  28. package/dist/composition/format.js +28 -0
  29. package/dist/composition/format.js.map +1 -0
  30. package/dist/composition/index.d.ts +2 -1
  31. package/dist/composition/index.d.ts.map +1 -1
  32. package/dist/composition/index.js +1 -0
  33. package/dist/composition/index.js.map +1 -1
  34. package/dist/context.d.ts +82 -0
  35. package/dist/context.d.ts.map +1 -0
  36. package/dist/context.js +277 -0
  37. package/dist/context.js.map +1 -0
  38. package/dist/contracts.d.ts +80 -6
  39. package/dist/contracts.d.ts.map +1 -1
  40. package/dist/contracts.js +3 -0
  41. package/dist/contracts.js.map +1 -1
  42. package/dist/doctor.d.ts +14 -1
  43. package/dist/doctor.d.ts.map +1 -1
  44. package/dist/doctor.js +166 -7
  45. package/dist/doctor.js.map +1 -1
  46. package/dist/errors.d.ts +1 -1
  47. package/dist/errors.d.ts.map +1 -1
  48. package/dist/errors.js.map +1 -1
  49. package/dist/fonts/add.d.ts +41 -0
  50. package/dist/fonts/add.d.ts.map +1 -0
  51. package/dist/fonts/add.js +259 -0
  52. package/dist/fonts/add.js.map +1 -0
  53. package/dist/fonts/binary.d.ts +4 -0
  54. package/dist/fonts/binary.d.ts.map +1 -0
  55. package/dist/fonts/binary.js +21 -0
  56. package/dist/fonts/binary.js.map +1 -0
  57. package/dist/fonts/css.d.ts +4 -0
  58. package/dist/fonts/css.d.ts.map +1 -0
  59. package/dist/fonts/css.js +50 -0
  60. package/dist/fonts/css.js.map +1 -0
  61. package/dist/fonts/fontsource.d.ts +107 -0
  62. package/dist/fonts/fontsource.d.ts.map +1 -0
  63. package/dist/fonts/fontsource.js +565 -0
  64. package/dist/fonts/fontsource.js.map +1 -0
  65. package/dist/fonts/index.d.ts +11 -0
  66. package/dist/fonts/index.d.ts.map +1 -0
  67. package/dist/fonts/index.js +11 -0
  68. package/dist/fonts/index.js.map +1 -0
  69. package/dist/fonts/install.d.ts +24 -0
  70. package/dist/fonts/install.d.ts.map +1 -0
  71. package/dist/fonts/install.js +145 -0
  72. package/dist/fonts/install.js.map +1 -0
  73. package/dist/fonts/manifest.d.ts +21 -0
  74. package/dist/fonts/manifest.d.ts.map +1 -0
  75. package/dist/fonts/manifest.js +96 -0
  76. package/dist/fonts/manifest.js.map +1 -0
  77. package/dist/fonts/options.d.ts +9 -0
  78. package/dist/fonts/options.d.ts.map +1 -0
  79. package/dist/fonts/options.js +66 -0
  80. package/dist/fonts/options.js.map +1 -0
  81. package/dist/fonts/schema.d.ts +4 -0
  82. package/dist/fonts/schema.d.ts.map +1 -0
  83. package/dist/fonts/schema.js +316 -0
  84. package/dist/fonts/schema.js.map +1 -0
  85. package/dist/fonts/summary.d.ts +16 -0
  86. package/dist/fonts/summary.d.ts.map +1 -0
  87. package/dist/fonts/summary.js +22 -0
  88. package/dist/fonts/summary.js.map +1 -0
  89. package/dist/fonts/types.d.ts +56 -0
  90. package/dist/fonts/types.d.ts.map +1 -0
  91. package/dist/fonts/types.js +8 -0
  92. package/dist/fonts/types.js.map +1 -0
  93. package/dist/fonts/verify.d.ts +33 -0
  94. package/dist/fonts/verify.d.ts.map +1 -0
  95. package/dist/fonts/verify.js +200 -0
  96. package/dist/fonts/verify.js.map +1 -0
  97. package/dist/html/document-adapter.d.ts +3 -3
  98. package/dist/html/document-adapter.d.ts.map +1 -1
  99. package/dist/html/document-adapter.js +1 -1
  100. package/dist/html/document-adapter.js.map +1 -1
  101. package/dist/html/index.d.ts +1 -1
  102. package/dist/html/index.d.ts.map +1 -1
  103. package/dist/html/parse.d.ts +10 -2
  104. package/dist/html/parse.d.ts.map +1 -1
  105. package/dist/html/parse.js +14 -5
  106. package/dist/html/parse.js.map +1 -1
  107. package/dist/html/validate.d.ts +12 -1
  108. package/dist/html/validate.d.ts.map +1 -1
  109. package/dist/html/validate.js +292 -61
  110. package/dist/html/validate.js.map +1 -1
  111. package/dist/index.d.ts +4 -4
  112. package/dist/index.d.ts.map +1 -1
  113. package/dist/index.js +2 -2
  114. package/dist/index.js.map +1 -1
  115. package/dist/inspect.d.ts +61 -0
  116. package/dist/inspect.d.ts.map +1 -0
  117. package/dist/inspect.js +325 -0
  118. package/dist/inspect.js.map +1 -0
  119. package/dist/lint.d.ts +63 -0
  120. package/dist/lint.d.ts.map +1 -0
  121. package/dist/lint.js +265 -0
  122. package/dist/lint.js.map +1 -0
  123. package/dist/policy.d.ts +69 -0
  124. package/dist/policy.d.ts.map +1 -0
  125. package/dist/policy.js +361 -0
  126. package/dist/policy.js.map +1 -0
  127. package/dist/preview/main.d.ts +2 -2
  128. package/dist/preview/main.d.ts.map +1 -1
  129. package/dist/preview/main.js +35 -14
  130. package/dist/preview/main.js.map +1 -1
  131. package/dist/preview/timeline-data.d.ts +17 -65
  132. package/dist/preview/timeline-data.d.ts.map +1 -1
  133. package/dist/preview/timeline-host.d.ts.map +1 -1
  134. package/dist/preview/timeline-host.js +73 -38
  135. package/dist/preview/timeline-host.js.map +1 -1
  136. package/dist/preview/timeline-main.d.ts.map +1 -1
  137. package/dist/preview/timeline-main.js +47 -3
  138. package/dist/preview/timeline-main.js.map +1 -1
  139. package/dist/project.d.ts +13 -8
  140. package/dist/project.d.ts.map +1 -1
  141. package/dist/project.js +20 -17
  142. package/dist/project.js.map +1 -1
  143. package/dist/renderer/browser.d.ts +2 -2
  144. package/dist/renderer/browser.d.ts.map +1 -1
  145. package/dist/renderer/browser.js +10 -4
  146. package/dist/renderer/browser.js.map +1 -1
  147. package/dist/renderer/ffmpeg.d.ts +44 -1
  148. package/dist/renderer/ffmpeg.d.ts.map +1 -1
  149. package/dist/renderer/ffmpeg.js +371 -73
  150. package/dist/renderer/ffmpeg.js.map +1 -1
  151. package/dist/renderer/index.d.ts +40 -4
  152. package/dist/renderer/index.d.ts.map +1 -1
  153. package/dist/renderer/index.js +100 -25
  154. package/dist/renderer/index.js.map +1 -1
  155. package/dist/renderer/keyframe-expressions.d.ts +39 -0
  156. package/dist/renderer/keyframe-expressions.d.ts.map +1 -0
  157. package/dist/renderer/keyframe-expressions.js +180 -0
  158. package/dist/renderer/keyframe-expressions.js.map +1 -0
  159. package/dist/renderer/timeline.d.ts +2 -0
  160. package/dist/renderer/timeline.d.ts.map +1 -1
  161. package/dist/renderer/timeline.js +168 -106
  162. package/dist/renderer/timeline.js.map +1 -1
  163. package/dist/runtime/host.d.ts.map +1 -1
  164. package/dist/runtime/host.js +159 -31
  165. package/dist/runtime/host.js.map +1 -1
  166. package/dist/scaffold.d.ts +3 -0
  167. package/dist/scaffold.d.ts.map +1 -1
  168. package/dist/scaffold.js +48 -317
  169. package/dist/scaffold.js.map +1 -1
  170. package/dist/server/composition-html.d.ts +15 -7
  171. package/dist/server/composition-html.d.ts.map +1 -1
  172. package/dist/server/composition-html.js +72 -16
  173. package/dist/server/composition-html.js.map +1 -1
  174. package/dist/server/server.d.ts.map +1 -1
  175. package/dist/server/server.js +56 -6
  176. package/dist/server/server.js.map +1 -1
  177. package/dist/server/timeline-preview-data.d.ts.map +1 -1
  178. package/dist/server/timeline-preview-data.js +12 -23
  179. package/dist/server/timeline-preview-data.js.map +1 -1
  180. package/dist/server/timeline-preview-html.d.ts.map +1 -1
  181. package/dist/server/timeline-preview-html.js +20 -3
  182. package/dist/server/timeline-preview-html.js.map +1 -1
  183. package/dist/templates.d.ts +16 -0
  184. package/dist/templates.d.ts.map +1 -0
  185. package/dist/templates.js +186 -0
  186. package/dist/templates.js.map +1 -0
  187. package/dist/timeline/easing.d.ts +36 -0
  188. package/dist/timeline/easing.d.ts.map +1 -0
  189. package/dist/timeline/easing.js +225 -0
  190. package/dist/timeline/easing.js.map +1 -0
  191. package/dist/timeline/keyframes.d.ts +23 -0
  192. package/dist/timeline/keyframes.d.ts.map +1 -0
  193. package/dist/timeline/keyframes.js +95 -0
  194. package/dist/timeline/keyframes.js.map +1 -0
  195. package/dist/timeline/plan.d.ts +6 -12
  196. package/dist/timeline/plan.d.ts.map +1 -1
  197. package/dist/timeline/plan.js +17 -30
  198. package/dist/timeline/plan.js.map +1 -1
  199. package/dist/timeline/resolve.d.ts +16 -3
  200. package/dist/timeline/resolve.d.ts.map +1 -1
  201. package/dist/timeline/resolve.js +119 -13
  202. package/dist/timeline/resolve.js.map +1 -1
  203. package/dist/timeline/scene.d.ts +126 -0
  204. package/dist/timeline/scene.d.ts.map +1 -0
  205. package/dist/timeline/scene.js +109 -0
  206. package/dist/timeline/scene.js.map +1 -0
  207. package/dist/timeline/schema.d.ts +38 -0
  208. package/dist/timeline/schema.d.ts.map +1 -1
  209. package/dist/timeline/schema.js +290 -9
  210. package/dist/timeline/schema.js.map +1 -1
  211. package/dist/timeline/transitions.d.ts +21 -0
  212. package/dist/timeline/transitions.d.ts.map +1 -0
  213. package/dist/timeline/transitions.js +23 -0
  214. package/dist/timeline/transitions.js.map +1 -0
  215. package/dist/version.d.ts +1 -1
  216. package/dist/version.js +1 -1
  217. package/docs/adr/0007-make-html-documents-the-composition-unit.md +2 -2
  218. package/docs/adr/0008-share-the-composition-document-grammar.md +1 -1
  219. package/docs/adr/0010-make-timelines-the-render-unit.md +3 -2
  220. package/docs/adr/0011-compose-timeline-frames-in-a-live-browser-host.md +1 -1
  221. package/docs/adr/0012-render-timelines-with-segmented-lossless-clip-intermediates.md +1 -1
  222. package/docs/adr/0014-compose-timeline-clips-with-css-blend-modes.md +38 -0
  223. package/docs/adr/0015-inherit-composition-format-from-the-project.md +28 -0
  224. package/docs/adr/0016-author-compositions-as-html-fragments.md +32 -0
  225. package/docs/adr/0017-render-timeless-compositions-as-stills.md +99 -0
  226. package/docs/adr/0018-animate-timeline-clips-with-keyframes.md +35 -0
  227. package/docs/adr/0019-make-same-track-overlap-the-transition-window.md +36 -0
  228. package/docs/adr/0020-crossfade-audio-clips-on-audio-tracks.md +27 -0
  229. package/docs/adr/0021-project-timeline-scenes-once.md +32 -0
  230. package/docs/adr/0022-gate-heavy-operations-with-a-workspace-resource-policy.md +35 -0
  231. package/docs/adr/0023-pin-fonts-as-project-assets.md +98 -0
  232. package/docs/migration-0.4.md +5 -3
  233. package/docs/migration-0.8.md +163 -0
  234. package/docs/preview-testing.md +23 -3
  235. package/docs/{v0.1-design.md → v0.x-design.md} +86 -53
  236. package/examples/blend/README.md +106 -0
  237. package/examples/blend/assets/ink.mp4 +0 -0
  238. package/examples/blend/compositions/aurora.html +55 -0
  239. package/examples/blend/compositions/backdrop.html +74 -0
  240. package/examples/blend/compositions/labels.html +149 -0
  241. package/examples/blend/fy.config.js +11 -0
  242. package/examples/blend/scripts/make-ink.sh +26 -0
  243. package/examples/blend/timelines/blend.timeline.json +155 -0
  244. package/examples/demo/README.md +2 -2
  245. package/examples/demo/compositions/demo.html +243 -251
  246. package/examples/intro/README.md +71 -0
  247. package/examples/intro/assets/fonts/figtree/LICENSE.txt +93 -0
  248. package/examples/intro/assets/fonts/figtree/figtree-latin-wght-normal.woff2 +0 -0
  249. package/examples/intro/assets/fonts/instrument-serif/LICENSE.txt +93 -0
  250. package/examples/intro/assets/fonts/instrument-serif/instrument-serif-latin-400-italic.woff2 +0 -0
  251. package/examples/intro/assets/fonts/space-mono/LICENSE.txt +93 -0
  252. package/examples/intro/assets/fonts/space-mono/space-mono-latin-400-normal.woff2 +0 -0
  253. package/examples/intro/assets/fonts/space-mono/space-mono-latin-700-normal.woff2 +0 -0
  254. package/examples/intro/assets/grain.png +0 -0
  255. package/examples/intro/assets/score.ogg +0 -0
  256. package/examples/intro/compositions/brand.html +412 -0
  257. package/examples/intro/compositions/chrome.controller.js +63 -0
  258. package/examples/intro/compositions/chrome.html +256 -0
  259. package/examples/intro/compositions/lattice.controller.js +148 -0
  260. package/examples/intro/compositions/lattice.html +308 -0
  261. package/examples/intro/compositions/opening.html +504 -0
  262. package/examples/intro/compositions/studio.html +930 -0
  263. package/examples/intro/compositions/theme.css +218 -0
  264. package/examples/intro/fy.config.js +13 -0
  265. package/examples/intro/fy.fonts.json +100 -0
  266. package/examples/intro/scripts/make-grain.mjs +79 -0
  267. package/examples/intro/scripts/make-score.mjs +702 -0
  268. package/examples/intro/timelines/intro.timeline.json +60 -0
  269. package/examples/keyframes/README.md +76 -0
  270. package/examples/keyframes/compositions/backdrop.html +60 -0
  271. package/examples/keyframes/compositions/card.html +99 -0
  272. package/examples/keyframes/fy.config.js +10 -0
  273. package/examples/keyframes/timelines/keyframes.timeline.json +60 -0
  274. package/examples/nested/README.md +112 -0
  275. package/examples/nested/assets/pulse.wav +0 -0
  276. package/examples/nested/compositions/backdrop.html +84 -0
  277. package/examples/nested/compositions/card.html +275 -0
  278. package/examples/nested/compositions/overlay.html +307 -0
  279. package/examples/nested/fy.config.js +13 -0
  280. package/examples/nested/scripts/make-pulse.sh +20 -0
  281. package/examples/nested/timelines/chapter-a.timeline.json +39 -0
  282. package/examples/nested/timelines/chapter-b.timeline.json +39 -0
  283. package/examples/nested/timelines/nested-reel.timeline.json +52 -0
  284. package/examples/static/README.md +17 -0
  285. package/examples/static/compositions/still.html +37 -0
  286. package/examples/static/fy.config.js +9 -0
  287. package/examples/static/timelines/still.timeline.json +13 -0
  288. package/examples/transitions/README.md +128 -0
  289. package/examples/transitions/assets/alpha.wav +0 -0
  290. package/examples/transitions/assets/beta.wav +0 -0
  291. package/examples/transitions/assets/gamma.wav +0 -0
  292. package/examples/transitions/compositions/alpha.html +56 -0
  293. package/examples/transitions/compositions/beta.html +56 -0
  294. package/examples/transitions/compositions/gamma.html +56 -0
  295. package/examples/transitions/fy.config.js +12 -0
  296. package/examples/transitions/timelines/audio-crossfade.timeline.json +44 -0
  297. package/examples/transitions/timelines/transitions.timeline.json +31 -0
  298. package/examples/video/README.md +4 -4
  299. package/examples/video/compositions/video-mp4.html +349 -357
  300. package/examples/video/compositions/video-overlay.html +337 -345
  301. package/examples/video/compositions/video.html +349 -357
  302. package/examples/webgl/README.md +1 -1
  303. package/examples/webgl/compositions/webgl.html +205 -213
  304. package/package.json +13 -5
  305. package/scaffolds/init/AGENTS.md +131 -0
  306. package/scaffolds/init/README.md +56 -0
  307. package/scaffolds/init/compositions/hello.html +79 -0
  308. package/scaffolds/init/fy.config.js +9 -0
  309. package/scaffolds/init/gitignore.template +1 -0
  310. package/scaffolds/init/package.json +11 -0
  311. package/scaffolds/init/props/hello.json +4 -0
  312. package/scaffolds/init/props/main.json +6 -0
  313. package/scaffolds/init/timelines/main.timeline.json +20 -0
  314. package/scaffolds/local-models/LOCAL-MODELS.md +77 -0
  315. package/scaffolds/local-models/fy.policy.json +5 -0
  316. package/scaffolds/local-models/opencode/opencode.jsonc +17 -0
  317. package/scaffolds/local-models/pi/APPEND_SYSTEM.md +21 -0
  318. package/scaffolds/local-models/pi/sandbox.json +15 -0
  319. package/scaffolds/local-models/pi/settings.json +15 -0
  320. package/skills/frameyard/README.md +1 -0
  321. package/skills/frameyard/SKILL.md +59 -59
  322. package/skills/frameyard/references/audio-assets.md +32 -3
  323. package/skills/frameyard/references/common-mistakes.md +81 -7
  324. package/skills/frameyard/references/controllers.md +5 -4
  325. package/skills/frameyard/references/deterministic-authoring.md +49 -37
  326. package/skills/frameyard/references/diagnostics.md +42 -4
  327. package/skills/frameyard/references/failure-recovery.md +56 -10
  328. package/skills/frameyard/references/fonts.md +132 -0
  329. package/skills/frameyard/references/json-results.md +165 -10
  330. package/skills/frameyard/references/quick-reference.md +222 -0
  331. package/skills/frameyard/references/timelines.md +109 -15
  332. /package/examples/demo/{video.config.js → fy.config.js} +0 -0
  333. /package/examples/video/{video.config.js → fy.config.js} +0 -0
  334. /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,81 +600,140 @@ 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 <video>] [--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.
248
628
  --props <file> Composition props, or a { "<clip-id>": {...} } override map for a timeline.
249
- --out <path> Output path for still, render, tts, or transcribe. For still, one frame uses a .png file and multiple frames use a directory. Render defaults to out/<id>.mp4 in the project.
629
+ --out <path> Output path for still, render, tts, or transcribe. For still, one frame uses a .png file and multiple frames use a directory. Render defaults to out/<id>.mp4 for video and out/<id>.png for a static composition.
250
630
  --frame <spec> Still frames: n, n-m, or a comma-separated list (maximum 32 unique frames).
251
- --concurrency <n> Parallel browser sessions for composition render, 1-${MAX_RENDER_CONCURRENCY} (default: 1).
631
+ --concurrency <n> Parallel browser sessions for animated composition render, 1-${MAX_RENDER_CONCURRENCY} (default: 1).
252
632
  --workers <n> Parallel segment workers for timeline render, 1-${MAX_TIMELINE_WORKERS} (default: 8).
253
633
  --encoder <name> Encoder: auto, software, nvenc, or qsv (default: auto).
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.\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 <video>] [--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. The default output is out/<id>.<extension> in the project. 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.\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
+ }
523
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
+ }
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`);
@@ -559,6 +1678,9 @@ async function runCheck(parsed, signal) {
559
1678
  if (json) {
560
1679
  printJson({ ok: true, command: "check", ...result });
561
1680
  }
1681
+ else if (result.kind === "static") {
1682
+ process.stdout.write(`✓ ${compositionId}: static ${result.metadata.width}×${result.metadata.height}\n`);
1683
+ }
562
1684
  else {
563
1685
  const meta = result.metadata;
564
1686
  process.stdout.write(`✓ ${compositionId}: ${result.framesChecked} frames, ${meta.width}×${meta.height} @ ${meta.fps} fps (${formatDuration(meta.durationInFrames / meta.fps)})\n`);
@@ -695,8 +1817,17 @@ function printStillResult(result, targetId, timeline, json) {
695
1817
  process.stdout.write(`Wrote ${result.frames.length} frames of ${target} to ${directory} (${result.width}×${result.height})\n`);
696
1818
  }
697
1819
  }
698
- 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
+ }
699
1829
  const project = await projectFromFlag(parsed.flags);
1830
+ await assertFontIntegrity(project.root, project.fonts, { hashes: true });
700
1831
  const props = await readPropsFile(parsed.flags);
701
1832
  const targetId = parsed.positionals[0];
702
1833
  const frameValue = parsed.flags.get("frame");
@@ -704,6 +1835,7 @@ async function runStill(parsed, signal) {
704
1835
  usage('"still" requires --frame <spec>.');
705
1836
  }
706
1837
  const frames = parseFrameSpec(frameValue);
1838
+ assertStillFrameLimit(policy, frames.length);
707
1839
  const outputPlan = await resolveStillOutputs({
708
1840
  targetId,
709
1841
  frames,
@@ -748,13 +1880,24 @@ async function runStill(parsed, signal) {
748
1880
  throw error;
749
1881
  }
750
1882
  }
751
- 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
+ }
752
1892
  const project = await projectFromFlag(parsed.flags);
1893
+ await assertFontIntegrity(project.root, project.fonts, { hashes: true });
753
1894
  const props = await readPropsFile(parsed.flags);
754
1895
  const encoder = encoderChoice(parsed.flags);
755
- const container = containerChoice(parsed.flags) ?? "mp4";
1896
+ const containerValue = containerChoice(parsed.flags);
1897
+ const container = containerValue ?? "mp4";
756
1898
  // WebM is a VP9/Opus container; every other container defaults to H.264.
757
- const codec = codecChoice(parsed.flags) ?? defaultCodecForContainer(container);
1899
+ const codecValue = codecChoice(parsed.flags);
1900
+ const codec = codecValue ?? defaultCodecForContainer(container);
758
1901
  assertContainerCodec(container, codec);
759
1902
  if (encoder !== undefined && encoder !== "auto") {
760
1903
  // Rejects codec/vendor mismatches such as --codec hevc --encoder libx264.
@@ -762,9 +1905,8 @@ async function runRender(parsed, signal) {
762
1905
  }
763
1906
  const targetId = parsed.positionals[0];
764
1907
  const outValue = flagString(parsed.flags, "out");
765
- const outPath = outValue === undefined
766
- ? path.join(project.root, "out", `${targetId}.${CONTAINER_EXTENSION[container]}`)
767
- : path.resolve(process.cwd(), outValue);
1908
+ const explicitOutPath = outValue === undefined ? undefined : path.resolve(process.cwd(), outValue);
1909
+ const timelineOutPath = explicitOutPath ?? path.join(project.root, "out", `${targetId}.${CONTAINER_EXTENSION[container]}`);
768
1910
  const json = parsed.flags.has("json") === true;
769
1911
  if (project.timelines.has(targetId)) {
770
1912
  if (parsed.flags.has("concurrency")) {
@@ -783,11 +1925,113 @@ async function runRender(parsed, signal) {
783
1925
  }
784
1926
  const scratchValue = flagString(parsed.flags, "scratch");
785
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
+ }
786
2030
  const result = await renderTimeline({
787
2031
  project,
788
2032
  timelineId: targetId,
789
- outPath,
790
- ...(workers !== undefined ? { workers } : {}),
2033
+ outPath: timelineOutPath,
2034
+ ...(effectiveWorkers !== undefined ? { workers: effectiveWorkers } : {}),
791
2035
  ...(scratchDir !== undefined ? { scratchDir } : {}),
792
2036
  ...(encoder !== undefined ? { encoder } : {}),
793
2037
  codec,
@@ -811,16 +2055,111 @@ async function runRender(parsed, signal) {
811
2055
  }
812
2056
  const compositionId = targetId;
813
2057
  const concurrencyValue = parsed.flags.get("concurrency");
814
- const concurrency = validateRenderConcurrency(typeof concurrencyValue === "string" ? Number(concurrencyValue) : undefined);
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
+ }
815
2154
  const result = await renderComposition({
816
2155
  project,
817
2156
  compositionId,
818
2157
  props,
819
- outPath,
820
- concurrency,
2158
+ ...(explicitOutPath !== undefined ? { outPath: explicitOutPath } : {}),
2159
+ ...(concurrency !== undefined ? { concurrency } : {}),
821
2160
  ...(encoder !== undefined ? { encoder } : {}),
822
- codec,
823
- container,
2161
+ ...(codecValue !== undefined ? { codec: codecValue } : {}),
2162
+ ...(containerValue !== undefined ? { container: containerValue } : {}),
824
2163
  overwrite: parsed.flags.has("overwrite") === true,
825
2164
  signal,
826
2165
  onProgress: progressPrinter("Rendering"),
@@ -829,20 +2168,30 @@ async function runRender(parsed, signal) {
829
2168
  if (json) {
830
2169
  printJson({ ok: true, command: "render", ...result });
831
2170
  }
2171
+ else if (result.kind === "image") {
2172
+ process.stdout.write(`Rendered ${compositionId} to ${result.path} (${result.width}×${result.height}, ${result.bytes} bytes)\n`);
2173
+ }
832
2174
  else {
833
2175
  process.stdout.write(`Rendered ${compositionId} to ${result.path} (${result.framesRendered} frames, ${formatDuration(result.durationSeconds)}, ${result.audio.clips} audio clip${result.audio.clips === 1 ? "" : "s"}, ${result.encoder} encoder)\n`);
834
2176
  }
835
2177
  return 0;
836
2178
  }
837
- 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
+ }
838
2188
  const projectRoot = await assetRootFromFlag(parsed.flags);
839
2189
  const json = parsed.flags.has("json") === true;
2190
+ const engine = (flagString(parsed.flags, "engine") ?? "qwen");
840
2191
  const speaker = flagString(parsed.flags, "speaker");
841
- const talkerModel = flagString(parsed.flags, "talker-model");
842
- const codecModel = flagString(parsed.flags, "codec-model");
843
2192
  const outValue = flagString(parsed.flags, "out");
844
- if (!speaker || !talkerModel || !codecModel || !outValue) {
845
- usage('"tts" requires --speaker, --talker-model, --codec-model, and --out.');
2193
+ if (!speaker || !outValue) {
2194
+ usage('"tts" requires --speaker and --out.');
846
2195
  }
847
2196
  const textFile = flagString(parsed.flags, "text-file");
848
2197
  let text;
@@ -861,39 +2210,157 @@ async function runTts(parsed, signal) {
861
2210
  }
862
2211
  text = await readStdin();
863
2212
  }
864
- const result = await synthesizeSpeech({
865
- projectRoot,
866
- text,
867
- speaker,
868
- talkerModelPath: path.resolve(process.cwd(), talkerModel),
869
- codecModelPath: path.resolve(process.cwd(), codecModel),
870
- outPath: path.resolve(process.cwd(), outValue),
871
- ...(flagString(parsed.flags, "style") !== undefined ? { style: flagString(parsed.flags, "style") } : {}),
872
- ...(flagString(parsed.flags, "language") !== undefined
873
- ? { language: flagString(parsed.flags, "language") }
874
- : {}),
875
- ...(flagString(parsed.flags, "device") !== undefined
876
- ? { device: flagString(parsed.flags, "device") }
877
- : {}),
878
- ...(flagString(parsed.flags, "executable") !== undefined
879
- ? { executable: flagString(parsed.flags, "executable") }
880
- : {}),
881
- ...(flagString(parsed.flags, "manifest") !== undefined
882
- ? { manifestPath: flagString(parsed.flags, "manifest") }
883
- : {}),
884
- force: parsed.flags.has("force") === true,
885
- signal,
886
- });
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
+ });
887
2346
  if (json) {
888
2347
  printJson({ ok: true, command: "tts", ...result });
889
2348
  }
890
2349
  else {
891
- 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`);
892
2351
  process.stdout.write(`Updated provenance manifest ${result.manifest}\n`);
893
2352
  }
894
2353
  return 0;
895
2354
  }
896
- 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
+ }
897
2364
  const projectRoot = await assetRootFromFlag(parsed.flags);
898
2365
  const json = parsed.flags.has("json") === true;
899
2366
  const outValue = flagString(parsed.flags, "out");
@@ -901,13 +2368,82 @@ async function runTranscribe(parsed, signal) {
901
2368
  if (!outValue || !modelValue) {
902
2369
  usage('"transcribe" requires --out and --model.');
903
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
+ }
904
2440
  const result = await transcribeAudio({
905
2441
  projectRoot,
906
- sourcePath: path.resolve(process.cwd(), parsed.positionals[0]),
907
- outPath: path.resolve(process.cwd(), outValue),
908
- modelPath: path.resolve(process.cwd(), modelValue),
2442
+ sourcePath,
2443
+ outPath,
2444
+ modelPath,
909
2445
  words: parsed.flags.has("words") === true,
910
- exports: parseSubtitleFormats(parsed.flags.get("export")),
2446
+ exports: formats,
911
2447
  ...(flagString(parsed.flags, "language") !== undefined
912
2448
  ? { language: flagString(parsed.flags, "language") }
913
2449
  : {}),
@@ -917,7 +2453,7 @@ async function runTranscribe(parsed, signal) {
917
2453
  ...(flagString(parsed.flags, "manifest") !== undefined
918
2454
  ? { manifestPath: flagString(parsed.flags, "manifest") }
919
2455
  : {}),
920
- force: parsed.flags.has("force") === true,
2456
+ force,
921
2457
  signal,
922
2458
  });
923
2459
  if (json) {
@@ -933,6 +2469,19 @@ async function runTranscribe(parsed, signal) {
933
2469
  return 0;
934
2470
  }
935
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
+ }
936
2485
  const projectRoot = await assetRootFromFlag(parsed.flags);
937
2486
  const manifestPath = resolveManifestPath(projectRoot, flagString(parsed.flags, "manifest"));
938
2487
  const json = parsed.flags.has("json") === true;
@@ -942,6 +2491,9 @@ async function runDoctorCommand(parsed, signal) {
942
2491
  ...(flagString(parsed.flags, "qwen-tts") !== undefined
943
2492
  ? { qwenTts: flagString(parsed.flags, "qwen-tts") }
944
2493
  : {}),
2494
+ ...(flagString(parsed.flags, "sherpa-onnx-tts") !== undefined
2495
+ ? { sherpaOnnxTts: flagString(parsed.flags, "sherpa-onnx-tts") }
2496
+ : {}),
945
2497
  ...(flagString(parsed.flags, "whisper-cli") !== undefined
946
2498
  ? { whisperCli: flagString(parsed.flags, "whisper-cli") }
947
2499
  : {}),
@@ -951,13 +2503,32 @@ async function runDoctorCommand(parsed, signal) {
951
2503
  ...(flagString(parsed.flags, "codec-model") !== undefined
952
2504
  ? { codecModel: flagString(parsed.flags, "codec-model") }
953
2505
  : {}),
2506
+ ...(flagString(parsed.flags, "model-dir") !== undefined
2507
+ ? { supertonicModelDir: flagString(parsed.flags, "model-dir") }
2508
+ : {}),
954
2509
  ...(flagString(parsed.flags, "model") !== undefined
955
2510
  ? { whisperModel: flagString(parsed.flags, "model") }
956
2511
  : {}),
957
2512
  signal,
958
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 };
959
2530
  if (json) {
960
- printJson({ ok: true, command: "doctor", ...result });
2531
+ printJson({ ok: true, command: "doctor", ...report });
961
2532
  }
962
2533
  else {
963
2534
  for (const check of result.checks) {
@@ -965,7 +2536,11 @@ async function runDoctorCommand(parsed, signal) {
965
2536
  const suffix = check.required ? "" : " (optional)";
966
2537
  process.stdout.write(`${mark} ${check.name}${suffix}: ${check.detail}\n`);
967
2538
  }
968
- 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
+ ]) {
969
2544
  const mark = integration.status === "ok" ? "✓" : integration.status === "missing" && !integration.requested ? "○" : "✗";
970
2545
  const detail = integration.detail ? ` — ${integration.detail}` : "";
971
2546
  const version = integration.version ? ` ${integration.version}` : "";
@@ -976,13 +2551,23 @@ async function runDoctorCommand(parsed, signal) {
976
2551
  const detail = model.detail ? ` — ${model.detail}` : "";
977
2552
  process.stdout.write(`${mark} model ${model.role}: ${model.path}${detail}\n`);
978
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`);
979
2563
  process.stdout.write(`\ndoctor: ${result.healthy ? "healthy" : "problems found"}\n`);
980
2564
  }
981
2565
  return result.healthy ? 0 : 1;
982
2566
  }
983
2567
  function reportFailure(error, command, jsonRequested, signal) {
984
2568
  const aborted = signal?.aborted === true;
985
- 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";
986
2571
  const message = aborted ? "Operation cancelled." : error instanceof Error ? error.message : String(error);
987
2572
  const details = isFrameyardError(error) ? error.details : undefined;
988
2573
  const diagnostics = isFrameyardError(error) ? error.diagnostics : undefined;
@@ -998,14 +2583,14 @@ function reportFailure(error, command, jsonRequested, signal) {
998
2583
  ...(diagnostics !== undefined ? { diagnostics } : {}),
999
2584
  });
1000
2585
  }
1001
- process.stderr.write(`${aborted ? "Cancelled" : `error [${code}]`}${aborted ? "" : `: ${message}`}\n`);
2586
+ process.stderr.write(`${cancelled ? "Cancelled" : `error [${code}]`}${cancelled ? "" : `: ${message}`}\n`);
1002
2587
  if (diagnostics !== undefined) {
1003
2588
  printDiagnostics(diagnostics);
1004
2589
  }
1005
2590
  if (!aborted && !isFrameyardError(error) && process.env.FRAMEYARD_DEBUG === "1" && error instanceof Error) {
1006
2591
  process.stderr.write(`${error.stack ?? ""}\n`);
1007
2592
  }
1008
- if (aborted) {
2593
+ if (cancelled) {
1009
2594
  return 130;
1010
2595
  }
1011
2596
  return code === "USAGE" ? 2 : 1;
@@ -1070,17 +2655,31 @@ async function main(argv) {
1070
2655
  process.on("SIGINT", interrupt);
1071
2656
  process.on("SIGTERM", interrupt);
1072
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;
1073
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);
1074
2665
  case "check":
1075
- return await runCheck(parsed, controller.signal);
2666
+ return await runCheck(parsed, controller.signal, policy);
1076
2667
  case "still":
1077
- 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);
1078
2673
  case "render":
1079
- return await runRender(parsed, controller.signal);
2674
+ return await runRender(parsed, controller.signal, policy);
1080
2675
  case "tts":
1081
- return await runTts(parsed, controller.signal);
2676
+ return await runTts(parsed, controller.signal, policy);
1082
2677
  case "transcribe":
1083
- 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);
1084
2683
  case "doctor":
1085
2684
  return await runDoctorCommand(parsed, controller.signal);
1086
2685
  default: