Setting the file. One moment. Storyboard · Product Launch Video · heygen-com/hyperframes · Skills Docs163
function parseHeading
— line 163
This file
- Number
- 31.23
- Position
- 23 of 33
- Type
- JavaScript
- Size
- 8 KB
- Lines
- 249
scripts/lib/storyboard.mjs
JavaScript·249 lines·8 KB
[
"outline"
,
"built"
,
"animated"
];
14export const DEFAULT_FRAME_STATUS = "outline";
15
16// Detection-only frame heading (ends at the keyword); ReDoS-hardened — keep as-is.
17const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
18const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
19const HEADING_LEVEL_RE = /^(#{1,6})\s+/;
20const META_RE = /^\s*[-*]\s+([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/;
21const LEADING_INT_RE = /^(\d+)/;
22const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/;
23const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
24const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
25export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"];
26const VOICEOVER_KEYS = new Set(VOICEOVER_ALIASES);
27
28export function parseStoryboard(source) {
29 const warnings = [];
30 const { globals, bodyStartLine, body } = parseFrontmatter(source, warnings);
31 const frames = parseFrames(body, bodyStartLine, warnings);
32 return { globals, frames, warnings };
33}
34
35function emptyGlobals() {
36 return { extra: {} };
37}
38
39function isFrameStatus(value) {
40 return FRAME_STATUSES.includes(value);
41}
42
43// ── Frontmatter ─────────────────────────────────────────────────────────────
44function findFrontmatterRange(lines, warnings) {
45 let start = 0;
46 while (start < lines.length && (lines[start] ?? "").trim() === "") start++;
47 if ((lines[start] ?? "").trim() !== "---") return null;
48 for (let i = start + 1; i < lines.length; i++) {
49 if ((lines[i] ?? "").trim() === "---") return { start, end: i };
50 }
51 warnings.push({
52 message: "Frontmatter opening '---' has no closing '---'; treating whole file as body.",
53 line: start + 1,
54 });
55 return null;
56}
57
58function parseFrontmatterEntries(lines, start, end, warnings) {
59 const globals = emptyGlobals();
60 for (let i = start + 1; i < end; i++) {
61 const raw = lines[i] ?? "";
62 if (raw.trim() === "") continue;
63 const colon = raw.indexOf(":");
64 if (colon === -1) {
65 warnings.push({
66 message: `Ignored non key:value frontmatter line: "${raw.trim()}"`,
67 line: i + 1,
68 });
69 continue;
70 }
71 const key = raw.slice(0, colon).trim().toLowerCase();
72 assignGlobal(globals, key, stripQuotes(raw.slice(colon + 1).trim()));
73 }
74 return globals;
75}
76
77function parseFrontmatter(source, warnings) {
78 const lines = source.split(/\r?\n/);
79 const range = findFrontmatterRange(lines, warnings);
80 if (!range) return { globals: emptyGlobals(), bodyStartLine: 1, body: source };
81 const globals = parseFrontmatterEntries(lines, range.start, range.end, warnings);
82 const body = lines.slice(range.end + 1).join("\n");
83 return { globals, bodyStartLine: range.end + 2, body };
84}
85
86function assignGlobal(globals, key, value) {
87 switch (key) {
88 case "format":
89 globals.format = value;
90 break;
91 case "message":
92 globals.message = value;
93 break;
94 case "arc":
95 globals.arc = value;
96 break;
97 case "audience":
98 globals.audience = value;
99 break;
100 default:
101 globals.extra[key] = value;
102 }
103}
104
105// ── Frames ──────────────────────────────────────────────────────────────────
106function openFrameSection(line, headingLine) {
107 const match = FRAME_HEADING_RE.exec(line);
108 if (!match) return null;
109 const headingText = line.slice(match[0].length).replace(FRAME_TITLE_SEP_RE, "").trim();
110 return { headingText, headingLine, level: (match[1] ?? "##").length, lines: [] };
111}
112
113function endsFrameSection(line, current) {
114 if (!current) return false;
115 const heading = HEADING_LEVEL_RE.exec(line);
116 return heading !== null && (heading[1] ?? "").length <= current.level;
117}
118
119function parseFrames(body, bodyStartLine, warnings) {
120 const lines = body.split(/\r?\n/);
121 const sections = [];
122 let current = null;
123 for (let i = 0; i < lines.length; i++) {
124 const line = lines[i] ?? "";
125 const opened = openFrameSection(line, bodyStartLine + i);
126 if (opened) {
127 sections.push(opened);
128 current = opened;
129 } else if (endsFrameSection(line, current)) {
130 current = null;
131 } else if (current) {
132 current.lines.push(line);
133 }
134 }
135 return sections.map((section, idx) => buildFrame(section, idx + 1, warnings));
136}
137
138function buildFrame(section, index, warnings) {
139 const frame = { index, status: DEFAULT_FRAME_STATUS, narrative: "", extra: {} };
140 const { number, title } = parseHeading(section.headingText);
141 if (number !== undefined) frame.number = number;
142 if (title) frame.title = title;
143
144 const narrativeLines = [];
145 for (const line of section.lines) {
146 const meta = META_RE.exec(line);
147 if (meta) {
148 applyMeta(
149 frame,
150 (meta[1] ?? "").toLowerCase(),
151 (meta[2] ?? "").trim(),
152 section.headingLine,
153 warnings,
154 );
155 } else {
156 narrativeLines.push(line);
157 }
158 }
159 frame.narrative = narrativeLines.join("\n").trim();
160 return frame;
161}
162
163function parseHeading(text) {
164 if (!text) return {};
165 const intMatch = LEADING_INT_RE.exec(text);
166 if (!intMatch) return { title: text };
167 const number = Number.parseInt(intMatch[1] ?? "", 10);
168 const rest = text
169 .slice((intMatch[0] ?? "").length)
170 .replace(/^[\s.:—-]+/, "")
171 .trim();
172 return { number, title: rest || undefined };
173}
174
175// Dispatch a recognized metadata key to its field, else stash under `extra`.
176// Mirrors core's META_SETTERS map exactly (direct keys + alias sets).
177function applyMeta(frame, key, value, headingLine, warnings) {
178 switch (key) {
179 case "duration":
180 applyDuration(frame, value, headingLine, warnings);
181 return;
182 case "status":
183 applyStatus(frame, value, headingLine, warnings);
184 return;
185 case "poster":
186 applyPoster(frame, value);
187 return;
188 case "src":
189 frame.src = value;
190 return;
191 }
192 if (TRANSITION_KEYS.has(key)) {
193 frame.transitionIn = value;
194 return;
195 }
196 if (SCENE_KEYS.has(key)) {
197 frame.scene = value;
198 return;
199 }
200 if (VOICEOVER_KEYS.has(key)) {
201 frame.voiceover = stripQuotes(value);
202 return;
203 }
204 frame.extra[key] = value;
205}
206
207function applyPoster(frame, value) {
208 const num = DURATION_NUM_RE.exec(value);
209 if (num) frame.poster = Number.parseFloat(num[1] ?? "");
210}
211
212function applyDuration(frame, value, headingLine, warnings) {
213 frame.duration = value;
214 const num = DURATION_NUM_RE.exec(value);
215 if (num) {
216 frame.durationSeconds = Number.parseFloat(num[1] ?? "");
217 return;
218 }
219 warnings.push({
220 message: `Frame ${frame.index}: could not parse duration "${value}".`,
221 line: headingLine,
222 frameIndex: frame.index,
223 });
224}
225
226function applyStatus(frame, value, headingLine, warnings) {
227 const normalized = value.toLowerCase();
228 if (isFrameStatus(normalized)) {
229 frame.status = normalized;
230 return;
231 }
232 frame.extra.status = value;
233 warnings.push({
234 message: `Frame ${frame.index}: unknown status "${value}"; defaulting to "${DEFAULT_FRAME_STATUS}".`,
235 line: headingLine,
236 frameIndex: frame.index,
237 });
238}
239
240function stripQuotes(value) {
241 if (value.length >= 2) {
242 const first = value[0];
243 const last = value[value.length - 1];
244 if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
245 return value.slice(1, -1);
246 }
247 }
248 return value;
249}