Setting the file. One moment. Recipe Store · Media Use · heygen-com/hyperframes · Skills Docs⋯
scripts/11 files
This file
- Number
- 27.25
- Position
- 25 of 78
- Type
- JavaScript
- Size
- 13 KB
- Lines
- 367
scripts/lib/recipe-store.mjs
JavaScript·367 lines·13 KB
13import { appendRecord, mediaDir, nextId } from "./manifest.mjs";
14import { regenerateIndex } from "./index-gen.mjs";
15import { 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. */
35const 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. */
39const BRIEF_CONTENT_KEYS = new Set(["flow", "storyboard", "message", "audience"]);
40
41/** Per-frame metadata that is content, not structure. */
42const 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
54const FRAME_HEADING_RE = /^(#{2,3})\s+(?:frame|beat|scene)\s+\d+/i;
55
56export function projectRecipesDir(projectDir) {
57 return join(mediaDir(projectDir), "recipes");
58}
59
60export function userRecipesDir() {
61 return join(homedir(), ".media", "recipes");
62}
63
64export 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
76function 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. */
83function 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). */
100function 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 */
133export 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
150export 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. */
172function 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
184function 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
196function 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 */
205export 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
298function 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. */
310export 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 */
328export 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}