hyperframes 0.8.57 → 0.8.59

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 (182) hide show
  1. package/dist/cli.js +5300 -3428
  2. package/dist/docs/data-attributes.md +2 -1
  3. package/dist/fontLocalizeCli.js +73 -12
  4. package/dist/hyperframe-runtime.js +25 -25
  5. package/dist/hyperframe.manifest.json +1 -1
  6. package/dist/hyperframe.runtime.iife.js +25 -25
  7. package/dist/hyperframes-player.global.js +3 -3
  8. package/dist/registry/localSemantic.js +548 -0
  9. package/dist/skills/hyperframes-cli/SKILL.md +2 -2
  10. package/dist/skills/media-use/SKILL.md +96 -0
  11. package/dist/skills/media-use/audio/assets/sfx/CREDITS.md +35 -0
  12. package/dist/skills/media-use/audio/assets/sfx/chime.mp3 +0 -0
  13. package/dist/skills/media-use/audio/assets/sfx/click-soft.mp3 +0 -0
  14. package/dist/skills/media-use/audio/assets/sfx/click.mp3 +0 -0
  15. package/dist/skills/media-use/audio/assets/sfx/error.mp3 +0 -0
  16. package/dist/skills/media-use/audio/assets/sfx/glitch-1.mp3 +0 -0
  17. package/dist/skills/media-use/audio/assets/sfx/glitch-2.mp3 +0 -0
  18. package/dist/skills/media-use/audio/assets/sfx/glitch-3.mp3 +0 -0
  19. package/dist/skills/media-use/audio/assets/sfx/impact-bass-1.mp3 +0 -0
  20. package/dist/skills/media-use/audio/assets/sfx/impact-bass-2.mp3 +0 -0
  21. package/dist/skills/media-use/audio/assets/sfx/key-press.mp3 +0 -0
  22. package/dist/skills/media-use/audio/assets/sfx/manifest.json +97 -0
  23. package/dist/skills/media-use/audio/assets/sfx/notification.mp3 +0 -0
  24. package/dist/skills/media-use/audio/assets/sfx/ping.mp3 +0 -0
  25. package/dist/skills/media-use/audio/assets/sfx/pop.mp3 +0 -0
  26. package/dist/skills/media-use/audio/assets/sfx/riser.mp3 +0 -0
  27. package/dist/skills/media-use/audio/assets/sfx/sparkle.mp3 +0 -0
  28. package/dist/skills/media-use/audio/assets/sfx/typing.mp3 +0 -0
  29. package/dist/skills/media-use/audio/assets/sfx/whoosh-cinematic.mp3 +0 -0
  30. package/dist/skills/media-use/audio/assets/sfx/whoosh-short.mp3 +0 -0
  31. package/dist/skills/media-use/audio/assets/sfx/whoosh.mp3 +0 -0
  32. package/dist/skills/media-use/audio/references/bgm.md +74 -0
  33. package/dist/skills/media-use/audio/references/captions/authoring.md +163 -0
  34. package/dist/skills/media-use/audio/references/captions/motion.md +87 -0
  35. package/dist/skills/media-use/audio/references/captions/transcript-handling.md +97 -0
  36. package/dist/skills/media-use/audio/references/remove-background.md +143 -0
  37. package/dist/skills/media-use/audio/references/requirements.md +29 -0
  38. package/dist/skills/media-use/audio/references/sfx.md +42 -0
  39. package/dist/skills/media-use/audio/references/transcribe.md +52 -0
  40. package/dist/skills/media-use/audio/references/tts-to-captions.md +26 -0
  41. package/dist/skills/media-use/audio/references/tts.md +165 -0
  42. package/dist/skills/media-use/audio/scripts/audio.mjs +295 -0
  43. package/dist/skills/media-use/audio/scripts/audio.test.mjs +49 -0
  44. package/dist/skills/media-use/audio/scripts/heygen-tts.mjs +121 -0
  45. package/dist/skills/media-use/audio/scripts/lib/audio-meta.mjs +39 -0
  46. package/dist/skills/media-use/audio/scripts/lib/audio-meta.test.mjs +136 -0
  47. package/dist/skills/media-use/audio/scripts/lib/bgm.mjs +257 -0
  48. package/dist/skills/media-use/audio/scripts/lib/bgm.test.mjs +30 -0
  49. package/dist/skills/media-use/audio/scripts/lib/concurrency.mjs +14 -0
  50. package/dist/skills/media-use/audio/scripts/lib/concurrency.test.mjs +41 -0
  51. package/dist/skills/media-use/audio/scripts/lib/heygen.mjs +156 -0
  52. package/dist/skills/media-use/audio/scripts/lib/heygen.test.mjs +103 -0
  53. package/dist/skills/media-use/audio/scripts/lib/python.mjs +63 -0
  54. package/dist/skills/media-use/audio/scripts/lib/python.test.mjs +68 -0
  55. package/dist/skills/media-use/audio/scripts/lib/sfx.mjs +143 -0
  56. package/dist/skills/media-use/audio/scripts/lib/sfx.test.mjs +69 -0
  57. package/dist/skills/media-use/audio/scripts/lib/tts.mjs +379 -0
  58. package/dist/skills/media-use/audio/scripts/lib/tts.spawn.test.mjs +158 -0
  59. package/dist/skills/media-use/audio/scripts/lib/tts.test.mjs +157 -0
  60. package/dist/skills/media-use/audio/scripts/lyria-recipe.py +136 -0
  61. package/dist/skills/media-use/audio/scripts/wait-bgm.mjs +171 -0
  62. package/dist/skills/media-use/audio/scripts/wait-bgm.test.mjs +65 -0
  63. package/dist/skills/media-use/luts/README.md +28 -0
  64. package/dist/skills/media-use/luts/index.json +57 -0
  65. package/dist/skills/media-use/references/audio.md +19 -0
  66. package/dist/skills/media-use/references/grading.md +160 -0
  67. package/dist/skills/media-use/references/media-treatment-recipes.md +929 -0
  68. package/dist/skills/media-use/references/media-treatments.md +202 -0
  69. package/dist/skills/media-use/references/memory.md +36 -0
  70. package/dist/skills/media-use/references/meta.md +61 -0
  71. package/dist/skills/media-use/references/operations.md +325 -0
  72. package/dist/skills/media-use/references/resolve.md +150 -0
  73. package/dist/skills/media-use/references/setup-providers.md +80 -0
  74. package/dist/skills/media-use/references/telemetry-dashboard.md +49 -0
  75. package/dist/skills/media-use/registry/localSemantic.js +548 -0
  76. package/dist/skills/media-use/scripts/audio-duck.mjs +113 -0
  77. package/dist/skills/media-use/scripts/compatibility.test.mjs +27 -0
  78. package/dist/skills/media-use/scripts/dither.mjs +328 -0
  79. package/dist/skills/media-use/scripts/dither.test.mjs +176 -0
  80. package/dist/skills/media-use/scripts/eval.mjs +369 -0
  81. package/dist/skills/media-use/scripts/lib/adopt.mjs +128 -0
  82. package/dist/skills/media-use/scripts/lib/adopt.test.mjs +95 -0
  83. package/dist/skills/media-use/scripts/lib/bgm-provider.mjs +20 -0
  84. package/dist/skills/media-use/scripts/lib/brand-provider.mjs +59 -0
  85. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.mjs +119 -0
  86. package/dist/skills/media-use/scripts/lib/bundled-sfx-provider.test.mjs +139 -0
  87. package/dist/skills/media-use/scripts/lib/cache.mjs +150 -0
  88. package/dist/skills/media-use/scripts/lib/candidates.mjs +91 -0
  89. package/dist/skills/media-use/scripts/lib/candidates.test.mjs +155 -0
  90. package/dist/skills/media-use/scripts/lib/codex-provider.mjs +161 -0
  91. package/dist/skills/media-use/scripts/lib/codex-provider.test.mjs +34 -0
  92. package/dist/skills/media-use/scripts/lib/coverage.test.mjs +132 -0
  93. package/dist/skills/media-use/scripts/lib/cube-build.mjs +181 -0
  94. package/dist/skills/media-use/scripts/lib/cube-build.test.mjs +80 -0
  95. package/dist/skills/media-use/scripts/lib/cube-validate.mjs +198 -0
  96. package/dist/skills/media-use/scripts/lib/cube-validate.test.mjs +125 -0
  97. package/dist/skills/media-use/scripts/lib/cutlist.mjs +184 -0
  98. package/dist/skills/media-use/scripts/lib/cutlist.test.mjs +162 -0
  99. package/dist/skills/media-use/scripts/lib/duck.mjs +95 -0
  100. package/dist/skills/media-use/scripts/lib/duck.test.mjs +153 -0
  101. package/dist/skills/media-use/scripts/lib/error-diffusion.mjs +230 -0
  102. package/dist/skills/media-use/scripts/lib/error-diffusion.test.mjs +102 -0
  103. package/dist/skills/media-use/scripts/lib/freeze.mjs +62 -0
  104. package/dist/skills/media-use/scripts/lib/freeze.test.mjs +46 -0
  105. package/dist/skills/media-use/scripts/lib/grade-analyzer.mjs +265 -0
  106. package/dist/skills/media-use/scripts/lib/grade-analyzer.test.mjs +285 -0
  107. package/dist/skills/media-use/scripts/lib/heygen-cli.mjs +191 -0
  108. package/dist/skills/media-use/scripts/lib/heygen-cli.test.mjs +301 -0
  109. package/dist/skills/media-use/scripts/lib/heygen-search.mjs +45 -0
  110. package/dist/skills/media-use/scripts/lib/heygen-search.test.mjs +49 -0
  111. package/dist/skills/media-use/scripts/lib/heygen-video-provider.mjs +138 -0
  112. package/dist/skills/media-use/scripts/lib/heygen-video-provider.test.mjs +435 -0
  113. package/dist/skills/media-use/scripts/lib/image-provider.mjs +44 -0
  114. package/dist/skills/media-use/scripts/lib/index-gen.mjs +63 -0
  115. package/dist/skills/media-use/scripts/lib/local-media-search.mjs +32 -0
  116. package/dist/skills/media-use/scripts/lib/local-models.mjs +318 -0
  117. package/dist/skills/media-use/scripts/lib/local-models.test.mjs +220 -0
  118. package/dist/skills/media-use/scripts/lib/local-run.mjs +78 -0
  119. package/dist/skills/media-use/scripts/lib/local-run.test.mjs +121 -0
  120. package/dist/skills/media-use/scripts/lib/logo-provider.mjs +223 -0
  121. package/dist/skills/media-use/scripts/lib/logo-provider.test.mjs +154 -0
  122. package/dist/skills/media-use/scripts/lib/ltx-video-provider.mjs +94 -0
  123. package/dist/skills/media-use/scripts/lib/ltx-video-provider.test.mjs +316 -0
  124. package/dist/skills/media-use/scripts/lib/lut-preset-provider.mjs +271 -0
  125. package/dist/skills/media-use/scripts/lib/lut-preset-provider.test.mjs +199 -0
  126. package/dist/skills/media-use/scripts/lib/manifest.mjs +229 -0
  127. package/dist/skills/media-use/scripts/lib/manifest.test.mjs +388 -0
  128. package/dist/skills/media-use/scripts/lib/match.mjs +47 -0
  129. package/dist/skills/media-use/scripts/lib/media-fetch.mjs +71 -0
  130. package/dist/skills/media-use/scripts/lib/media-fetch.test.mjs +169 -0
  131. package/dist/skills/media-use/scripts/lib/media-search.d.mts +9 -0
  132. package/dist/skills/media-use/scripts/lib/media-search.mjs +107 -0
  133. package/dist/skills/media-use/scripts/lib/mflux-provider.mjs +128 -0
  134. package/dist/skills/media-use/scripts/lib/mflux-provider.test.mjs +174 -0
  135. package/dist/skills/media-use/scripts/lib/misses.mjs +49 -0
  136. package/dist/skills/media-use/scripts/lib/misses.test.mjs +78 -0
  137. package/dist/skills/media-use/scripts/lib/npx-sync.mjs +31 -0
  138. package/dist/skills/media-use/scripts/lib/npx-sync.test.mjs +50 -0
  139. package/dist/skills/media-use/scripts/lib/parakeet-words.mjs +27 -0
  140. package/dist/skills/media-use/scripts/lib/parakeet-words.test.mjs +44 -0
  141. package/dist/skills/media-use/scripts/lib/prefs-store.mjs +180 -0
  142. package/dist/skills/media-use/scripts/lib/prefs-store.test.mjs +189 -0
  143. package/dist/skills/media-use/scripts/lib/probe.mjs +39 -0
  144. package/dist/skills/media-use/scripts/lib/probe.test.mjs +31 -0
  145. package/dist/skills/media-use/scripts/lib/providers.mjs +5 -0
  146. package/dist/skills/media-use/scripts/lib/recipe-store.mjs +367 -0
  147. package/dist/skills/media-use/scripts/lib/recipe-store.test.mjs +367 -0
  148. package/dist/skills/media-use/scripts/lib/registry.mjs +218 -0
  149. package/dist/skills/media-use/scripts/lib/registry.test.mjs +262 -0
  150. package/dist/skills/media-use/scripts/lib/search.mjs +24 -0
  151. package/dist/skills/media-use/scripts/lib/search.test.mjs +32 -0
  152. package/dist/skills/media-use/scripts/lib/sfx-provider.mjs +23 -0
  153. package/dist/skills/media-use/scripts/lib/specs.mjs +132 -0
  154. package/dist/skills/media-use/scripts/lib/specs.test.mjs +157 -0
  155. package/dist/skills/media-use/scripts/lib/stats.mjs +117 -0
  156. package/dist/skills/media-use/scripts/lib/stats.test.mjs +165 -0
  157. package/dist/skills/media-use/scripts/lib/telemetry.mjs +193 -0
  158. package/dist/skills/media-use/scripts/lib/telemetry.test.mjs +373 -0
  159. package/dist/skills/media-use/scripts/lib/tts-local-provider.mjs +92 -0
  160. package/dist/skills/media-use/scripts/lib/tts-local-provider.test.mjs +72 -0
  161. package/dist/skills/media-use/scripts/lib/usage.mjs +57 -0
  162. package/dist/skills/media-use/scripts/lib/usage.test.mjs +54 -0
  163. package/dist/skills/media-use/scripts/lib/voice-provider.mjs +57 -0
  164. package/dist/skills/media-use/scripts/lib/voice-provider.test.mjs +46 -0
  165. package/dist/skills/media-use/scripts/lib/words.mjs +18 -0
  166. package/dist/skills/media-use/scripts/prefs.mjs +70 -0
  167. package/dist/skills/media-use/scripts/recipe.mjs +103 -0
  168. package/dist/skills/media-use/scripts/resolve.mjs +1287 -0
  169. package/dist/skills/media-use/scripts/transcribe.mjs +180 -0
  170. package/dist/skills/media-use/scripts/transcript-cut.mjs +239 -0
  171. package/dist/skills/registry/localSemantic.js +548 -0
  172. package/dist/studio/assets/{hyperframes-player-c23wnIfc.js → hyperframes-player-BBub3ZdI.js} +3 -3
  173. package/dist/studio/assets/{index-IDNr3X1H.js → index-BxZ5Cw5w.js} +1 -1
  174. package/dist/studio/assets/{index-DgkAzx3n.js → index-CI7WON-E.js} +1 -1
  175. package/dist/studio/assets/{index-gbOCDCuZ.js → index-D1KNPijK.js} +192 -192
  176. package/dist/studio/assets/{index-DKJpHYYd.css → index-DZBHG9TW.css} +1 -1
  177. package/dist/studio/index.d.ts +577 -8
  178. package/dist/studio/index.html +2 -2
  179. package/dist/studio/index.js +22537 -21600
  180. package/dist/studio/index.js.map +1 -1
  181. package/dist/studio/styles.css +1 -1
  182. package/package.json +3 -3
@@ -0,0 +1,367 @@
1
+ import {
2
+ cpSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { basename, join, resolve } from "node:path";
13
+ import { appendRecord, mediaDir, nextId } from "./manifest.mjs";
14
+ import { regenerateIndex } from "./index-gen.mjs";
15
+ import { mergedPreferences } from "./prefs-store.mjs";
16
+
17
+ /**
18
+ * Recipes — the heavyweight tier of HyperFrames user memory.
19
+ *
20
+ * A recipe is the full confirmed bundle for one video type: the frozen design
21
+ * spec (`frame.md`), the storyboard skeleton (structure with the content
22
+ * blanked), and the confirmed brief values — frozen after the run's final
23
+ * approval, reused to start the next video of the same type from everything
24
+ * already approved.
25
+ *
26
+ * Storage is **named folders**, not content-addressed cache entries: a recipe
27
+ * is an evolving bundle with a `version`, so re-freezing the same name bumps
28
+ * the version and archives the old folder as `<name>@v<N>`. Two tiers, same
29
+ * split as everything else in media-use: project `.media/recipes/<name>/`
30
+ * (committed) and user `~/.media/recipes/<name>/` (a freeze is already a
31
+ * confirmed bundle, so it promotes immediately — no two-project rule here).
32
+ */
33
+
34
+ /** Frontmatter keys that describe THIS video, not the reusable type. */
35
+ const FRONTMATTER_CONTENT_KEYS = new Set(["message", "audience", "mode"]);
36
+
37
+ /** BRIEF.md frontmatter keys that describe this run, not the reusable type —
38
+ * a recipe never locks the run's shape, so the intent layer always re-asks. */
39
+ const BRIEF_CONTENT_KEYS = new Set(["flow", "storyboard", "message", "audience"]);
40
+
41
+ /** Per-frame metadata that is content, not structure. */
42
+ const FRAME_CONTENT_KEYS = new Set([
43
+ "voiceover",
44
+ "vo",
45
+ "voice_over",
46
+ "narration",
47
+ "scene",
48
+ "description",
49
+ "summary",
50
+ "caption",
51
+ "asset_candidates",
52
+ ]);
53
+
54
+ const FRAME_HEADING_RE = /^(#{2,3})\s+(?:frame|beat|scene)\s+\d+/i;
55
+
56
+ export function projectRecipesDir(projectDir) {
57
+ return join(mediaDir(projectDir), "recipes");
58
+ }
59
+
60
+ export function userRecipesDir() {
61
+ return join(homedir(), ".media", "recipes");
62
+ }
63
+
64
+ export function slugifyRecipeName(name) {
65
+ const slug = String(name ?? "")
66
+ .trim()
67
+ .toLowerCase()
68
+ .replace(/[\s_]+/g, "-")
69
+ .replace(/[^a-z0-9-]/g, "")
70
+ .replace(/-+/g, "-")
71
+ .replace(/^-|-$/g, "");
72
+ if (!slug) throw new Error(`recipe name "${name}" has no usable characters`);
73
+ return slug;
74
+ }
75
+
76
+ function frameTitle(headingLine) {
77
+ const dash = headingLine.split(/\s+—\s+/)[1];
78
+ if (dash && dash.trim()) return dash.trim();
79
+ return headingLine.replace(/^#+\s*/, "").trim();
80
+ }
81
+
82
+ /** Frontmatter: drop the content keys, keep structure/style keys verbatim. */
83
+ function skeletonFrontmatter(lines, out, contentKeys = FRONTMATTER_CONTENT_KEYS) {
84
+ if (lines[0]?.trim() !== "---") return 0;
85
+ out.push(lines[0]);
86
+ let i = 1;
87
+ while (i < lines.length && lines[i].trim() !== "---") {
88
+ const key = lines[i].match(/^(\w+)\s*:/)?.[1]?.toLowerCase();
89
+ if (!key || !contentKeys.has(key)) out.push(lines[i]);
90
+ i++;
91
+ }
92
+ if (i < lines.length) {
93
+ out.push(lines[i]); // closing ---
94
+ i++;
95
+ }
96
+ return i;
97
+ }
98
+
99
+ /** One line inside a frame section — returns the replacement lines (may be none). */
100
+ function skeletonFrameLine(line, state, out) {
101
+ const bulletKey = line.match(/^-\s+(\w+)\s*:/)?.[1]?.toLowerCase();
102
+ if (bulletKey) {
103
+ if (bulletKey === "status") out.push("- status: outline");
104
+ else if (!FRAME_CONTENT_KEYS.has(bulletKey)) out.push(line);
105
+ return;
106
+ }
107
+ if (!line.trim()) {
108
+ out.push(line);
109
+ return;
110
+ }
111
+ // Frame prose: one placeholder per frame in place of the narrative.
112
+ if (!state.proseReplaced) {
113
+ out.push(
114
+ `<fill in: this video's content for the "${state.title}" beat — keep the layout role, replace the words.>`,
115
+ );
116
+ state.proseReplaced = true;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Skeletonize a STORYBOARD.md: keep the reusable structure (frame count,
122
+ * durations, transitions, src paths, the Video direction block, style-ish
123
+ * frontmatter), reset every status to `outline`, and blank the content
124
+ * (message/audience, narration guides, per-frame prose) down to a fill-in
125
+ * placeholder that names the frame's role.
126
+ */
127
+ /**
128
+ * Skeletonize a BRIEF.md: keep the frontmatter's reusable keys (workflow,
129
+ * destination, aspect, language, length, angle…), drop the run-shape and
130
+ * content keys (flow, storyboard, message, audience), and blank each body
131
+ * section down to a fill-in placeholder under its kept heading.
132
+ */
133
+ export function skeletonizeBrief(source) {
134
+ const lines = String(source ?? "").split(/\r?\n/);
135
+ const out = [];
136
+ let i = skeletonFrontmatter(lines, out, BRIEF_CONTENT_KEYS);
137
+ for (; i < lines.length; i++) {
138
+ const heading = lines[i].match(/^##\s+(.+)$/);
139
+ if (heading) {
140
+ out.push(lines[i], "");
141
+ out.push(
142
+ `<fill in: this video's ${heading[1].trim().toLowerCase()} — the recipe keeps the shape, this run supplies the specifics.>`,
143
+ );
144
+ out.push("");
145
+ }
146
+ }
147
+ return out.join("\n").replace(/\n{3,}/g, "\n\n");
148
+ }
149
+
150
+ export function skeletonizeStoryboard(source) {
151
+ const lines = String(source ?? "").split(/\r?\n/);
152
+ const out = [];
153
+ const state = { inFrame: false, proseReplaced: false, title: "" };
154
+ for (let i = skeletonFrontmatter(lines, out); i < lines.length; i++) {
155
+ const line = lines[i];
156
+ if (/^#{2,3}\s/.test(line)) {
157
+ state.inFrame = FRAME_HEADING_RE.test(line);
158
+ state.proseReplaced = false;
159
+ state.title = state.inFrame ? frameTitle(line) : "";
160
+ out.push(line);
161
+ } else if (!state.inFrame) {
162
+ out.push(line);
163
+ } else {
164
+ skeletonFrameLine(line, state, out);
165
+ }
166
+ }
167
+ return out.join("\n").replace(/\n{3,}/g, "\n\n");
168
+ }
169
+
170
+ /** The run's workflow as BRIEF.md records it — the source of truth a freeze
171
+ * must not contradict. Undefined when no BRIEF.md (or no `workflow:`) exists. */
172
+ function briefWorkflow(root) {
173
+ const brief = join(root, "BRIEF.md");
174
+ if (!existsSync(brief)) return undefined;
175
+ const lines = readFileSync(brief, "utf8").split(/\r?\n/);
176
+ if (lines[0]?.trim() !== "---") return undefined;
177
+ for (let i = 1; i < lines.length && lines[i].trim() !== "---"; i++) {
178
+ const match = lines[i].match(/^workflow\s*:\s*(.+?)\s*$/);
179
+ if (match) return match[1].replace(/^["']|["']$/g, "") || undefined;
180
+ }
181
+ return undefined;
182
+ }
183
+
184
+ function readRecipeJson(dir) {
185
+ try {
186
+ const parsed = JSON.parse(readFileSync(join(dir, "recipe.json"), "utf8"));
187
+ if (typeof parsed !== "object" || parsed === null || typeof parsed.name !== "string") {
188
+ return null;
189
+ }
190
+ return parsed;
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+
196
+ function prefValue(prefs, key) {
197
+ return prefs[key]?.value;
198
+ }
199
+
200
+ /**
201
+ * Freeze the current project's approved run as a named recipe. Writes the
202
+ * project-tier folder + a manifest record, then copies to the user tier (a
203
+ * freeze is already confirmed — it promotes immediately).
204
+ */
205
+ export function freezeRecipe({ projectDir, name, workflow, blocks }) {
206
+ const slug = slugifyRecipeName(name);
207
+ const root = resolve(projectDir);
208
+ const fromBrief = briefWorkflow(root);
209
+ const fromFlag = workflow && String(workflow).trim() ? String(workflow).trim() : undefined;
210
+ // BRIEF.md decides; the flag only covers projects briefed before it existed.
211
+ const resolvedWorkflow = fromBrief ?? fromFlag;
212
+ if (!resolvedWorkflow) {
213
+ throw new Error("no workflow found — BRIEF.md names none and no --workflow was given");
214
+ }
215
+ const frameSpec = join(root, "frame.md");
216
+ const storyboard = join(root, "STORYBOARD.md");
217
+ if (!existsSync(frameSpec)) throw new Error("no frame.md to freeze — run the design step first");
218
+ if (!existsSync(storyboard)) throw new Error("no STORYBOARD.md to freeze");
219
+
220
+ const dir = join(projectRecipesDir(root), slug);
221
+ let version = 1;
222
+ const previous = existsSync(dir) ? readRecipeJson(dir) : null;
223
+ if (previous) {
224
+ version = (Number.isInteger(previous.version) ? previous.version : 1) + 1;
225
+ const archive = `${dir}@v${previous.version ?? 1}`;
226
+ rmSync(archive, { recursive: true, force: true });
227
+ renameSync(dir, archive);
228
+ }
229
+ mkdirSync(dir, { recursive: true });
230
+
231
+ const prefs = mergedPreferences(root);
232
+ const recipe = {
233
+ version,
234
+ name: slug,
235
+ workflow: resolvedWorkflow,
236
+ approved_at: new Date().toISOString(),
237
+ source_project: basename(root),
238
+ destination: prefValue(prefs, "destination"),
239
+ aspect: prefValue(prefs, "aspect"),
240
+ language: prefValue(prefs, "language"),
241
+ voice: prefValue(prefs, "voice"),
242
+ // The bare-key fallback tolerates records made before the store required
243
+ // style_preset to be workflow-scoped.
244
+ style_preset:
245
+ prefValue(prefs, `style_preset.${resolvedWorkflow}`) ?? prefValue(prefs, "style_preset"),
246
+ blocks: Array.isArray(blocks) && blocks.length > 0 ? blocks : undefined,
247
+ };
248
+
249
+ writeFileSync(join(dir, "recipe.json"), `${JSON.stringify(recipe, null, 2)}\n`);
250
+ cpSync(frameSpec, join(dir, "frame.md"));
251
+ writeFileSync(
252
+ join(dir, "storyboard-skeleton.md"),
253
+ `${skeletonizeStoryboard(readFileSync(storyboard, "utf8")).trimEnd()}\n`,
254
+ );
255
+
256
+ // Best-effort fourth artifact — projects briefed before BRIEF.md existed
257
+ // (or by workflows that don't write one) freeze fine without it.
258
+ const brief = join(root, "BRIEF.md");
259
+ const briefSkeleton = existsSync(brief);
260
+ if (briefSkeleton) {
261
+ writeFileSync(
262
+ join(dir, "brief-skeleton.md"),
263
+ `${skeletonizeBrief(readFileSync(brief, "utf8")).trimEnd()}\n`,
264
+ );
265
+ }
266
+
267
+ const id = nextId(root, "recipe");
268
+ appendRecord(root, {
269
+ id,
270
+ type: "recipe",
271
+ path: `.media/recipes/${slug}/recipe.json`,
272
+ entity: slug,
273
+ description: `recipe: ${slug} (${recipe.workflow}, v${version})`,
274
+ provenance: { provider: "recipe.freeze", version, source_project: recipe.source_project },
275
+ });
276
+ regenerateIndex(root);
277
+
278
+ // User tier — best-effort, like every other promotion.
279
+ try {
280
+ const userDir = join(userRecipesDir(), slug);
281
+ mkdirSync(userDir, { recursive: true });
282
+ cpSync(dir, userDir, { recursive: true, force: true });
283
+ } catch {
284
+ // The project-tier freeze already landed.
285
+ }
286
+
287
+ return {
288
+ id,
289
+ slug,
290
+ version,
291
+ dir,
292
+ briefSkeleton,
293
+ workflow: resolvedWorkflow,
294
+ workflowOverridden: Boolean(fromBrief && fromFlag && fromBrief !== fromFlag),
295
+ };
296
+ }
297
+
298
+ function scanRecipesDir(dir, source) {
299
+ if (!existsSync(dir)) return [];
300
+ const found = [];
301
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
302
+ if (!entry.isDirectory() || entry.name.includes("@v")) continue;
303
+ const recipe = readRecipeJson(join(dir, entry.name));
304
+ if (recipe) found.push({ ...recipe, source, dir: join(dir, entry.name) });
305
+ }
306
+ return found;
307
+ }
308
+
309
+ /** Two-tier merged listing (project wins), newest approval first. */
310
+ export function listRecipes({ projectDir, workflow }) {
311
+ const merged = new Map();
312
+ for (const recipe of scanRecipesDir(userRecipesDir(), "user")) merged.set(recipe.name, recipe);
313
+ for (const recipe of scanRecipesDir(projectRecipesDir(resolve(projectDir)), "project")) {
314
+ merged.set(recipe.name, recipe);
315
+ }
316
+ let list = [...merged.values()];
317
+ if (workflow) list = list.filter((r) => r.workflow === workflow);
318
+ return list.sort((a, b) =>
319
+ String(b.approved_at ?? "").localeCompare(String(a.approved_at ?? "")),
320
+ );
321
+ }
322
+
323
+ /**
324
+ * Adopt a recipe into the current project: import the folder from the user
325
+ * tier when the project doesn't have it, copy its frame.md over the project's,
326
+ * and hand back the values + the skeleton path for the storyboard draft.
327
+ */
328
+ export function useRecipe({ projectDir, name }) {
329
+ const slug = slugifyRecipeName(name);
330
+ const root = resolve(projectDir);
331
+ let dir = join(projectRecipesDir(root), slug);
332
+
333
+ if (!readRecipeJson(dir)) {
334
+ const userDir = join(userRecipesDir(), slug);
335
+ if (!readRecipeJson(userDir)) {
336
+ const known = listRecipes({ projectDir: root }).map((r) => r.name);
337
+ throw new Error(
338
+ `no recipe named "${slug}"${known.length ? ` (known: ${known.join(", ")})` : ""}`,
339
+ );
340
+ }
341
+ mkdirSync(dir, { recursive: true });
342
+ cpSync(userDir, dir, { recursive: true, force: true });
343
+ const imported = readRecipeJson(dir);
344
+ appendRecord(root, {
345
+ id: nextId(root, "recipe"),
346
+ type: "recipe",
347
+ path: `.media/recipes/${slug}/recipe.json`,
348
+ entity: slug,
349
+ description: `recipe: ${slug} (${imported.workflow}, v${imported.version})`,
350
+ provenance: { provider: "recipe.local", imported_from: "user-tier" },
351
+ });
352
+ regenerateIndex(root);
353
+ }
354
+
355
+ const recipe = readRecipeJson(dir);
356
+ cpSync(join(dir, "frame.md"), join(root, "frame.md"));
357
+ return {
358
+ recipe,
359
+ dir,
360
+ frameSpecPath: "frame.md",
361
+ skeletonPath: `.media/recipes/${slug}/storyboard-skeleton.md`,
362
+ // Recipes frozen before BRIEF.md existed have no brief skeleton — degrade.
363
+ briefSkeletonPath: existsSync(join(dir, "brief-skeleton.md"))
364
+ ? `.media/recipes/${slug}/brief-skeleton.md`
365
+ : undefined,
366
+ };
367
+ }