From 0ec429db2ae87d30a675e93e0186aedcd927b9af Mon Sep 17 00:00:00 2001 From: chenlb Date: Mon, 15 Jun 2026 17:36:46 +0800 Subject: [PATCH] fix(studio): single-frame export uses wrong content, ignores duration setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Wrong content: exportMp4 always rendered the template's original source HTML, ignoring the agent-generated preview saved to lastPreviewHtmlPath. Now uses lastPreviewHtmlPath when present. 2. Duration ignored: exportMp4 hardcoded `duration: 'auto'`, causing the adapter to fall back to the animation's natural length (~4s) instead of the user's setting. durationTargetSec was defined in UserPreferences but never written or read. - studio-server now persists collected.duration to preferences.durationTargetSec at generate time (covers both single-frame and multi-frame paths). - exportMp4 reads durationTargetSec and passes it as durationMode: 'explicit' so the adapter honors it as a hard cap. 3. UI showed render time not video duration: the "MP4 已导出 · Xs" badge was displaying elapsed_ms (wall-clock render time) instead of the actual video length. export_done SSE event now carries duration_sec; the studio UI uses that instead. --- packages/cli/src/studio-server.ts | 16 +++++++++++++++- packages/core/src/project.ts | 13 +++++++++++-- packages/project-studio/public/app.js | 2 +- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/studio-server.ts b/packages/cli/src/studio-server.ts index 6888cba..c64dbfb 100644 --- a/packages/cli/src/studio-server.ts +++ b/packages/cli/src/studio-server.ts @@ -398,7 +398,11 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< process.stderr.write( `[studio:export] proj=${projectId} done in ${ms}ms → ${outputPath}\n`, ); - sse({ type: 'export_done', output_path: outputPath, project, elapsed_ms: ms }); + // Compute the actual video duration to show in the UI (not the render wall-clock time). + const videoDurationSec = (project.frames && project.frames.length > 0) + ? project.frames.reduce((s, f) => s + (f.durationSec || 0), 0) + : (project.preferences.durationTargetSec ?? null); + sse({ type: 'export_done', output_path: outputPath, project, elapsed_ms: ms, duration_sec: videoDurationSec }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`[studio:export] proj=${projectId} failed: ${msg}\n`); @@ -958,6 +962,16 @@ export async function startStudioServer(ctx: CliContext, port: number): Promise< let textChunks = 0; let summaryLine = ''; + // Persist user duration preference at generate time so exportMp4 can + // honor it regardless of single-frame vs multi-frame path. + if (phaseInfo.phase === 'generate' && phaseInfo.inputs.collected) { + const parsedDur = Number(phaseInfo.inputs.collected.duration ?? '') || 0; + if (parsedDur > 0) { + project.preferences = { ...project.preferences, durationTargetSec: parsedDur }; + await ctx.projects.save(project); + } + } + // ---- generate-phase: multi-frame path runs split (graph + per-frame) ---- // Empirically claude --print returns 1 byte ~50% of the time when asked // to emit a graph and 4-6 full HTML pages in a single response. Each diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 0c54217..3ea701c 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -443,15 +443,24 @@ export class ProjectOrchestrator { const tmpl = this.deps.templates.get(project.templateId); const adapter = this.deps.engines.get(tmpl.engine); + // Use the agent-generated HTML if available; fall back to the template source. + const singleFrameTemplateRef = project.lastPreviewHtmlPath + ? { id: tmpl.id, engine: tmpl.engine, sourcePath: project.lastPreviewHtmlPath } + : templateRefFromMeta(tmpl); + // Honor the user-set duration; fall back to 'auto' so the adapter probes animation length. + const singleFrameDuration = project.preferences.durationTargetSec ?? 'auto'; + const singleFrameDurationMode = typeof singleFrameDuration === 'number' ? 'explicit' as const : undefined; + await adapter.render( { - template: templateRefFromMeta(tmpl), + template: singleFrameTemplateRef, variables: project.variables, config: { format: 'mp4', resolution: project.preferences.resolution ?? { width: 1920, height: 1080 }, fps: project.preferences.fps ?? 60, - duration: 'auto', + duration: singleFrameDuration, + ...(singleFrameDurationMode && { durationMode: singleFrameDurationMode }), outputPath, }, }, diff --git a/packages/project-studio/public/app.js b/packages/project-studio/public/app.js index 13785b2..689538b 100644 --- a/packages/project-studio/public/app.js +++ b/packages/project-studio/public/app.js @@ -192,7 +192,7 @@ async function startExportStream() { state.exporting = false; state.exportProgress = null; if (ev.project) state.selected = ev.project; - const seconds = ev.elapsed_ms ? `${(ev.elapsed_ms / 1000).toFixed(1)}s` : ''; + const seconds = ev.duration_sec != null ? `${Number(ev.duration_sec).toFixed(1)}s` : ''; state.messages.push({ role: 'preview-event', content: seconds ? t('export.done_seconds', { seconds }) : t('export.done_no_seconds'),