Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/guides/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions docs/guides/user-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,7 @@ AI 助手可通过共用编辑命令修改同一份已保存时间线。交接
| 视频无法合成或导出 | 检查浏览器的编码支持,以及素材是否能正常读取和播放;合成期间保持页面和编辑器打开 |
| 任务中断或提交状态不明 | 查看“生成历史”;可恢复任务使用“继续查询原任务”,状态不明时先核对服务端是否已接收,避免重复提交 |
| 修改没有保存 | 保持页面打开,检查“已保存到本机”状态和错误提示,处理保存失败后再关闭或切换 |

### 在画布上输出时间线

选中未锁定的时间线节点,点击顶部工具栏的“输出视频”。焦点不在文本输入框或对话框中时,macOS 可按 **⌘Enter**,Windows/Linux 可按 **Ctrl+Enter** 执行同一操作。输出沿用已保存时间线的画幅、帧率、片段和音轨。开始输出后,时间线下方立即显示带动画、进度和取消按钮的视频占位节点。发布成功后,同一节点显示可播放的 MP4,并以来源线连接时间线。重复输出会为该视频节点新增版本,可通过版本入口预览和恢复之前的输出。原时间线仍可继续编辑;失败时可在占位节点上重试。渲染期间请保持页面打开;可取消渲染。发布失败后重试会复用已渲染视频和原操作标识。
23 changes: 17 additions & 6 deletions packages/core/src/opendirector/application/creation_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/opendirector/domain/creation/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.')

Expand Down Expand Up @@ -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)
Expand All @@ -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':
Expand Down
1 change: 1 addition & 0 deletions scripts/run-browser-suite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
5 changes: 4 additions & 1 deletion src/backend/creationProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
28 changes: 24 additions & 4 deletions src/backend/timelinePublication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,45 @@ 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: {
itemId: string;
editorId: string;
asset: Awaited<ReturnType<CreationProjection["importResource"]>>;
outputId: string;
expectedOutputRevision?: number;
title: string;
position: { x: number; y: number };
};
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/canvas/CanvasNodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,7 +19,7 @@ import {
import { useI18n } from "../i18n/I18nProvider.tsx";
import type { MessageKey } from "../i18n/types.ts";

export type CanvasFlowNode = Node<CanvasNodeData, CanvasNodeKind>;
export type CanvasFlowNode = Node<CanvasNodeData & { timelineOutput?: Parameters<typeof TimelinePendingVideo>[0]["output"] }, CanvasNodeKind>;

const kindNameKeys: Record<CanvasNodeKind, MessageKey> = {
image: "canvasNodes.kind.image",
Expand Down Expand Up @@ -58,6 +59,8 @@ function CanvasNodeContents({ id, data, selected }: NodeProps<CanvasFlowNode>) {
else actions?.select(id, event);
};

if (data.timelineOutput) return <TimelinePendingVideo id={id} selected={selected} output={data.timelineOutput} />;

return (
<NodeFrame
id={id}
Expand Down
9 changes: 7 additions & 2 deletions src/canvas/GeneratingOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,17 @@ function ProgressRing({ progress }: { progress: number }) {
export function GeneratingOverlay({
activity,
hasAsset,
progress: actualProgress,
label,
}: {
activity: NodeGenerationActivity;
hasAsset: boolean;
progress?: number;
label?: string;
}) {
const t = useT();
const progress = useProgress(activity);
const estimate = useProgress(activity);
const progress = actualProgress ?? estimate;
return (
<div
className={`cw-generating${hasAsset ? " cw-generating-with-asset" : ""}`}
Expand All @@ -110,7 +115,7 @@ export function GeneratingOverlay({
{progress !== null && <ProgressRing progress={progress} />}
<div className="cw-generating-caption" role="status" aria-atomic="true">
<span className="cw-generating-indicator" aria-hidden="true" />
<span>{t(GENERATION_LABELS[activity.phase])}</span>
<span>{label ?? t(GENERATION_LABELS[activity.phase])}</span>
{activity.count > 1 && (
<span className="cw-generating-count">
{t("generation.overlay.tasks", { count: activity.count })}
Expand Down
8 changes: 8 additions & 0 deletions src/canvas/NodeSelectionToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface NodeSelectionToolbarProps {
presets?: readonly { value: string | number; label: string }[];
onPreset?: (preset: string) => void;
onEdit?: (mode: SelectionEditorMode) => void;
onOutput?: () => void;
outputBusy?: boolean;
onCaptureFrame?: (position: "first" | "last") => void;
onGridSplit?: (rows: number, columns: number) => void;
onPreview?: () => void;
Expand Down Expand Up @@ -109,6 +111,8 @@ export function NodeSelectionToolbar({
presets = [],
onPreset,
onEdit,
onOutput,
outputBusy = false,
onCaptureFrame,
onGridSplit,
onPreview,
Expand All @@ -129,6 +133,9 @@ export function NodeSelectionToolbar({
if (mediaKind && !hasMedia) return null;

const editing: Action[] = [];
if (node.type === "timeline" && onOutput) {
editing.push({ id: "output", label: t("timeline.output.title"), icon: "arrowUp", run: onOutput, disabled: locked || outputBusy });
}
const toolOption = (
tool: CanvasToolId,
label: string,
Expand Down Expand Up @@ -486,6 +493,7 @@ export function NodeSelectionToolbar({
aria-controls={
menuAction?.id === action.id ? menuId : undefined
}
shortcut={action.id === "output" ? (navigator.platform.includes("Mac") ? "⌘ Enter" : "Ctrl Enter") : undefined}
label={
action.disabled
? t("canvas.selection.locked", { label: action.label })
Expand Down
Loading
Loading