diff --git a/docs/guides/user-guide.md b/docs/guides/user-guide.md index dedc3237..00375f00 100644 --- a/docs/guides/user-guide.md +++ b/docs/guides/user-guide.md @@ -193,3 +193,7 @@ Saving projects locally does not imply offline inference. Generation prompts and | A video cannot be rendered or exported | Check your browser's encoding support and whether the assets can be read and played correctly. Keep the page and editor open while rendering | | A task is interrupted, or its submission status is unknown | Check **Generation history**. Use **Continue checking the original task** for recoverable tasks. If the status is unknown, check whether the server accepted the task before submitting again | | Changes have not been saved | Keep the page open, check the **Saved locally** status and any error messages, and resolve the save failure before closing or switching | + +### Output a timeline from the canvas + +Select an unlocked timeline node and use **Output video** in its toolbar. **⌘Enter** on macOS or **Ctrl+Enter** on Windows/Linux performs the same action when focus is outside a text field or dialog. The saved timeline supplies the output size, frame rate, clips and audio. A pending video node appears below the timeline immediately, with rendering progress and a cancel control. Successful publication replaces that preview with the playable MP4 under the same node identity, connected to its timeline by an output-source line. Repeated output adds a version to that video node; use its version picker to preview or restore earlier outputs. The editable timeline remains available. Failed output can be retried from the pending node. Keep the page open while rendering. Cancel stops rendering; retrying a failed publication reuses the rendered video and operation identity. diff --git a/docs/guides/user-guide.zh-CN.md b/docs/guides/user-guide.zh-CN.md index 377ba8cf..8a64f09f 100644 --- a/docs/guides/user-guide.zh-CN.md +++ b/docs/guides/user-guide.zh-CN.md @@ -193,3 +193,7 @@ AI 助手可通过共用编辑命令修改同一份已保存时间线。交接 | 视频无法合成或导出 | 检查浏览器的编码支持,以及素材是否能正常读取和播放;合成期间保持页面和编辑器打开 | | 任务中断或提交状态不明 | 查看“生成历史”;可恢复任务使用“继续查询原任务”,状态不明时先核对服务端是否已接收,避免重复提交 | | 修改没有保存 | 保持页面打开,检查“已保存到本机”状态和错误提示,处理保存失败后再关闭或切换 | + +### 在画布上输出时间线 + +选中未锁定的时间线节点,点击顶部工具栏的“输出视频”。焦点不在文本输入框或对话框中时,macOS 可按 **⌘Enter**,Windows/Linux 可按 **Ctrl+Enter** 执行同一操作。输出沿用已保存时间线的画幅、帧率、片段和音轨。开始输出后,时间线下方立即显示带动画、进度和取消按钮的视频占位节点。发布成功后,同一节点显示可播放的 MP4,并以来源线连接时间线。重复输出会为该视频节点新增版本,可通过版本入口预览和恢复之前的输出。原时间线仍可继续编辑;失败时可在占位节点上重试。渲染期间请保持页面打开;可取消渲染。发布失败后重试会复用已渲染视频和原操作标识。 diff --git a/packages/core/src/opendirector/application/creation_canvas.py b/packages/core/src/opendirector/application/creation_canvas.py index 68faf53a..1d6e9d19 100644 --- a/packages/core/src/opendirector/application/creation_canvas.py +++ b/packages/core/src/opendirector/application/creation_canvas.py @@ -463,13 +463,24 @@ def _publish_render(self, data, payload): for ref in (*sources, payload.asset): self._available(self.assets._reference(snapshot, ref)) asset = self._get(data, 'asset', output.id) - asset['provenance'] = dict(source='rendered', editorDocumentId=str(doc.id), editorRevision=doc.revision, + existing = next((i for i in snapshot.canvasItems if i.id == payload.outputId), None) + if existing is not None: + if payload.expectedOutputRevision is None: + raise ContractError('revision_conflict', '输出节点已存在,请提供其当前版本。', payload.outputId) + target = self._editable(data, payload.outputId) + previous = current_asset(snapshot, existing) + if (existing.kind != 'video' or existing.binding.role != 'material' or previous is None + or previous.provenance.source != 'rendered' or previous.provenance.editorDocumentId != doc.id + or previous.provenance.sourceItemId not in {None, payload.itemId}): + raise ContractError('role_restricted', '输出节点不属于当前时间线。', payload.outputId) + target['binding'] = dict(role='material', objectRef=payload.asset.model_dump(mode='json')) + else: + result = VideoCanvasItem(id=payload.outputId, projectId=snapshot.project.id, kind='video', title=payload.title, + position=payload.position, binding=dict(role='material', objectRef=payload.asset)) + data['canvasItems'].append(result.model_dump(mode='json')) + asset['provenance'] = dict(source='rendered', sourceItemId=str(payload.itemId), + editorDocumentId=str(doc.id), editorRevision=doc.revision, sources=[ref.model_dump(mode='json') for ref in sources]) - if any(i['id'] == str(payload.outputId) for i in data['canvasItems']): - raise ContractError('invalid_input', '输出节点标识已存在。', payload.outputId) - result = VideoCanvasItem(id=payload.outputId, projectId=snapshot.project.id, kind='video', title=payload.title, - position=payload.position, binding=dict(role='material', objectRef=payload.asset)) - data['canvasItems'].append(result.model_dump(mode='json')) def _mutate(self, data, kind, payload): value = payload.model_dump(mode='json', exclude_unset=True) diff --git a/packages/core/src/opendirector/domain/creation/commands.py b/packages/core/src/opendirector/domain/creation/commands.py index 2e02ff8c..87c7a9b6 100644 --- a/packages/core/src/opendirector/domain/creation/commands.py +++ b/packages/core/src/opendirector/domain/creation/commands.py @@ -131,6 +131,7 @@ class TimelinePublishRender(Contract): editorId: UUID = Field(description='Editable document ID.') asset: AssetRef = Field(description='Immutable asset version.') outputId: UUID = Field(description='Published canvas output ID.') + expectedOutputRevision: Revision | None = Field(default=None, exclude_if=lambda value: value is None, description='Current output node revision when appending a rendered version.') title: str = Field(default='合成视频', max_length=255, description='User-visible title.') position: Point = Field(default_factory=Point, description='Position in the owning canvas or stage.') @@ -269,6 +270,8 @@ def scope_and_versions(self): raise ValueError('expectedVersions missing current versions for ' + ', '.join(missing)) if self.type == 'generation.submit' and versions[('generation_spec', payload.specId)] != payload.expectedSpecRevision: raise ValueError('Submission version differs from expected spec version') + if self.type == 'timeline.publishRender' and payload.expectedOutputRevision is not None and versions[('canvas_item', payload.outputId)] != payload.expectedOutputRevision: + raise ValueError('Output revision differs from its expected version') if self.type in {'generationSpec.update', 'editor.save', 'reference.update', 'reference.restore'}: obj = (payload.spec if self.type == 'generationSpec.update' else payload.document if self.type == 'editor.save' else payload.reference) @@ -288,6 +291,8 @@ def required_versions(self) -> list[tuple[str, UUID]]: required.append(('editor_document', payload.sourceEditorId)) elif self.type in {'canvas.applyEdit', 'timeline.publishRender'}: required = [('canvas_item', payload.itemId), ('asset', payload.asset.assetId), ('editor_document', payload.editorId)] + if self.type == 'timeline.publishRender' and payload.expectedOutputRevision is not None: + required.append(('canvas_item', payload.outputId)) elif self.type in {'canvas.update', 'canvas.hide', 'canvas.materialize', 'canvas.applyEdit', 'canvas.configure', 'canvas.attachEditor', 'canvas.detachEditor', 'canvas.applyVersion'}: required = [('canvas_item', payload.itemId)] elif self.type == 'canvas.move': diff --git a/scripts/run-browser-suite.mjs b/scripts/run-browser-suite.mjs index b64bb957..7762b5c9 100644 --- a/scripts/run-browser-suite.mjs +++ b/scripts/run-browser-suite.mjs @@ -13,6 +13,7 @@ import { spawnSync } from "node:child_process"; const workflows = [ + { name: "timeline node output", script: "tests/browser/timeline_node_output.mjs" }, { name: "locale", script: "tests/browser/locale.mjs" }, { name: "creation closure", script: "scripts/verify_creation_browser.mjs" }, { name: "assistant activity", script: "tests/browser/assistant_activity.mjs" }, diff --git a/src/backend/creationProjection.ts b/src/backend/creationProjection.ts index 00e1b858..18eef8f9 100644 --- a/src/backend/creationProjection.ts +++ b/src/backend/creationProjection.ts @@ -899,7 +899,10 @@ export class CreationProjection { } for (const item of snapshot.canvasItems) { const asset = currentAsset(snapshot, item); - const sourceId = asset?.provenance.sourceItemId; + const timelineSources = asset?.provenance.source === "rendered" && asset.provenance.editorDocumentId + ? snapshot.canvasItems.filter((source) => source.kind === "timeline" && source.binding.role === "editor" && source.binding.objectRef.id === asset.provenance.editorDocumentId) + : []; + const sourceId = asset?.provenance.sourceItemId ?? (timelineSources.length === 1 ? timelineSources[0].id : undefined); if (sourceId && snapshot.canvasItems.some((i) => i.id === sourceId)) document.edges.push({ id: `publication:${item.id}:${asset!.id}`, diff --git a/src/backend/timelinePublication.ts b/src/backend/timelinePublication.ts index d12a2439..5b2a7a59 100644 --- a/src/backend/timelinePublication.ts +++ b/src/backend/timelinePublication.ts @@ -4,20 +4,37 @@ import { CreationClient, objectVersion } from "./creationClient.ts"; import { CreationProjection } from "./creationProjection.ts"; import { tNow } from "../i18n/messages.ts"; +export function timelineOutputItem(snapshot: ProjectSnapshot, itemId: string) { + const source = snapshot.canvasItems.find((item) => item.id === itemId); + if (source?.binding.role !== "editor") return undefined; + const editorId = source.binding.objectRef.id; + return [...snapshot.canvasItems].reverse().find((item) => { + if (item.kind !== "video" || item.binding.role !== "material" || !item.visible) return false; + const assetId = item.binding.objectRef.assetId; + const origin = snapshot.assets.find((asset) => asset.id === assetId)?.provenance; + return origin?.source === "rendered" && origin.editorDocumentId === editorId && + (!origin.sourceItemId || origin.sourceItemId === itemId); + }); +} + /** Captured when rendering starts, independent of subsequent editor changes. */ export function timelinePublication( client: CreationClient, snapshot: ProjectSnapshot, itemId: string, + position?: { x: number; y: number }, + outputId?: string, ) { const item = snapshot.canvasItems.find((item) => item.id === itemId)!; if (item.binding.role !== "editor") throw new Error(tNow("errors.backend.timelineSourceMissing")); const editorId = item.binding.objectRef.id; const editor = snapshot.editorDocuments.find((doc) => doc.id === editorId)!; + const existing = timelineOutputItem(snapshot, itemId); + outputId ??= existing?.id ?? crypto.randomUUID(); + const target = snapshot.canvasItems.find((item) => item.id === outputId); const projection = new CreationProjection(client); - const commandId = crypto.randomUUID(), - outputId = crypto.randomUUID(); + const commandId = crypto.randomUUID(); let delivery: | { payload: { @@ -25,6 +42,7 @@ export function timelinePublication( editorId: string; asset: Awaited>; outputId: string; + expectedOutputRevision?: number; title: string; position: { x: number; y: number }; }; @@ -33,21 +51,23 @@ export function timelinePublication( | undefined; return async (result: LinkedMediaResult) => { if (!delivery) { - const asset = await projection.importResource(result); + const asset = await projection.importResource({ ...result, fresh: true, identity: commandId }); delivery = { payload: { itemId, editorId, asset, outputId, + ...(target ? { expectedOutputRevision: target.revision } : {}), title: result.title, - position: { + position: position ?? { x: item.position.x + item.width + 80, y: item.position.y, }, }, versions: [ objectVersion("canvas_item", item), + ...(target ? [objectVersion("canvas_item", target)] : []), objectVersion("editor_document", editor), objectVersion("asset", { id: asset.assetId, diff --git a/src/canvas/CanvasNodeView.tsx b/src/canvas/CanvasNodeView.tsx index 5a22474f..7c8684b9 100644 --- a/src/canvas/CanvasNodeView.tsx +++ b/src/canvas/CanvasNodeView.tsx @@ -9,6 +9,7 @@ import { SubjectNodeCard } from "./SubjectNodeCard"; import { CanvasIcon } from "./CanvasIcon"; import { TooltipButton } from "./TooltipButton"; import { GeneratingOverlay } from "./GeneratingOverlay"; +import { TimelinePendingVideo } from "./TimelineOutput"; import { CanvasVideoPreview } from "./CanvasMediaPreview"; import "./nodes.css"; import { @@ -18,7 +19,7 @@ import { import { useI18n } from "../i18n/I18nProvider.tsx"; import type { MessageKey } from "../i18n/types.ts"; -export type CanvasFlowNode = Node; +export type CanvasFlowNode = Node[0]["output"] }, CanvasNodeKind>; const kindNameKeys: Record = { image: "canvasNodes.kind.image", @@ -58,6 +59,8 @@ function CanvasNodeContents({ id, data, selected }: NodeProps) { else actions?.select(id, event); }; + if (data.timelineOutput) return ; + return ( }