| 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 |