返回 html-video
project.ts
根目录 / packages / core / src / project.ts
1 /**
2 * Project orchestrator: 单模板单视频工作流(RFC-05)。
3 * - createProject
4 * - addAsset / removeAsset
5 * - setTemplate / setVariable / setVariables
6 * - renderPreviewHtml: 调 EngineAdapter.renderToHtml() → HTML for iframe
7 * - exportMp4: 调 EngineAdapter.render() → MP4 file
8 */
9
10 import { randomUUID } from 'node:crypto';
11 import { join, basename } from 'node:path';
12 import type {
13 Asset,
14 EngineId,
15 FrameRecord,
16 Project,
17 ProjectStatus,
18 TemplateMetadata,
19 TemplateRef,
20 } from './types/index.js';
21 import {
22 type ContentGraph,
23 validate as validateGraph,
24 topoSort,
25 DEFAULT_FRAME_DURATION_SEC,
26 } from '@html-video/content-graph';
27 import { HtmlVideoError } from './errors.js';
28 import type { AssetStore } from './asset-store.js';
29 import type { EngineRegistry, ProjectStore, TemplateRegistry } from './registry.js';
30
31 export interface CreateProjectInput {
32 name: string;
33 intent?: string;
34 preferences?: Project['preferences'];
35 }
36
37 export interface ProjectOrchestratorDeps {
38 projectRoot: string;
39 engines: EngineRegistry;
40 templates: TemplateRegistry;
41 projects: ProjectStore;
42 assets: AssetStore;
43 }
44
45 export class ProjectOrchestrator {
46 constructor(private readonly deps: ProjectOrchestratorDeps) {}
47
48 // ---------------- CRUD ----------------
49
50 async create(input: CreateProjectInput): Promise<Project> {
51 const id = `proj_${randomUUID().slice(0, 12)}`;
52 const now = new Date().toISOString();
53 const project: Project = {
54 id,
55 name: input.name,
56 ...(input.intent !== undefined && { intent: input.intent }),
57 assets: [],
58 templateId: null,
59 variables: {},
60 preferences: input.preferences ?? {},
61 status: 'draft',
62 createdAt: now,
63 updatedAt: now,
64 };
65 await this.deps.projects.save(project);
66 return project;
67 }
68
69 async list(): Promise<Project[]> {
70 return this.deps.projects.list();
71 }
72
73 async load(id: string): Promise<Project> {
74 return this.deps.projects.load(id);
75 }
76
77 async remove(id: string): Promise<void> {
78 return this.deps.projects.remove(id);
79 }
80
81 // ---------------- Asset ops ----------------
82
83 async addFileAsset(projectId: string, sourcePath: string, userCaption?: string): Promise<Project> {
84 await this.deps.projects.ensureDir(projectId);
85 const project = await this.deps.projects.load(projectId);
86 const asset = await this.deps.assets.addFileAsset(projectId, sourcePath, [], userCaption);
87 if (!project.assets.find((a) => a.id === asset.id)) {
88 project.assets.push(asset);
89 }
90 project.status = downgradeStatus(project.status, 'draft');
91 await this.deps.projects.save(project);
92 return project;
93 }
94
95 async addInlineAsset(
96 projectId: string,
97 content: string,
98 type: 'text' | 'data',
99 userCaption?: string,
100 ): Promise<Project> {
101 await this.deps.projects.ensureDir(projectId);
102 const project = await this.deps.projects.load(projectId);
103 const asset = await this.deps.assets.addInlineAsset(projectId, content, type, [], userCaption);
104 if (!project.assets.find((a) => a.id === asset.id)) {
105 project.assets.push(asset);
106 }
107 project.status = downgradeStatus(project.status, 'draft');
108 await this.deps.projects.save(project);
109 return project;
110 }
111
112 /**
113 * Store generated audio bytes (MP3 from MiniMax) as a project asset and
114 * return the created Asset so the caller can reference it in `soundtrack`.
115 * Unlike addFileAsset, this does NOT downgrade status — attaching a
116 * soundtrack to an already-previewed video shouldn't invalidate the render.
117 */
118 async addBufferAsset(
119 projectId: string,
120 bytes: Buffer,
121 ext: string,
122 userCaption?: string,
123 ): Promise<{ project: Project; asset: Asset }> {
124 await this.deps.projects.ensureDir(projectId);
125 const project = await this.deps.projects.load(projectId);
126 const asset = await this.deps.assets.addBufferAsset(projectId, bytes, ext, [], userCaption);
127 if (!project.assets.find((a) => a.id === asset.id)) {
128 project.assets.push(asset);
129 }
130 await this.deps.projects.save(project);
131 return { project, asset };
132 }
133
134 async removeAsset(projectId: string, assetId: string): Promise<Project> {
135 const project = await this.deps.projects.load(projectId);
136 project.assets = project.assets.filter((a) => a.id !== assetId);
137 await this.deps.projects.save(project);
138 return project;
139 }
140
141 // ---------------- Template / variables ----------------
142
143 async setTemplate(projectId: string, templateId: string | null): Promise<Project> {
144 const project = await this.deps.projects.load(projectId);
145 if (templateId !== null && !this.deps.templates.has(templateId)) {
146 throw new HtmlVideoError('template-not-found', `Template ${templateId} not found`);
147 }
148 project.templateId = templateId;
149 // v0.3: variables are no longer the user-facing surface. Reset on every
150 // template change so old keys don't bleed through into the new context.
151 project.variables = {};
152 project.status = downgradeStatus(project.status, 'draft');
153 await this.deps.projects.save(project);
154 return project;
155 }
156
157 async setVariables(projectId: string, vars: Record<string, unknown>): Promise<Project> {
158 const project = await this.deps.projects.load(projectId);
159 project.variables = vars;
160 project.status = downgradeStatus(project.status, 'draft');
161 await this.deps.projects.save(project);
162 return project;
163 }
164
165 async setVariable(projectId: string, key: string, value: unknown): Promise<Project> {
166 const project = await this.deps.projects.load(projectId);
167 project.variables = { ...project.variables, [key]: value };
168 project.status = downgradeStatus(project.status, 'draft');
169 await this.deps.projects.save(project);
170 return project;
171 }
172
173 async setAgent(projectId: string, agentId: string | null, agentModel?: string | null): Promise<Project> {
174 const project = await this.deps.projects.load(projectId);
175 project.agentId = agentId;
176 // Only touch the model when explicitly provided; switching agent clears a
177 // stale model unless a new one is given.
178 if (agentModel !== undefined) project.agentModel = agentModel;
179 else project.agentModel = null;
180 await this.deps.projects.save(project);
181 return project;
182 }
183
184 /**
185 * v0.3 chat-to-HTML: write raw HTML produced by an agent into the project's preview slot.
186 * Single-frame fast-path. Clears any prior multi-frame graph state.
187 */
188 async writePreviewHtmlRaw(projectId: string, html: string): Promise<{ project: Project; htmlPath: string }> {
189 const project = await this.deps.projects.load(projectId);
190 const projectDir = await this.deps.projects.ensureDir(projectId);
191 const { writeFile } = await import('node:fs/promises');
192 const { join } = await import('node:path');
193 const htmlPath = join(projectDir, 'preview.html');
194 await writeFile(htmlPath, html, 'utf8');
195 project.lastPreviewHtmlPath = htmlPath;
196 // v0.8.2: only treat this as a "supersedes the storyboard" event for
197 // truly fresh single-frame projects (no frames yet). For projects that
198 // already have a storyboard, the single-frame raw write is treated as
199 // an in-place inline edit on the active preview file — frames[] /
200 // contentGraphPath are preserved so the user doesn't lose their
201 // storyboard if they happen to use a single-frame iteration on a
202 // multi-frame project. (Frame-specific in-place edits should go
203 // through writeFrameHtml instead.)
204 if ((project.frames?.length ?? 0) === 0) {
205 project.frames = [];
206 delete project.contentGraphPath;
207 }
208 if (project.status === 'draft') project.status = 'previewed';
209 await this.deps.projects.save(project);
210 return { project, htmlPath };
211 }
212
213 // ---------------- v0.8: ContentGraph + multi-frame ----------------
214
215 /**
216 * Persist a content graph alongside the project. Validates first, throws
217 * on cycles / unknown edges / etc.
218 */
219 async writeContentGraph(
220 projectId: string,
221 graph: ContentGraph,
222 opts: { preserveFrames?: boolean } = {},
223 ): Promise<{ project: Project; graphPath: string }> {
224 const result = validateGraph(graph);
225 if (!result.ok) {
226 throw new HtmlVideoError(
227 'invalid-input',
228 `ContentGraph invalid: ${result.errors.map((e) => e.message).join('; ')}`,
229 );
230 }
231 const project = await this.deps.projects.load(projectId);
232 const projectDir = await this.deps.projects.ensureDir(projectId);
233 const { writeFile, mkdir } = await import('node:fs/promises');
234 const { join } = await import('node:path');
235 const graphPath = join(projectDir, 'content-graph.json');
236 await writeFile(graphPath, JSON.stringify(graph, null, 2), 'utf8');
237 project.contentGraphPath = graphPath;
238 await mkdir(join(projectDir, 'frames'), { recursive: true });
239 if (opts.preserveFrames) {
240 // Editing an existing storyboard's metadata (e.g. re-pacing durations) —
241 // keep the rendered frames, just sync each frame's durationSec from the
242 // graph so export uses the new timing.
243 const byId = new Map(graph.nodes.map((n) => [n.id, n.durationSec]));
244 project.frames = (project.frames ?? []).map((f) => ({
245 ...f,
246 durationSec: byId.get(f.graphNodeId) ?? f.durationSec,
247 }));
248 } else {
249 // Fresh graph → agent will re-emit per-frame HTML; drop stale frames.
250 project.frames = [];
251 if (project.status !== 'rendered') project.status = 'draft';
252 }
253 await this.deps.projects.save(project);
254 return { project, graphPath };
255 }
256
257 /**
258 * Read the persisted content graph. Returns null if none.
259 */
260 async readContentGraph(projectId: string): Promise<ContentGraph | null> {
261 const project = await this.deps.projects.load(projectId);
262 if (!project.contentGraphPath) return null;
263 const { readFile } = await import('node:fs/promises');
264 const { existsSync } = await import('node:fs');
265 if (!existsSync(project.contentGraphPath)) return null;
266 return JSON.parse(await readFile(project.contentGraphPath, 'utf8')) as ContentGraph;
267 }
268
269 /**
270 * Write one frame's HTML to disk. Updates the project's frames[] list,
271 * keeping play-order consistent with the graph's topo sort.
272 *
273 * Frame filenames follow `<order>-<nodeId>.html` for visual debuggability.
274 */
275 async writeFrameHtml(
276 projectId: string,
277 graphNodeId: string,
278 html: string,
279 ): Promise<{ project: Project; frame: FrameRecord }> {
280 const project = await this.deps.projects.load(projectId);
281 const graph = await this.readContentGraph(projectId);
282 if (!graph) {
283 throw new HtmlVideoError(
284 'invalid-input',
285 'Cannot write frame: project has no content graph yet',
286 );
287 }
288 const order = topoSort(graph);
289 const idx = order.indexOf(graphNodeId);
290 if (idx === -1) {
291 throw new HtmlVideoError(
292 'invalid-input',
293 `Graph node "${graphNodeId}" not found in content graph`,
294 );
295 }
296 const node = graph.nodes.find((n) => n.id === graphNodeId)!;
297
298 const projectDir = await this.deps.projects.ensureDir(projectId);
299 const { writeFile, mkdir } = await import('node:fs/promises');
300 const { join } = await import('node:path');
301 const framesDir = join(projectDir, 'frames');
302 await mkdir(framesDir, { recursive: true });
303 const safeId = graphNodeId.replace(/[^a-z0-9_-]/gi, '_');
304 const filename = `${String(idx + 1).padStart(2, '0')}-${safeId}.html`;
305 const htmlPath = join(framesDir, filename);
306 await writeFile(htmlPath, html, 'utf8');
307
308 const frame: FrameRecord = {
309 graphNodeId,
310 htmlPath,
311 durationSec: node.durationSec ?? DEFAULT_FRAME_DURATION_SEC,
312 order: idx,
313 };
314 project.frames = (project.frames ?? []).filter((f) => f.graphNodeId !== graphNodeId);
315 project.frames.push(frame);
316 project.frames.sort((a, b) => a.order - b.order);
317 // First frame becomes the project preview when no single-frame HTML exists.
318 if (project.frames[0]?.graphNodeId === graphNodeId) {
319 project.lastPreviewHtmlPath = htmlPath;
320 }
321 if (project.status === 'draft') project.status = 'previewed';
322 await this.deps.projects.save(project);
323 return { project, frame };
324 }
325
326 // ---------------- Render: preview HTML / export MP4 ----------------
327
328 async renderPreviewHtml(projectId: string): Promise<{ project: Project; htmlPath: string }> {
329 const project = await this.deps.projects.load(projectId);
330 if (!project.templateId) {
331 throw new HtmlVideoError('invalid-input', 'Project has no template selected');
332 }
333 const tmpl = this.deps.templates.get(project.templateId);
334 const adapter = this.deps.engines.get(tmpl.engine);
335 if (!adapter.renderToHtml) {
336 throw new HtmlVideoError(
337 'render-failed',
338 `Engine ${tmpl.engine} adapter does not support renderToHtml()`,
339 );
340 }
341 const projectDir = await this.deps.projects.ensureDir(projectId);
342
343 const out = await adapter.renderToHtml(
344 {
345 template: templateRefFromMeta(tmpl),
346 variables: project.variables,
347 config: {
348 format: 'mp4',
349 resolution: project.preferences.resolution ?? { width: 1920, height: 1080 },
350 fps: project.preferences.fps ?? 60,
351 duration: 'auto',
352 outputPath: join(projectDir, 'output.mp4'),
353 },
354 },
355 { workDir: projectDir },
356 );
357
358 project.lastPreviewHtmlPath = out.htmlPath;
359 project.lastPreviewPosterPath = out.posterPath;
360 if (project.status === 'draft') project.status = 'previewed';
361 await this.deps.projects.save(project);
362 return { project, htmlPath: out.htmlPath };
363 }
364
365 async exportMp4(args: {
366 projectId: string;
367 outputPath?: string;
368 onProgress?: (pct: number, stage: string) => void;
369 signal?: AbortSignal;
370 }): Promise<{ project: Project; outputPath: string }> {
371 const project = await this.deps.projects.load(args.projectId);
372 const projectDir = await this.deps.projects.ensureDir(project.id);
373 // Unique per-export filename so repeated exports of the SAME project don't
374 // overwrite each other (different projects already have separate dirs).
375 // output.mp4 stays as a stable "latest" alias updated after each export.
376 const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
377 const outputPath = args.outputPath ?? join(projectDir, `output-${stamp}.mp4`);
378
379 // v0.8: multi-frame path. If the project has frames[] from a content graph,
380 // render each frame's HTML to a per-frame MP4, then ffmpeg concat them.
381 if (project.frames && project.frames.length > 0) {
382 const ordered = [...project.frames].sort((a, b) => a.order - b.order);
383 const tmpl = project.templateId ? this.deps.templates.get(project.templateId) : null;
384 const projectEngine = tmpl?.engine ?? 'hyperframes';
385 const frameMp4s: string[] = [];
386 // Mixed engines across frames → the per-frame MP4s may carry different
387 // h264 params (hyperframes' libx264 vs Remotion's encoder), so a stream
388 // -c copy concat can stutter/corrupt. Re-encode the join in that case.
389 const enginesUsed = new Set(ordered.map((f) => f.engine ?? projectEngine));
390 const reencode = enginesUsed.size > 1;
391
392 for (let i = 0; i < ordered.length; i++) {
393 const f = ordered[i]!;
394 const frameOut = join(projectDir, 'frames', `${String(i + 1).padStart(2, '0')}.mp4`);
395 const { engine: frameEngine, templateRef } = this.resolveFrameTemplateRef(f, projectEngine);
396 const adapter = this.deps.engines.get(frameEngine);
397 await adapter.render(
398 {
399 template: templateRef,
400 // Native data templates read `data` from variables; bridge/hyperframes
401 // ignore it. Frame's own data (when enhanced) overrides project vars.
402 variables: f.data !== undefined ? { ...project.variables, data: f.data } : project.variables,
403 config: {
404 format: 'mp4',
405 resolution: project.preferences.resolution ?? { width: 1920, height: 1080 },
406 fps: project.preferences.fps ?? 60,
407 duration: f.durationSec,
408 // The user set per-frame length on the format card — honor it as a
409 // hard cap so one runaway animation can't stretch a 4s frame to ~30s.
410 durationMode: 'explicit',
411 outputPath: frameOut,
412 },
413 },
414 {
415 workDir: projectDir,
416 ...(args.onProgress !== undefined && {
417 onProgress: (pct, stage) =>
418 args.onProgress!((i + pct / 100) / ordered.length * 100, `frame ${i + 1}/${ordered.length}: ${stage}`),
419 }),
420 ...(args.signal !== undefined && { signal: args.signal }),
421 },
422 );
423 frameMp4s.push(frameOut);
424 }
425
426 await concatFramesWithFfmpeg(frameMp4s, outputPath, projectDir, {
427 reencode,
428 fps: project.preferences.fps ?? 60,
429 });
430 const totalDur = ordered.reduce((s, f) => s + (f.durationSec || 0), 0);
431 await this.applySoundtrack(project, outputPath, totalDur, args.onProgress);
432 project.lastOutputMp4Path = outputPath;
433 recordExport(project, outputPath);
434 project.status = 'rendered';
435 await this.deps.projects.save(project);
436 return { project, outputPath };
437 }
438
439 // Single-frame fast path (v0.7 behaviour).
440 if (!project.templateId) {
441 throw new HtmlVideoError('invalid-input', 'Project has no template selected');
442 }
443 const tmpl = this.deps.templates.get(project.templateId);
444 const adapter = this.deps.engines.get(tmpl.engine);
445
446 await adapter.render(
447 {
448 template: templateRefFromMeta(tmpl),
449 variables: project.variables,
450 config: {
451 format: 'mp4',
452 resolution: project.preferences.resolution ?? { width: 1920, height: 1080 },
453 fps: project.preferences.fps ?? 60,
454 duration: 'auto',
455 outputPath,
456 },
457 },
458 {
459 workDir: projectDir,
460 ...(args.onProgress !== undefined && { onProgress: args.onProgress }),
461 ...(args.signal !== undefined && { signal: args.signal }),
462 },
463 );
464 await this.applySoundtrack(project, outputPath, undefined, args.onProgress);
465 project.lastOutputMp4Path = outputPath;
466 recordExport(project, outputPath);
467 project.status = 'rendered';
468 await this.deps.projects.save(project);
469 return { project, outputPath };
470 }
471
472 /**
473 * Resolve which engine + TemplateRef render a single frame. A frame that the
474 * user has enhanced (engine='remotion' + nativeTemplateId) renders via the
475 * native template's .tsx entry; otherwise it's the classic per-frame HTML on
476 * the project's engine (hyperframes). The base `htmlPath` is always retained
477 * on the frame so un-enhancing is non-destructive. (RFC-08/09)
478 */
479 private resolveFrameTemplateRef(
480 f: FrameRecord,
481 projectEngine: EngineId,
482 ): { engine: EngineId; templateRef: TemplateRef } {
483 if (f.engine === 'remotion' && f.nativeTemplateId) {
484 const nt = this.deps.templates.get(f.nativeTemplateId);
485 if (!nt.native?.compositionId) {
486 throw new HtmlVideoError(
487 'template-invalid',
488 `Native template "${f.nativeTemplateId}" has no native.compositionId in its metadata`,
489 );
490 }
491 if (!nt.__dir) {
492 throw new HtmlVideoError(
493 'template-invalid',
494 `Native template "${f.nativeTemplateId}" has no __dir; was it loaded via TemplateRegistry?`,
495 );
496 }
497 return {
498 engine: 'remotion',
499 templateRef: {
500 id: `frame-${f.graphNodeId}`,
501 engine: 'remotion',
502 sourcePath: join(nt.__dir, nt.source_entry),
503 mode: 'native',
504 nativeCompositionId: nt.native.compositionId,
505 },
506 };
507 }
508 const engine = f.engine ?? projectEngine;
509 return {
510 engine,
511 templateRef: { id: `frame-${f.graphNodeId}`, engine, sourcePath: f.htmlPath },
512 };
513 }
514
515 /**
516 * Enhance one data frame with a native engine template (the user-initiated
517 * "motion enhancement" — RFC-08/09). Snapshots the source DataNode's `data`
518 * onto the frame and points it at the native template. Asserts the node is a
519 * `data` node and that its data fits the native template's expected shape, so
520 * export doesn't later render NaN bars. The frame's `htmlPath` is untouched,
521 * so {@link unenhanceFrame} fully reverts it.
522 */
523 async enhanceFrameNative(
524 projectId: string,
525 graphNodeId: string,
526 nativeTemplateId: string,
527 ): Promise<{ project: Project; frame: FrameRecord }> {
528 const project = await this.deps.projects.load(projectId);
529 const graph = await this.readContentGraph(projectId);
530 if (!graph) {
531 throw new HtmlVideoError('invalid-input', 'Project has no content graph');
532 }
533 const node = graph.nodes.find((n) => n.id === graphNodeId);
534 if (!node) {
535 throw new HtmlVideoError('invalid-input', `Graph node "${graphNodeId}" not found`);
536 }
537 if (node.kind !== 'data') {
538 throw new HtmlVideoError(
539 'invalid-input',
540 `Frame "${graphNodeId}" is a ${node.kind} node; native data enhancement only applies to data frames`,
541 );
542 }
543 const tmpl = this.deps.templates.get(nativeTemplateId); // throws if unknown
544 if (tmpl.engine !== 'remotion' || !tmpl.native?.compositionId) {
545 throw new HtmlVideoError(
546 'invalid-input',
547 `Template "${nativeTemplateId}" is not a native Remotion template`,
548 );
549 }
550 const data = normalizeRollupData((node as { data?: unknown }).data);
551
552 const frame = (project.frames ?? []).find((f) => f.graphNodeId === graphNodeId);
553 if (!frame) {
554 throw new HtmlVideoError(
555 'invalid-input',
556 `Frame "${graphNodeId}" has not been rendered yet (no FrameRecord)`,
557 );
558 }
559 frame.engine = 'remotion';
560 frame.nativeTemplateId = nativeTemplateId;
561 frame.data = data;
562 await this.deps.projects.save(project);
563 return { project, frame };
564 }
565
566 /**
567 * Revert a frame's native enhancement back to its base hyperframes HTML.
568 * Clears the three enhance fields; `htmlPath` was never touched. (RFC-08/09)
569 */
570 async unenhanceFrame(
571 projectId: string,
572 graphNodeId: string,
573 ): Promise<{ project: Project; frame: FrameRecord }> {
574 const project = await this.deps.projects.load(projectId);
575 const frame = (project.frames ?? []).find((f) => f.graphNodeId === graphNodeId);
576 if (!frame) {
577 throw new HtmlVideoError('invalid-input', `Frame "${graphNodeId}" not found`);
578 }
579 delete frame.engine;
580 delete frame.nativeTemplateId;
581 delete frame.data;
582 delete frame.previewMp4Path; // stop advertising a now-stale preview video
583 await this.deps.projects.save(project);
584 return { project, frame };
585 }
586
587 /**
588 * Render a single (enhanced) frame to a short MP4 for studio preview. A native
589 * frame has no HTML to show in the iframe strip, so the studio renders it on
590 * its own and plays the result as a <video>. Reuses {@link resolveFrameTemplateRef}
591 * — the same per-frame engine/template resolution exportMp4 uses — so the
592 * preview is pixel-identical to what the final export will stitch in.
593 *
594 * Writes to `frames/<order>.preview.mp4` (distinct from export's `frames/NN.mp4`
595 * so the two never overwrite each other). No soundtrack mux — a per-frame
596 * preview is silent and faster. Sets `frame.previewMp4Path` and saves (bumping
597 * `updatedAt`, which the studio uses as the <video> cache-bust token).
598 */
599 async renderFrameNativePreview(args: {
600 projectId: string;
601 graphNodeId: string;
602 onProgress?: (pct: number, stage: string) => void;
603 signal?: AbortSignal;
604 }): Promise<{ project: Project; frame: FrameRecord; previewPath: string }> {
605 const project = await this.deps.projects.load(args.projectId);
606 const projectDir = await this.deps.projects.ensureDir(project.id);
607 const frame = (project.frames ?? []).find((f) => f.graphNodeId === args.graphNodeId);
608 if (!frame) {
609 throw new HtmlVideoError('invalid-input', `Frame "${args.graphNodeId}" not found`);
610 }
611 const tmpl = project.templateId ? this.deps.templates.get(project.templateId) : null;
612 const projectEngine = tmpl?.engine ?? 'hyperframes';
613 const { engine, templateRef } = this.resolveFrameTemplateRef(frame, projectEngine);
614 const adapter = this.deps.engines.get(engine);
615
616 const previewPath = join(projectDir, 'frames', `${String(frame.order + 1).padStart(2, '0')}.preview.mp4`);
617 await adapter.render(
618 {
619 template: templateRef,
620 variables: frame.data !== undefined ? { ...project.variables, data: frame.data } : project.variables,
621 config: {
622 format: 'mp4',
623 resolution: project.preferences.resolution ?? { width: 1920, height: 1080 },
624 fps: project.preferences.fps ?? 60,
625 duration: frame.durationSec,
626 durationMode: 'explicit',
627 outputPath: previewPath,
628 },
629 },
630 {
631 workDir: projectDir,
632 ...(args.onProgress !== undefined && { onProgress: args.onProgress }),
633 ...(args.signal !== undefined && { signal: args.signal }),
634 },
635 );
636
637 frame.previewMp4Path = previewPath;
638 await this.deps.projects.save(project);
639 return { project, frame, previewPath };
640 }
641
642 /**
643 * If the project has a soundtrack (music and/or narration), mux it into the
644 * just-rendered video at `outputPath`. Renders to a temp file then renames
645 * over the original. No-op when there's no soundtrack. Audio generation
646 * never depends on ffmpeg — only this export-time mux does.
647 */
648 private async applySoundtrack(
649 project: Project,
650 outputPath: string,
651 videoDurationSec: number | undefined,
652 onProgress?: (pct: number, stage: string) => void,
653 ): Promise<void> {
654 const st = project.soundtrack;
655 if (!st || (!st.musicAssetId && !st.narrationAssetId)) return;
656
657 const findPath = (id?: string): string | undefined =>
658 id ? project.assets.find((a) => a.id === id)?.path : undefined;
659 const musicPath = findPath(st.musicAssetId);
660 const narrationPath = findPath(st.narrationAssetId);
661 if (!musicPath && !narrationPath) return; // referenced assets are gone
662
663 onProgress?.(99, 'mixing audio');
664 const { rename } = await import('node:fs/promises');
665 const tmpOut = `${outputPath}.muxed.mp4`;
666 // MiniMax music is a fixed ~50s clip regardless of request; `-shortest`
667 // already trims it to the video length, but a hard cut sounds abrupt.
668 // Default a gentle fade-out (≤ a third of the clip, capped 1.5s) when the
669 // user hasn't set one and we know the video length.
670 const defaultFadeOut =
671 musicPath && videoDurationSec && videoDurationSec > 2
672 ? Math.min(1.5, videoDurationSec / 3)
673 : 0;
674 const fadeOutSec = st.fadeOutSec ?? defaultFadeOut;
675 await muxAudioWithFfmpeg({
676 videoPath: outputPath,
677 outputPath: tmpOut,
678 ...(musicPath !== undefined && { musicPath }),
679 ...(narrationPath !== undefined && { narrationPath }),
680 ...(st.musicVolumeDb !== undefined && { musicVolumeDb: st.musicVolumeDb }),
681 ...(st.narrationVolumeDb !== undefined && { narrationVolumeDb: st.narrationVolumeDb }),
682 ...(st.fadeInSec !== undefined && { fadeInSec: st.fadeInSec }),
683 ...(fadeOutSec > 0 && { fadeOutSec }),
684 ...(videoDurationSec !== undefined && { videoDurationSec }),
685 });
686 await rename(tmpOut, outputPath);
687 }
688 }
689
690 // ---------------------------------------------------------------------------
691 // ffmpeg concat helper
692 // ---------------------------------------------------------------------------
693
694 /**
695 * Coerce a content-graph DataNode's free-form `data` into the shape the native
696 * frame-data-rollup template expects ({ title?, unit?, items: {label,value}[] }).
697 * DataNode.data is `unknown`, so an enhanced frame could otherwise feed NaN bars
698 * to the renderer. Accepts either the already-shaped object or a bare array of
699 * {label,value}. Throws a clear error rather than rendering garbage.
700 */
701 function normalizeRollupData(raw: unknown): { title?: string; unit?: string; items: { label: string; value: number }[] } {
702 const asItems = (arr: unknown): { label: string; value: number }[] => {
703 if (!Array.isArray(arr)) {
704 throw new HtmlVideoError(
705 'invalid-input',
706 'Data frame has no `items` array to animate; expected {items:[{label,value}]}',
707 );
708 }
709 const items = arr
710 .map((it) => {
711 const o = (it ?? {}) as Record<string, unknown>;
712 const label = String(o.label ?? o.name ?? '');
713 const value = Number(o.value ?? o.y ?? o.count);
714 return { label, value };
715 })
716 .filter((it) => it.label !== '' && Number.isFinite(it.value));
717 if (items.length === 0) {
718 throw new HtmlVideoError(
719 'invalid-input',
720 'Data frame items had no usable {label, numeric value} pairs',
721 );
722 }
723 return items;
724 };
725
726 if (Array.isArray(raw)) return { items: asItems(raw) };
727 const o = (raw ?? {}) as Record<string, unknown>;
728 const out: { title?: string; unit?: string; items: { label: string; value: number }[] } = {
729 items: asItems(o.items),
730 };
731 if (typeof o.title === 'string') out.title = o.title;
732 if (typeof o.unit === 'string') out.unit = o.unit;
733 return out;
734 }
735
736 /**
737 * Concatenate per-frame MP4 files into a single output with ffmpeg.
738 *
739 * Two strategies:
740 * - Single-engine (default): the concat **demuxer** with `-c copy`. All frames
741 * came from one engine so their h264 streams are byte-compatible — fast, no
742 * re-encode.
743 * - Mixed-engine (`opts.reencode`): a hyperframes frame next to a native
744 * Remotion frame can differ in profile/GOP/**timebase**. The concat demuxer
745 * assumes continuous timestamps across segments and mis-accumulates the
746 * Remotion segment's PTS, ballooning the total duration. So we feed each
747 * segment as an independent input and join with the concat **filter**, which
748 * rebuilds a clean timeline, then re-encode to a uniform h264.
749 *
750 * Requires `ffmpeg` on PATH. Throws with a friendly hint if missing.
751 */
752 async function concatFramesWithFfmpeg(
753 frameMp4s: string[],
754 outputPath: string,
755 workDir: string,
756 opts: { reencode?: boolean; fps?: number } = {},
757 ): Promise<void> {
758 if (frameMp4s.length === 0) {
759 throw new HtmlVideoError('render-failed', 'No frames to concat');
760 }
761 const { writeFile } = await import('node:fs/promises');
762 const { join } = await import('node:path');
763 const { spawn } = await import('node:child_process');
764
765 const fps = opts.fps ?? 60;
766 let ffmpegArgs: string[];
767
768 if (opts.reencode) {
769 // concat FILTER: independent `-i` per segment + filter rebuilds the timeline.
770 const n = frameMp4s.length;
771 const inputs = frameMp4s.flatMap((p) => ['-i', p]);
772 const filter = `${frameMp4s.map((_, i) => `[${i}:v]`).join('')}concat=n=${n}:v=1:a=0[v]`;
773 ffmpegArgs = [
774 '-y',
775 ...inputs,
776 '-filter_complex', filter,
777 '-map', '[v]',
778 '-c:v', 'libx264',
779 '-pix_fmt', 'yuv420p',
780 '-r', String(fps),
781 '-movflags', '+faststart',
782 outputPath,
783 ];
784 } else {
785 // concat DEMUXER + stream copy: needs the list file.
786 const listPath = join(workDir, 'frames', 'concat.txt');
787 const list = frameMp4s.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join('\n');
788 await writeFile(listPath, list, 'utf8');
789 ffmpegArgs = ['-y', '-f', 'concat', '-safe', '0', '-i', listPath, '-c', 'copy', outputPath];
790 }
791
792 await new Promise<void>((resolveFn, reject) => {
793 const proc = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
794 let stderr = '';
795 proc.stderr.on('data', (chunk: Buffer) => {
796 stderr += chunk.toString('utf8');
797 });
798 proc.on('error', (err: NodeJS.ErrnoException) => {
799 if (err.code === 'ENOENT') {
800 reject(
801 new HtmlVideoError(
802 'render-failed',
803 'ffmpeg not found on PATH. Install with `brew install ffmpeg` (macOS) or your platform equivalent.',
804 ),
805 );
806 } else {
807 reject(err);
808 }
809 });
810 proc.on('exit', (code: number | null) => {
811 if (code === 0) resolveFn();
812 else
813 reject(
814 new HtmlVideoError(
815 'render-failed',
816 `ffmpeg concat exited with code ${code}: ${stderr.slice(-2000)}`,
817 ),
818 );
819 });
820 });
821 }
822
823 /**
824 * Mix a background-music track and/or a narration track into a (silent) video
825 * file, writing the result to `outputPath`. Video is stream-copied (no
826 * re-encode); audio is encoded to AAC. Music is ducked under narration via a
827 * volume offset, optional fade in/out is applied to the music, and `-shortest`
828 * keeps the result aligned to the video length.
829 *
830 * `videoPath` and `outputPath` must differ. Throws HtmlVideoError on ffmpeg
831 * failure; a missing ffmpeg yields the same friendly hint as concat.
832 */
833 async function muxAudioWithFfmpeg(args: {
834 videoPath: string;
835 outputPath: string;
836 musicPath?: string;
837 narrationPath?: string;
838 musicVolumeDb?: number;
839 narrationVolumeDb?: number;
840 fadeInSec?: number;
841 fadeOutSec?: number;
842 videoDurationSec?: number;
843 }): Promise<void> {
844 const { spawn } = await import('node:child_process');
845 const hasMusic = !!args.musicPath;
846 const hasNarration = !!args.narrationPath;
847 if (!hasMusic && !hasNarration) return; // nothing to mix
848
849 const musicVol = args.musicVolumeDb ?? -18;
850 const narrVol = args.narrationVolumeDb ?? 0;
851 const fadeIn = args.fadeInSec ?? 0;
852 const fadeOut = args.fadeOutSec ?? 0;
853
854 // Inputs: [0] video, then music / narration in order.
855 const inputs: string[] = ['-i', args.videoPath];
856 let musicIdx = -1;
857 let narrIdx = -1;
858 let next = 1;
859 if (hasMusic) { inputs.push('-i', args.musicPath!); musicIdx = next++; }
860 if (hasNarration) { inputs.push('-i', args.narrationPath!); narrIdx = next++; }
861
862 // Build a filter graph producing a single [aout] label.
863 const filters: string[] = [];
864 const mixLabels: string[] = [];
865 if (hasMusic) {
866 let chain = `[${musicIdx}:a]volume=${musicVol}dB`;
867 if (fadeIn > 0) chain += `,afade=t=in:st=0:d=${fadeIn}`;
868 // Fade-out only when we know where the end is.
869 if (fadeOut > 0 && args.videoDurationSec && args.videoDurationSec > fadeOut) {
870 chain += `,afade=t=out:st=${(args.videoDurationSec - fadeOut).toFixed(2)}:d=${fadeOut}`;
871 }
872 chain += '[bg]';
873 filters.push(chain);
874 mixLabels.push('[bg]');
875 }
876 if (hasNarration) {
877 filters.push(`[${narrIdx}:a]volume=${narrVol}dB[vo]`);
878 mixLabels.push('[vo]');
879 }
880 if (mixLabels.length === 2) {
881 filters.push(`${mixLabels[0]}${mixLabels[1]}amix=inputs=2:duration=longest:dropout_transition=0[aout]`);
882 } else {
883 // single source → relabel to [aout]
884 filters.push(`${mixLabels[0]}anull[aout]`);
885 }
886
887 const ffArgs = [
888 '-y',
889 ...inputs,
890 '-filter_complex', filters.join(';'),
891 '-map', '0:v',
892 '-map', '[aout]',
893 '-c:v', 'copy',
894 '-c:a', 'aac',
895 '-b:a', '192k',
896 '-shortest',
897 args.outputPath,
898 ];
899
900 await new Promise<void>((resolveFn, reject) => {
901 const proc = spawn('ffmpeg', ffArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
902 let stderr = '';
903 proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
904 proc.on('error', (err: NodeJS.ErrnoException) => {
905 if (err.code === 'ENOENT') {
906 reject(new HtmlVideoError(
907 'render-failed',
908 'ffmpeg not found on PATH. Install with `brew install ffmpeg` (macOS) or your platform equivalent.',
909 ));
910 } else {
911 reject(err);
912 }
913 });
914 proc.on('exit', (code: number | null) => {
915 if (code === 0) resolveFn();
916 else reject(new HtmlVideoError('render-failed', `ffmpeg audio mux exited with code ${code}: ${stderr.slice(-2000)}`));
917 });
918 });
919 }
920
921 // ---------------------------------------------------------------------------
922 // Helpers
923 // ---------------------------------------------------------------------------
924
925 /** Append this export to the project's history (newest last, de-duped by path,
926 * capped so it doesn't grow unbounded). */
927 function recordExport(project: Project, outputPath: string): void {
928 const list = (project.exports ?? []).filter((e) => e.path !== outputPath);
929 list.push({ path: outputPath, filename: basename(outputPath), createdAt: new Date().toISOString() });
930 // Keep the most recent 20.
931 project.exports = list.slice(-20);
932 }
933
934 function templateRefFromMeta(meta: TemplateMetadata) {
935 if (!meta.__dir) {
936 throw new HtmlVideoError(
937 'template-invalid',
938 `Template ${meta.id} has no __dir set; was it loaded via TemplateRegistry?`,
939 );
940 }
941 return {
942 id: meta.id,
943 engine: meta.engine,
944 sourcePath: join(meta.__dir, meta.source_entry),
945 };
946 }
947
948 function downgradeStatus(current: ProjectStatus, target: ProjectStatus): ProjectStatus {
949 // After any modification, status should not be more advanced than 'draft'/given target.
950 // 'rendered' / 'previewed' get demoted back to 'draft' on any meaningful change.
951 if (target === 'draft') return 'draft';
952 return current;
953 }
954
955
955 lines TYPESCRIPT