Merge remote-tracking branch 'origin/main'

This commit is contained in:
Rou
2026-09-05 13:06:19 +08:00
26 changed files with 773 additions and 110 deletions

View File

@@ -829,7 +829,8 @@ export function CanvasAssistantPanel({
}
const ops = onlineToolToOps(name, args, current, effectiveConfig);
const result = await executeOps(ops, { source: "online", conversationId: sessionId, messageId: messageId || sessionId });
return { ok: result.ok, message: result.changed ? canvasAgentPostconditionMessage(result) : result.noopReason, data: result };
const { snapshot: _snapshot, before: _before, after: _after, ...cleanData } = result;
return { ok: result.ok, message: result.changed ? canvasAgentPostconditionMessage(result) : result.noopReason, data: cleanData };
} catch (error) {
if (isAgentSessionPollingAbort(error)) throw error;
return { ok: false, message: error instanceof Error ? error.message : "工具执行失败" };

View File

@@ -16,7 +16,7 @@ export type EditorHostContextValue = {
/** 项目资产列表(M3.4 asset-ingest 消费;由宿主在进入编辑器时获取)。 */
assets: ProjectAsset[];
/** 重新拉取项目资产(导入媒体后刷新列表)。 */
refreshAssets: () => Promise<void>;
refreshAssets: () => Promise<ProjectAsset[] | null>;
};
const EditorStoreContext = createContext<EditorStoreHook | null>(null);

View File

@@ -34,15 +34,17 @@ const IMAGE_RE = /\.(png|jpe?g|webp|gif|avif)$/i;
const AUDIO_RE = /\.(mp3|wav|m4a|ogg|flac|aac)$/i;
const MEDIA_RE = /\.(mp4|mov|webm|mkv|m4v|avi)$/i;
/** 本地上传标记(assetFromUploadedResource 写入 payload.data.source)。 */
/** 本地上传标记与画布产物标记(assetFromUploadedResource 写入 payload.data.source)。 */
const SOURCE_UPLOADED = "uploaded";
const SOURCE_CANVAS = "canvas";
type AssetFilter = "all" | "project" | "uploaded";
type AssetFilter = "all" | "project" | "uploaded" | "canvas";
const FILTER_TABS: { id: AssetFilter; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "project", label: "项目素材" },
{ id: "uploaded", label: "本地上传" },
{ id: "canvas", label: "画布素材" },
];
/** 按 MIME 与扩展名推断媒体 kind;非媒体文件返回 null。 */
@@ -201,15 +203,16 @@ function MediaPreview({ asset }: { asset: ProjectAsset }) {
function SourceBadge({ source }: { source: string }) {
const uploaded = source === SOURCE_UPLOADED;
const canvas = source === SOURCE_CANVAS;
return (
<span
className={`rounded px-1 py-px text-[9px] leading-none ${
uploaded
uploaded || canvas
? "bg-[var(--director-control-hover)] text-[var(--director-dock-fg-strong)]"
: "bg-[var(--director-control-hover)] text-[var(--director-dock-fg)]/70"
}`}
>
{uploaded ? "本地上传" : "项目素材"}
{uploaded ? "本地上传" : canvas ? "画布素材" : "项目素材"}
</span>
);
}
@@ -230,7 +233,7 @@ export function EditorAssetIngest() {
const [filter, setFilter] = useState<AssetFilter>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean }>({ uploaded: true, project: true });
const [openGroups, setOpenGroups] = useState<{ uploaded: boolean; project: boolean; canvas: boolean }>({ uploaded: true, project: true, canvas: true });
const inputRef = useRef<HTMLInputElement | null>(null);
const lastAddKey = useRef<string | null>(null);
const lastAddAt = useRef(0);
@@ -264,6 +267,7 @@ export function EditorAssetIngest() {
});
const skipped = media.length - toImport.length;
// 逐文件导入:单文件失败不中断整批,汇总失败数提示。
const linkedIds: string[] = [];
for (const { file, kind } of toImport) {
try {
// 上传前探测真实时长(视频/音频),随 meta 入库供时间线片段使用。
@@ -291,6 +295,7 @@ export function EditorAssetIngest() {
continue;
}
okCount += 1;
linkedIds.push(resource.id);
} catch (err) {
failedNames.push(file.name);
const detail = extractApiMessage(err);
@@ -298,7 +303,12 @@ export function EditorAssetIngest() {
}
}
if (okCount > 0) {
await refreshAssets();
// 刷新后校验本次挂载的素材是否都出现在列表里;若单次刷新因网络抖动
// 或后端提交延迟而拿不到最新结果,立即再刷新一次,避免列表停留在旧快照。
const firstList = await refreshAssets();
if (firstList && linkedIds.some((id) => !firstList.some((asset) => asset.id === id))) {
await refreshAssets();
}
setImportNote(skipped > 0 ? `已导入 ${okCount} 个,跳过 ${skipped} 个重复文件` : `已导入 ${okCount} 个媒体`);
} else if (skipped > 0 && failedNames.length === 0) {
setImportNote(`媒体库中已有同名素材,跳过 ${skipped} 个重复文件`);
@@ -336,9 +346,11 @@ export function EditorAssetIngest() {
};
const uploadedAssets = assets.filter((a) => a.source === SOURCE_UPLOADED);
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED);
const groups: { id: "uploaded" | "project"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
const canvasAssets = assets.filter((a) => a.source === SOURCE_CANVAS);
const projectAssets = assets.filter((a) => a.source !== SOURCE_UPLOADED && a.source !== SOURCE_CANVAS);
const groups: { id: "uploaded" | "project" | "canvas"; label: string; icon: typeof HardDrive; items: ProjectAsset[] }[] = [
{ id: "uploaded", label: "本地上传", icon: HardDrive, items: uploadedAssets },
{ id: "canvas", label: "画布素材", icon: Boxes, items: canvasAssets },
{ id: "project", label: "项目素材", icon: Boxes, items: projectAssets },
];
const visibleGroups = groups.filter((g) => filter === "all" || g.id === filter);
@@ -416,7 +428,7 @@ export function EditorAssetIngest() {
) : visibleGroups.every((g) => g.items.length === 0) ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-4 text-center">
<p className="text-xs text-[var(--director-dock-fg)]/60">
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
{filter === "uploaded" ? "暂无本地上传的媒体" : filter === "canvas" ? "暂无画布素材,请先在画布工作台生成分镜/预演" : filter === "project" ? "暂无项目素材" : "项目暂无资产"}
</p>
<p className="max-w-[180px] text-[11px] leading-relaxed text-[var(--director-dock-fg)]/45">点击上方导入媒体,或将文件拖入此区域</p>
</div>
@@ -487,7 +499,7 @@ export function EditorAssetIngest() {
<dt className="text-[var(--director-dock-fg)]/60">分类</dt>
<dd className="truncate text-right text-[var(--director-dock-fg-strong)]">{asset.category}</dd>
<dt className="text-[var(--director-dock-fg)]/60">来源</dt>
<dd className="text-right text-[var(--director-dock-fg-strong)]">{source === SOURCE_UPLOADED ? "本地上传" : "项目素材"}</dd>
<dd className="text-right text-[var(--director-dock-fg-strong)]">{source === SOURCE_UPLOADED ? "本地上传" : source === SOURCE_CANVAS ? "画布素材" : "项目素材"}</dd>
{asset.durationMs ? (
<>
<dt className="text-[var(--director-dock-fg)]/60">时长</dt>

View File

@@ -11,7 +11,25 @@
// - 静音跟随所在音频/视频轨道(muted),不再硬编码 muted(否则"有声音"永远不成立)。
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronDown, Film, Gauge, Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
import {
AlertTriangle,
Captions,
ChevronDown,
Download,
Film,
Gauge,
Image as ImageIcon,
LoaderCircle,
Pause,
Play,
SkipBack,
SkipForward,
StepBack,
StepForward,
Type,
Video,
Volume2,
} from "lucide-react";
import { useEditorStoreContext } from "@/components/editor/editor-context";
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
@@ -67,6 +85,31 @@ function clipSourceTimeMs(clip: TimelineClip, transportMs: number): number {
return Math.max(0, transportMs - clip.startMs + (clip.sourceStartMs ?? 0));
}
function clipKindLabel(kind: TimelineClip["kind"]): string {
switch (kind) {
case "video":
return "视频";
case "image":
return "图片";
case "audio":
return "音频";
case "subtitle":
return "字幕";
case "text":
return "文字";
}
}
function clipDisplayTitle(clip: TimelineClip): string {
return clip.directMedia?.title || clip.title || clip.text || clip.nodeId || clip.id;
}
function ClipKindIcon({ kind, className }: { kind: TimelineClip["kind"]; className?: string }) {
const Icon =
kind === "video" ? Video : kind === "image" ? ImageIcon : kind === "audio" ? Volume2 : kind === "subtitle" ? Captions : Type;
return <Icon className={className} />;
}
export function EditorPreviewMonitor() {
const { project, transportMs, setTransportMs } = useEditorStoreContext();
const durationMs = project?.durationMs ?? 0;
@@ -95,11 +138,15 @@ export function EditorPreviewMonitor() {
const [mediaTier, setMediaTier] = useState<"primary" | "variant">("primary");
const [variantReadyTick, setVariantReadyTick] = useState(0);
const [mediaErrorHint, setMediaErrorHint] = useState<string | null>(null);
// 终态护栏:none/failed/超时/不可达后禁止再回退 primary 重试或覆盖终态文案。
// 否则原件不可解码且后端无副本可生成时会 primary→variant→primary 无限循环。
const mediaTerminalRef = useRef(false);
useEffect(() => {
setMediaTier("primary");
setVariantReadyTick(0);
setMediaErrorHint(null);
mediaTerminalRef.current = false;
}, [activeClip?.id, storageKey, activeMediaUrl]);
useEffect(() => {
@@ -116,16 +163,19 @@ export function EditorPreviewMonitor() {
setVariantReadyTick((n) => n + 1);
window.clearInterval(timer);
} else if (res.playbackStatus === "failed") {
mediaTerminalRef.current = true;
setMediaErrorHint("兼容副本生成失败,可下载原片后用本地播放器观看。");
window.clearInterval(timer);
} else if (res.playbackStatus === "none") {
// 后端判定无需转码(远端存储/无 ffmpeg/编码不可处理):无副本可等,
// 停轮询回到原件原生播放,避免"正在生成"永挂。
setMediaTier("primary");
setMediaErrorHint(null);
// 后端判定无播放副本可生成(远端存储/无 ffmpeg/编码不可处理)。
// 原件此刻必然已 onError 失败才进入 variant 轮询,回退 primary 只会
// 再次失败并切回 variant,形成无限循环 —— 直接进入终态提示并停轮询。
mediaTerminalRef.current = true;
setMediaErrorHint("视频编码此浏览器暂不支持,且无可生成的兼容副本;可下载原片转换格式后重新导入。");
window.clearInterval(timer);
} else if ((polls += 1) >= 120) {
// processing 上限保护(约 5 分钟):转码异常卡死时不再无限轮询。
mediaTerminalRef.current = true;
setMediaErrorHint("兼容版本生成超时,可下载原片后用本地播放器观看。");
window.clearInterval(timer);
} else {
@@ -136,6 +186,7 @@ export function EditorPreviewMonitor() {
if (cancelled) return;
failures += 1;
if (failures >= 4) {
mediaTerminalRef.current = true;
window.clearInterval(timer);
setMediaErrorHint("兼容版本生成服务暂不可达,请稍后重新打开预览重试。");
}
@@ -148,9 +199,12 @@ export function EditorPreviewMonitor() {
}, [mediaTier, mediaResourceId]);
const handleMediaError = () => {
// 已进入终态(副本生成失败/无副本可生成/超时/服务不可达):保持终态文案,
// 不再改写为"正在生成"或回退重试 —— 副本 URL 在 failed/none 时会回退原件,
// 反复 onError 会把失败提示覆盖成误导性的"正在生成兼容版本"。
if (mediaTerminalRef.current) return;
// 图片/无资源:无播放副本可切,仅提示。
if (activeClip?.kind !== "video" || !mediaResourceId) {
setMediaErrorHint("该媒体无法解码,可下载原片后用本地播放器观看。");
return;
}
if (mediaTier === "primary") {
@@ -351,6 +405,52 @@ export function EditorPreviewMonitor() {
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-[var(--director-sequencer-surface)]">
{/* 顶部信息条:媒体标题/元数据常驻在画面外的标题栏,不再悬浮遮挡画面内容 */}
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-[var(--director-sequencer-border)] bg-[var(--director-sequencer-surface-raised)] px-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
{activeClip ? (
<>
<ClipKindIcon kind={activeClip.kind} className="size-3.5 shrink-0 text-[var(--director-dock-fg)]/55" />
<span className="truncate text-xs font-medium text-[var(--director-dock-fg-strong)]" title={clipDisplayTitle(activeClip)}>
{clipDisplayTitle(activeClip)}
</span>
<span className="hidden shrink-0 rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--director-dock-fg)]/85 sm:inline-flex">
{clipKindLabel(activeClip.kind)}
</span>
{activeTrack && (
<span className="hidden shrink-0 rounded-full border border-[var(--director-sequencer-border)] px-1.5 py-0.5 text-[10px] leading-none text-[var(--director-dock-fg)]/55 md:inline-flex">
{activeTrack.label}
</span>
)}
</>
) : (
<span className="truncate text-xs text-[var(--director-dock-fg)]/40">预览监视器</span>
)}
</div>
{mediaErrorHint && (
<span
title={mediaErrorHint}
className="flex min-w-0 max-w-[42%] items-center gap-1.5 rounded-md bg-[var(--director-danger)]/10 px-2 py-1 text-[11px] text-[var(--director-danger)]"
>
{mediaErrorHint.includes("正在生成") ? (
<LoaderCircle className="size-3 shrink-0 animate-spin" />
) : (
<AlertTriangle className="size-3 shrink-0" />
)}
<span className="truncate">{mediaErrorHint}</span>
{mediaResourceId && (
<a
href={resourceFileUrl(mediaResourceId)}
download
title="下载原片,用本地播放器观看"
className="grid size-4 shrink-0 place-items-center rounded hover:bg-[var(--director-danger)]/15"
>
<Download className="size-3" />
</a>
)}
</span>
)}
</div>
{/* 预览画面:恒黑舞台,占满控制条以上剩余空间(Concat 监视器风格) */}
<div className="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden bg-black p-6">
{showEmpty ? (
@@ -388,25 +488,6 @@ export function EditorPreviewMonitor() {
onError={handleMediaError}
/>
) : null}
{mediaErrorHint && (
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center gap-2 bg-black/70 px-4 py-3 backdrop-blur">
<p className="text-xs leading-relaxed text-white/90">{mediaErrorHint}</p>
{mediaResourceId && (
<a
href={resourceFileUrl(mediaResourceId)}
download
className="text-[11px] underline decoration-white/40 underline-offset-2 text-white/70 hover:text-white"
>
下载原片用本地播放器观看
</a>
)}
</div>
)}
{activeClip && (
<div className="absolute left-4 top-4 rounded-md bg-black/60 px-2 py-1 text-[11px] text-white/90 backdrop-blur">
{activeClip.nodeId || activeClip.id} · {activeClip.kind}
</div>
)}
</div>
{/* 播放控制条:画面下方的独立一行(Concat 布局),不悬浮叠加、无进度条;

View File

@@ -1,7 +1,11 @@
// 字幕工具(editor-shell 预设插件贡献 subtitle-tool 插槽,M3.5)。
// SRT 导入导出(复用 srt-parser)+ 「从节点重建字幕片段」命令(§3.1 快照契约:
// 节点 subtitleEntries 权威 → 重建时间线字幕快照,替换过期快照,单向显式同步)。
// AI 高亮在 M6(editor-ai-assistant + ai.text 权限)接入,此处留占位。
// AI 字幕高亮与关键词标注已在 M6 落地,入口在画布「字幕」弹窗
//(canvas-subtitle-dialog.tsx,走 ai.text 权限门禁):对权威节点字幕条目生成
// 高亮/关键词,并随字幕条目渲染。此处(时间线字幕工具)字幕片段只是快照,
// 编辑器预览尚无字幕文本渲染层,高亮视觉无处挂载,故不在此重复接 AI;
// 待 M4 数据源接入、预览可渲染字幕文本后,再将高亮打通到时间线片段。
import { useMemo, useState } from "react";
@@ -117,10 +121,11 @@ export function EditorSubtitleTools() {
<div className="rounded-md border border-[var(--director-sequencer-border)] bg-[var(--director-control-hover)] p-2.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-[var(--director-dock-fg-strong)]">AI 高亮</span>
<span className="rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80">M6</span>
<span className="rounded-full bg-[var(--director-dock-active-surface)] px-1.5 py-0.5 text-[10px] text-[var(--director-dock-fg)]/80">可用</span>
</div>
<p className="mt-1.5 text-[11px] leading-relaxed text-[var(--director-dock-fg)]/55">
AI 字幕高亮与关键词标注将在 M6(ai-assistant 插件 + ai.text 权限)接入。
AI 字幕高亮与关键词标注已实现,入口在画布「字幕」弹窗(点击字幕 → AI 高亮,走 ai.text 权限)。
时间线字幕片段由该字幕快照重建而来;待编辑器预览支持字幕文本渲染后,高亮会随快照同步到这里。
</p>
</div>

View File

@@ -7,6 +7,7 @@ import { CANVAS_STORE_KEY, flushCanvasStorePersistence, useCanvasStore } from "@
import { CANVAS_HISTORY_STORE_KEY, useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-store";
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { useUserStore } from "@/stores/use-user-store";
import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store";
@@ -38,17 +39,19 @@ export async function applyUserSession(payload: AuthSessionPayload) {
localForageStorage.getItem(PLUGIN_STORE_KEY),
]);
const persistedConfig = scopedLocalStorage.getItem(CONFIG_STORE_KEY);
const persistedCreationPreferences = scopedLocalStorage.getItem(CREATION_PREFERENCES_STORE_KEY);
usePluginStore.setState({ hydrated: false, runtimeStatuses: {}, pluginStates: {} });
useUserStore.getState().setUser(payload.user);
useUserStore.getState().setRuntimeLimits(payload.runtimeLimits);
useUserStore.getState().setDrawingEngine(payload.drawingEngine);
useUserStore.getState().setFeatures(payload.features);
await Promise.all([useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate()]);
await Promise.all([useCanvasStore.persist.rehydrate(), useCanvasHistoryStore.persist.rehydrate(), useAssetStore.persist.rehydrate(), useConfigStore.persist.rehydrate(), usePluginStore.persist.rehydrate(), useCreationPreferencesStore.persist.rehydrate()]);
// Zustand 在目标 scope 没有快照时会保留旧内存,必须显式恢复该 scope 的空状态。
if (!persistedCanvas) useCanvasStore.setState({ projects: [] });
if (!persistedCanvasHistory) useCanvasHistoryStore.setState({ deletedProjects: [] });
if (!persistedAssets) useAssetStore.setState({ assets: [] });
if (!persistedPlugins) usePluginStore.setState({ installations: [], runtimeStatuses: {}, pluginStates: {} });
if (!persistedCreationPreferences) useCreationPreferencesStore.setState({ preferences: {} });
if (!persistedConfig) {
// 只有首次配置缺失时才生成能力推荐;已有配置中的空数组代表用户明确清空。
// 使用统一模型目录接口

View File

@@ -1097,8 +1097,13 @@ function AssetCard({
);
}
function isKnownAssetKind(kind: unknown): kind is AssetKind {
return kind === "image" || kind === "video" || kind === "audio" || kind === "model" || kind === "text";
}
function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; isTrash?: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
const KindIcon = assetKindIcons[asset.kind];
const kind = isKnownAssetKind(asset.kind) ? asset.kind : undefined;
const KindIcon = kind ? assetKindIcons[kind] : FileText;
const clock = asset.kind === "video" || asset.kind === "audio" ? formatAssetClock(asset.data.durationMs) : null;
const showPlay = asset.kind === "video";
const isLight = asset.kind === "audio" || asset.kind === "text" || asset.kind === "model";
@@ -1133,7 +1138,7 @@ function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuIt
<span className="assets-cover-badges">
<span className="assets-cover-badge is-kind">
<KindIcon />
{assetKindLabel(asset.kind)}
{kind ? assetKindLabel(kind) : "素材"}
</span>
{isTrash ? <span className="assets-cover-badge is-category !bg-amber-500/85 !text-white">回收站</span> : <span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>}
</span>
@@ -1320,7 +1325,8 @@ function AssetFilterGroup({
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAsset | null; onClose: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void }) {
const facts = asset ? assetArchiveFacts(asset) : [];
const KindIcon = asset ? assetKindIcons[asset.kind] : Clapperboard;
const kind = asset && isKnownAssetKind(asset.kind) ? asset.kind : undefined;
const KindIcon = asset ? (kind ? assetKindIcons[kind] : FileText) : Clapperboard;
return (
<Drawer className="library-drawer" title="素材档案" open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? (

View File

@@ -36,9 +36,10 @@ import { uploadImage } from "@/services/image-storage";
import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { loadCreationConversations, pendingCreationTaskIds, pendingCreationTaskKey, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
import { loadCreationConversations, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store";
import { recoverCreationTextTask } from "@/services/creation-text-task-recovery";
import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -138,6 +139,11 @@ export default function CreatePage() {
const { message: toast, modal } = App.useApp();
const brandName = useAppearanceStore((state) => state.appearance.brandName);
const config = useEffectiveConfig();
const composerPreferencesHydrated = useCreationPreferencesStore((state) => state.hydrated);
const rememberMode = useCreationPreferencesStore((state) => state.rememberMode);
const rememberImageSettings = useCreationPreferencesStore((state) => state.rememberImageSettings);
const rememberVideoSettings = useCreationPreferencesStore((state) => state.rememberVideoSettings);
const initialComposerPreferences = useCreationPreferencesStore.getState().preferences;
const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
const promptOptimizerProvider = useMemo<PromptOptimizerProvider | null>(() => {
@@ -152,7 +158,7 @@ export default function CreatePage() {
const [activeId, setActiveId] = useState("");
const activeIdRef = useRef("");
const [hydrated, setHydrated] = useState(false);
const [mode, setMode] = useState<CreationMode>("video");
const [mode, setMode] = useState<CreationMode>(() => initialComposerPreferences.mode || "video");
const [prompt, setPrompt] = useState("");
const [attachments, setAttachments] = useState<CreationAttachment[]>([]);
const promptRef = useRef(prompt);
@@ -177,9 +183,11 @@ export default function CreatePage() {
const threadScrollRef = useRef<HTMLElement>(null);
const followLatestMessageRef = useRef(true);
const taskSyncWarningRef = useRef(false);
const activeGenerationTaskIdsRef = useRef(new Set<string>());
const retryPreparingRef = useRef(new Set<string>());
const pendingRetryRef = useRef<{ context: CreationRetryContext; lockKey: string } | null>(null);
const [retrySequence, setRetrySequence] = useState(0);
const [composerPreferencesInitialized, setComposerPreferencesInitialized] = useState(false);
promptRef.current = prompt;
attachmentsRef.current = attachments;
@@ -220,38 +228,58 @@ export default function CreatePage() {
}, [attachments]);
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
const isEmpty = !activeConversation?.messages.length;
const pendingTaskKey = useMemo(() => pendingCreationTaskKey(conversations), [conversations]);
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
const recoveryTaskKey = useMemo(() => pendingTaskIds.filter((id) => !activeGenerationTaskIdsRef.current.has(id)).join("|"), [pendingTaskIds]);
const shots = useMemo(() => shotsFromMessages(activeConversation?.messages || []), [activeConversation]);
const visibleShotIndex = shots.length ? selectedShotIndex >= 0 && selectedShotIndex < shots.length ? selectedShotIndex : shots.length - 1 : -1;
useEffect(() => {
if (mode !== "image") return;
// 前台逻辑模型的默认参数优先于旧的全局创作参数;否则旧的合法值会一直覆盖后台刚配置的默认值。
if (!composerPreferencesHydrated || composerPreferencesInitialized) return;
const saved = useCreationPreferencesStore.getState().preferences;
const nextMode = saved.mode || "video";
setMode(nextMode);
if (nextMode === "image" && saved.image) {
if (saved.image.ratio) setRatio(saved.image.ratio);
if (saved.image.quality) setQuality(saved.image.quality);
if (saved.image.count) setCount(saved.image.count);
}
if (nextMode === "video" && saved.video) {
if (saved.video.ratio) setRatio(saved.video.ratio);
if (saved.video.seconds) setSeconds(saved.video.seconds);
if (saved.video.videoQuality) setVideoQuality(saved.video.videoQuality);
}
setComposerPreferencesInitialized(true);
}, [composerPreferencesHydrated, composerPreferencesInitialized]);
useEffect(() => {
if (!composerPreferencesHydrated || !composerPreferencesInitialized || mode !== "image") return;
const saved = useCreationPreferencesStore.getState().preferences.image;
// 优先恢复用户上次选择;只有当前模型不支持该值时,normalizeImageValue 才回退到模型默认值。
const normalized = normalizeImageValue(imageProfile, {
size: imageProfile.size.default,
quality: imageProfile.quality.default,
count,
size: saved?.ratio || imageProfile.size.default,
quality: saved?.quality || imageProfile.quality.default,
count: saved?.count || count,
});
setRatio(normalized.size);
setQuality(normalized.quality);
setCount(normalized.count);
}, [mode, selectedModel, imageProfile]);
}, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, imageProfile]);
useEffect(() => {
if (mode !== "video") return;
// 前台逻辑模型的默认参数必须直接落到创作端状态,提交任务时才不会被旧状态覆盖。
if (!composerPreferencesHydrated || !composerPreferencesInitialized || mode !== "video") return;
const saved = useCreationPreferencesStore.getState().preferences.video;
// 优先恢复用户上次选择;只有当前模型不支持该值时,normalizeVideoValue 才回退到模型默认值。
const normalized = normalizeVideoValue(videoProfile, {
seconds: String(videoProfile.duration.default),
ratio: videoProfile.defaultRatio,
resolution: videoProfile.defaultResolution,
seconds: saved?.seconds || String(videoProfile.duration.default),
ratio: saved?.ratio || videoProfile.defaultRatio,
resolution: saved?.videoQuality || videoProfile.defaultResolution,
});
setSeconds(normalized.seconds);
setRatio(normalized.ratio);
setVideoQuality(normalized.resolution.replace(/p$/i, ""));
const maxReferences = videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0;
if (attachments.length > maxReferences) setAttachments((current) => current.slice(0, maxReferences));
}, [mode, selectedModel, videoProfile]);
}, [composerPreferencesHydrated, composerPreferencesInitialized, mode, selectedModel, videoProfile]);
useEffect(() => {
const reconciled = reconcileCreationAttachmentLimit(attachments, mentionReferences, maxReferences);
@@ -288,7 +316,10 @@ export default function CreatePage() {
}, [conversations, hydrated]);
useEffect(() => {
if (!hydrated || !pendingTaskKey || !pendingTaskIds.length) return;
if (!hydrated || !recoveryTaskKey || !pendingTaskIds.length) return;
// 当前页面主动提交的任务由 submit 自己等待并收尾;恢复监听只接管刷新前遗留的任务,避免同一任务被双重轮询。
const recoverableTaskIds = pendingTaskIds.filter((id) => !activeGenerationTaskIdsRef.current.has(id));
if (!recoverableTaskIds.length) return;
let cancelled = false;
const observationController = new AbortController();
const applyTasks = async (tasks: GenerationTask[]) => {
@@ -298,14 +329,19 @@ export default function CreatePage() {
taskSyncWarningRef.current = false;
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length));
for (const task of attachable) {
await consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
if (cancelled) return;
await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) =>
applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value,
);
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: generationTaskMaterializedUrls });
try {
await consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
if (cancelled) return;
await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) =>
applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value,
);
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: generationTaskMaterializedUrls });
} catch (error) {
if (cancelled || observationController.signal.aborted) return;
console.warn("创作任务结果挂载失败,将使用已物化结果收敛消息状态", error);
}
}
if (!attachable.length && !cancelled) setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks));
if (!cancelled) setConversations((current) => reconcileCreationTaskMessages(current, persistedTasks));
};
const warnSync = (error: unknown) => {
if (cancelled || observationController.signal.aborted) return;
@@ -316,7 +352,7 @@ export default function CreatePage() {
}
};
let applyChain = Promise.resolve();
const unsubscribe = subscribeGenerationTasks(pendingTaskIds, (task) => {
const unsubscribe = subscribeGenerationTasks(recoverableTaskIds, (task) => {
applyChain = applyChain.then(() => applyTasks([task])).catch(warnSync);
});
return () => {
@@ -324,7 +360,7 @@ export default function CreatePage() {
observationController.abort();
unsubscribe();
};
}, [hydrated, pendingTaskKey, toast]);
}, [hydrated, recoveryTaskKey, toast]);
useEffect(() => {
let cancelled = false;
@@ -366,6 +402,7 @@ export default function CreatePage() {
const selectMode = (next: CreationMode) => {
setMode(next);
rememberMode(next);
const nextModels = selectableModelsByCapability(config, next);
const current = next === "text" ? config.textModel : next === "image" ? config.imageModel : config.videoModel;
if (!nextModels.includes(current) && nextModels[0]) {
@@ -373,6 +410,28 @@ export default function CreatePage() {
}
};
const setComposerRatio = (value: string) => {
setRatio(value);
if (mode === "image") rememberImageSettings({ ratio: value });
if (mode === "video") rememberVideoSettings({ ratio: value });
};
const setComposerSeconds = (value: string) => {
setSeconds(value);
if (mode === "video") rememberVideoSettings({ seconds: value });
};
const setComposerQuality = (value: string) => {
setQuality(value);
if (mode === "image") rememberImageSettings({ quality: value });
};
const setComposerVideoQuality = (value: string) => {
setVideoQuality(value);
if (mode === "video") rememberVideoSettings({ videoQuality: value });
};
const setComposerCount = (value: string) => {
setCount(value);
if (mode === "image") rememberImageSettings({ count: value });
};
const externalLibraryItems = useMemo<AssetLibraryPickerItem[]>(
() => externalAssetSources.items.map((item) => ({
...item,
@@ -570,6 +629,7 @@ export default function CreatePage() {
const bindTask = (task: GenerationTask) => {
if (typeof task.clientContext?.batchIndex === "number") boundTaskIdsByBatchIndex.set(task.clientContext.batchIndex, task.id);
boundTaskIds.add(task.id);
activeGenerationTaskIdsRef.current.add(task.id);
boundTasks.set(task.id, task);
updateOriginAssistant((item) => ({ ...item, generationStage: task.stage, generationOperation: task.operation, generationErrorCode: task.errorCode, taskIds: Array.from(new Set([...(item.taskIds || []), task.id])), clientOperationId: task.clientOperationId, retryOf: task.retryOf, attemptGroupId: task.attemptGroupId }));
if (abortRef.current === controller) {
@@ -699,6 +759,7 @@ export default function CreatePage() {
const message = generationErrorMessage(error);
updateOriginAssistant((item) => ({ ...item, status: "error", error: message, generationErrorCode: item.generationErrorCode || generationErrorCode(error), generationOperation: item.generationOperation || (mode === "video" ? videoOperation : mode), createdAt: assistantMessage.createdAt, content: "生成失败" }));
} finally {
for (const taskId of boundTaskIds) activeGenerationTaskIdsRef.current.delete(taskId);
requestLifecycle.release();
releaseRetryLock();
if (abortRef.current === controller) {
@@ -781,7 +842,7 @@ export default function CreatePage() {
const restoreMessageDraft = (item: CreationMessage) => {
const nextMode = item.mode || "text";
const nextSettings = item.settings;
setMode(nextMode);
selectMode(nextMode);
setPrompt(item.content);
setAttachments(item.attachments ? [...item.attachments] : []);
setDraftReferences(item.references ? [...item.references] : []);
@@ -792,6 +853,8 @@ export default function CreatePage() {
setQuality(nextSettings.quality);
setVideoQuality(nextSettings.videoQuality);
setCount(nextSettings.count);
if (nextMode === "image") rememberImageSettings({ ratio: nextSettings.ratio, quality: nextSettings.quality, count: nextSettings.count });
if (nextMode === "video") rememberVideoSettings({ ratio: nextSettings.ratio, seconds: nextSettings.seconds, videoQuality: nextSettings.videoQuality });
};
const retryFailedMessage = async (item: CreationMessage, index: number) => {
@@ -876,15 +939,15 @@ export default function CreatePage() {
config,
onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value),
ratio,
setRatio,
setRatio: setComposerRatio,
seconds,
setSeconds,
setSeconds: setComposerSeconds,
quality,
setQuality,
setQuality: setComposerQuality,
videoQuality,
setVideoQuality,
setVideoQuality: setComposerVideoQuality,
count,
setCount,
setCount: setComposerCount,
promptOptimizerProvider,
composerFocusRef,
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `${formatShotOrdinal(nextShotNumber - 1)} · 写下这一镜的镜头、画面或故事` : undefined,
@@ -1101,8 +1164,82 @@ function CreationMessageReferences({ references }: { references: CreationReferen
})}</div>;
}
type CreationImagePreviewView = { scale: number; offsetX: number; offsetY: number };
const initialCreationImagePreviewView: CreationImagePreviewView = { scale: 1, offsetX: 0, offsetY: 0 };
function CreationImagePreview({ url }: { url: string }) {
const viewportRef = useRef<HTMLDivElement>(null);
const imageRef = useRef<HTMLImageElement>(null);
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; startOffsetX: number; startOffsetY: number } | null>(null);
const [view, setView] = useState<CreationImagePreviewView>(initialCreationImagePreviewView);
const [dragging, setDragging] = useState(false);
useEffect(() => {
setView(initialCreationImagePreviewView);
dragRef.current = null;
setDragging(false);
}, [url]);
const clampOffset = (offsetX: number, offsetY: number, scale: number) => {
const viewport = viewportRef.current;
const image = imageRef.current;
if (!viewport || !image) return { offsetX, offsetY };
const maxOffsetX = Math.max(0, (image.offsetWidth * scale - viewport.clientWidth) / 2);
const maxOffsetY = Math.max(0, (image.offsetHeight * scale - viewport.clientHeight) / 2);
return {
offsetX: Math.max(-maxOffsetX, Math.min(maxOffsetX, offsetX)),
offsetY: Math.max(-maxOffsetY, Math.min(maxOffsetY, offsetY)),
};
};
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const viewport = event.currentTarget;
const rect = viewport.getBoundingClientRect();
const pointerX = event.clientX - (rect.left + rect.width / 2);
const pointerY = event.clientY - (rect.top + rect.height / 2);
setView((current) => {
const nextScale = Math.max(1, Math.min(5, current.scale * Math.exp(-event.deltaY * 0.0015)));
if (nextScale === current.scale) return current;
if (nextScale === 1) return initialCreationImagePreviewView;
const scaleRatio = nextScale / current.scale;
const nextOffset = clampOffset(pointerX + (current.offsetX - pointerX) * scaleRatio, pointerY + (current.offsetY - pointerY) * scaleRatio, nextScale);
return { scale: nextScale, ...nextOffset };
});
};
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (view.scale <= 1 || event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startOffsetX: view.offsetX, startOffsetY: view.offsetY };
setDragging(true);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.preventDefault();
const nextOffset = clampOffset(drag.startOffsetX + event.clientX - drag.startX, drag.startOffsetY + event.clientY - drag.startY, view.scale);
setView((current) => ({ ...current, ...nextOffset }));
};
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
if (!dragRef.current || dragRef.current.pointerId !== event.pointerId) return;
dragRef.current = null;
setDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
};
return <div ref={viewportRef} className={`creation-media-preview-viewport${view.scale > 1 ? " is-zoomed" : ""}${dragging ? " is-dragging" : ""}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onLostPointerCapture={endDrag}>
<img ref={imageRef} className="creation-media-preview-image" src={url} alt="媒体预览" draggable={false} style={{ transform: `translate3d(${view.offsetX}px, ${view.offsetY}px, 0) scale(${view.scale})` }} />
<span className="creation-media-preview-hint" aria-hidden="true">滚轮缩放 · 放大后拖动</span>
</div>;
}
function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
return <Modal open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width={type === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={onClose} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{url ? type === "video" ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : <img className="creation-media-preview-image" src={url} alt="媒体预览" /> : null}</Modal>;
return <Modal open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width={type === "video" ? "min(1160px, calc(100vw - 32px))" : "min(980px, calc(100vw - 32px))"} onCancel={onClose} className="creation-media-preview-modal" styles={{ body: { padding: 0 } }}>{url ? type === "video" ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : <CreationImagePreview url={url} /> : null}</Modal>;
}
function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {

View File

@@ -15,8 +15,8 @@ import {
X,
} from "lucide-react";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
import { listProjectAssets } from "@/services/api/projects";
import type { ProjectAsset, ProjectDetail, ShotArtifact } from "@/services/api/projects";
import { linkProjectAsset, listProjectAssets } from "@/services/api/projects";
import { useEditorSlots, type EditorSlotRegistration } from "@/lib/plugins/editor-slot-registry";
import { pluginMayRenderEditorSlot } from "@/lib/plugins/plugin-permission-check";
import { EditorStoreProvider } from "@/components/editor/editor-context";
@@ -269,11 +269,14 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
const [timelineH, setTimelineH] = useState(240);
const splitterRef = useRef<{ startY: number; startH: number } | null>(null);
const refreshAssets = async () => {
const refreshAssets = async (): Promise<ProjectAsset[] | null> => {
try {
setAssets((await listProjectAssets(projectId)).assets);
const list = (await listProjectAssets(projectId)).assets;
setAssets(list);
return list;
} catch {
// 刷新失败保留现有列表;导入面板内联提示错误
// 刷新失败保留现有列表;调用方可据此决定是否重试
return null;
}
};
@@ -294,6 +297,19 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
const scope = getActiveUserScope();
const projectId = detail.project.id;
// 挂载/切换项目时以服务端为准同步一次资产:detail.assets 只是详情页快照,
// 上个会话导入但详情未携带(或导入后未刷新)的素材会在进入编辑器后补现。
useEffect(() => {
let cancelled = false;
void (async () => {
const list = await refreshAssets();
if (!cancelled && list) setAssets(list);
})();
return () => {
cancelled = true;
};
}, [projectId]);
const store = useState(() =>
createEditorStore({
saveTimeline: async (project: TimelineProject) => {
@@ -318,6 +334,58 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
};
}, [scope, projectId, store]);
// 画布产物自动同步:detail.shotArtifacts 中当前采用(storyboard/action_board/
// 分镜/导出成片,且 ready + 有资源)尚未作为素材进本项目时,自动
// linkProjectAsset(source=canvas) 并入素材库 —— 剪辑器“素材选项”直接可用画布产物。
// 后端幂等(已链接直接返回),失败静默,下次进入编辑器自动补同步;
// 已完成集合记录在 ref,避免同一会话重复请求。
const syncedCanvasResourcesRef = useRef<Set<string>>(new Set());
const canvasSyncKeys = detail.shotArtifacts
.filter(
(a): a is ShotArtifact & { resourceId: string } =>
a.selected &&
a.status === "ready" &&
!!a.resourceId &&
["storyboard", "action_board", "start_frame", "end_frame", "video", "delivery"].includes(a.type),
)
.map((a) => `resource:${a.resourceId}`)
.join(",");
useEffect(() => {
if (!canvasSyncKeys) return;
const keys = canvasSyncKeys.split(",");
// 已同步过的(本会话 ref)或已存在于项目素材的(storageKey 命中)均跳过 ——
// storageKey 与 assetFromUploadedResource 合成格式一致(resource:<id>)。
const existing = new Set(assets.map((a) => a.storageKey));
const pending = keys.filter((key) => !syncedCanvasResourcesRef.current.has(key) && !existing.has(key));
if (!pending.length) return;
let cancelled = false;
void (async () => {
for (const key of pending) {
if (cancelled) break;
const resourceId = key.slice("resource:".length);
try {
await linkProjectAsset(projectId, { assetId: resourceId, category: "material", source: "canvas" });
syncedCanvasResourcesRef.current.add(key);
} catch {
// 单个同步失败不阻塞其余;留待下次进入编辑器重试
}
}
if (!cancelled) {
// 直接拉取最新列表(setAssets 稳定)而非经 refreshAssets,
// 避免闭包依赖变化导致 effect 反复触发
try {
const list = (await listProjectAssets(projectId)).assets;
if (!cancelled) setAssets(list);
} catch {
// 刷新失败保留现有列表,下次进入编辑器再同步
}
}
})();
return () => {
cancelled = true;
};
}, [canvasSyncKeys, projectId, assets]);
return (
<EditorStoreProvider store={store} host={{ projectId, assets, refreshAssets }}>
<div ref={workbenchRef} className="editor-workbench relative flex h-full min-h-0 flex-col bg-[var(--director-workspace-bg)]">

View File

@@ -32,7 +32,7 @@ export default function ProjectOverviewView({ detail, overview }: ProjectDetailV
{ id: "storyboard", icon: Clapperboard, label: "分镜脚本与画面", description: "拆分镜头并确认构图、对白和时长", metric: `${metrics.shotCount} 镜 · ${metrics.readyStoryboardCount} 张图`, href: workflowHref("storyboard"), complete: metrics.shotCount > 0 },
{ id: "previz", icon: PlaySquare, label: "动作预演", description: "检查表演节拍、运镜和连续性", metric: `${metrics.readyPrevizCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("previz"), complete: metrics.shotCount > 0 && metrics.readyPrevizCount === metrics.shotCount },
{ id: "video", icon: Film, label: "镜头视频", description: "逐镜生成、筛选版本并锁定成片", metric: `${metrics.readyVideoCount}/${metrics.shotCount || 0} 镜`, href: workflowHref("video"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
{ id: "editor", icon: Scissors, label: "剪辑成片", description: "在时间线中编排镜头、添加字幕并输出成片", metric: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount ? "可以开始剪辑" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: `/projects/${project.id}/editor`, complete: false },
{ id: "editor", icon: Scissors, label: "剪辑成片", description: "在时间线中编排镜头、添加字幕并输出成片", metric: metrics.renderSucceededCount > 0 ? `已输出 ${metrics.renderSucceededCount} 个成片` : metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount ? "可以开始剪辑" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: `/projects/${project.id}/editor`, complete: metrics.renderSucceededCount > 0 },
{ id: "delivery", icon: PackageCheck, label: "交付与打包", description: "检查缺失镜头并整理最终产物", metric: metrics.readyVideoCount && metrics.readyVideoCount === metrics.shotCount ? "可以交付" : `还差 ${Math.max(0, metrics.shotCount - metrics.readyVideoCount)} 镜`, href: workflowHref("delivery"), complete: metrics.shotCount > 0 && metrics.readyVideoCount === metrics.shotCount },
];
const gaps = [

View File

@@ -278,6 +278,7 @@ export type ProjectOverviewMetrics = {
readyStoryboardCount: number;
readyPrevizCount: number;
readyVideoCount: number;
renderSucceededCount: number;
staleArtifactCount: number;
};
@@ -479,7 +480,7 @@ export function unlinkCanvasProject(projectId: string, canvasId: string) {
return request<{ canvasId: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/canvases/${encodeURIComponent(canvasId)}`));
}
export function linkProjectAsset(projectId: string, input: { assetId: string; category: AssetCategory; folderId?: string; title?: string }, signal?: AbortSignal) {
export function linkProjectAsset(projectId: string, input: { assetId: string; category: AssetCategory; folderId?: string; title?: string; source?: "uploaded" | "canvas" }, signal?: AbortSignal) {
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets`, input, { signal }));
}

View File

@@ -532,6 +532,7 @@ export async function waitForGenerationTask(id: string, options?: WaitForGenerat
const intervalMs = options?.intervalMs || 2000;
let lastTask = options?.initialTask;
let lastQueryError: unknown;
let consecutiveFailures = 0;
try {
while (Date.now() - startedAt < (options?.timeoutMs || taskWaitTimeoutMs(lastTask))) {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
@@ -540,9 +541,16 @@ export async function waitForGenerationTask(id: string, options?: WaitForGenerat
task = await queryGenerationTask(id, { signal: options?.signal });
lastTask = task;
lastQueryError = undefined;
consecutiveFailures = 0;
options?.onTaskUpdate?.(task);
} catch (error) {
lastQueryError = error;
consecutiveFailures += 1;
// 连续失败说明查询通道已不可用,继续轮询只会空转到整体超时;
// 保留少量容忍度(约 10 秒)以跳过瞬时抖动后直接报错,便于用户尽早处理。
if (consecutiveFailures >= 5) {
throw error instanceof Error ? error : new Error(String(error));
}
await delay(intervalMs, options?.signal);
continue;
}

View File

@@ -369,7 +369,7 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>) {
message: "正在保存画布结构",
});
}
await upsertRemoteCanvasProject(remotePayload);
await upsertRemoteCanvasProject(sanitizeCanvasProjectForRemoteSync(remotePayload));
acknowledgedProjects.set(source.id, source);
if (total > 0) useSyncProgressStore.getState().setProjectProgress(source.id, null);
} catch (error) {
@@ -513,3 +513,42 @@ function numberValue(value: unknown) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : undefined;
}
function sanitizeCanvasProjectForRemoteSync<T>(project: T): T {
if (!project || typeof project !== "object") return project;
const clone = { ...(project as Record<string, unknown>) };
if (Array.isArray(clone.chatSessions)) {
clone.chatSessions = clone.chatSessions.map((session) => {
if (!session || typeof session !== "object") return session;
const s = { ...(session as Record<string, unknown>) };
if (Array.isArray(s.messages)) {
s.messages = s.messages.map((message) => {
if (!message || typeof message !== "object" || !message.detail) return message;
const m = { ...(message as Record<string, unknown>) };
if (m.detail && typeof m.detail === "object") {
const d = { ...(m.detail as Record<string, unknown>) };
if (Array.isArray(d.results)) {
d.results = d.results.map((r) => {
if (!r || typeof r !== "object") return r;
const res = { ...(r as Record<string, unknown>) };
if (res.result && typeof res.result === "object") {
const inner = { ...(res.result as Record<string, unknown>) };
if (inner.data && typeof inner.data === "object") {
const { snapshot: _s, before: _b, after: _a, ...restData } = inner.data as Record<string, unknown>;
inner.data = restData;
}
res.result = inner;
}
return res;
});
}
m.detail = d;
}
return m;
});
}
return s;
});
}
return clone as T;
}

View File

@@ -0,0 +1,96 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { scopedLocalStorage } from "@/lib/user-scope";
export type CreationModePreference = "text" | "image" | "video";
export type CreationImagePreferences = {
ratio?: string;
quality?: string;
count?: string;
};
export type CreationVideoPreferences = {
ratio?: string;
seconds?: string;
videoQuality?: string;
};
export type CreationComposerPreferences = {
mode?: CreationModePreference;
image?: CreationImagePreferences;
video?: CreationVideoPreferences;
};
type CreationPreferencesStore = {
hydrated: boolean;
preferences: CreationComposerPreferences;
rememberMode: (mode: CreationModePreference) => void;
rememberImageSettings: (settings: CreationImagePreferences) => void;
rememberVideoSettings: (settings: CreationVideoPreferences) => void;
};
export const CREATION_PREFERENCES_STORE_KEY = "open_ai_canvas:creation_preferences";
function nonEmptyString(value: unknown): value is string {
return typeof value === "string" && Boolean(value.trim());
}
function normalizeImagePreferences(value: unknown): CreationImagePreferences | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const preferences = {
...(nonEmptyString(raw.ratio) ? { ratio: raw.ratio } : {}),
...(nonEmptyString(raw.quality) ? { quality: raw.quality } : {}),
...(nonEmptyString(raw.count) ? { count: raw.count } : {}),
};
return Object.keys(preferences).length ? preferences : undefined;
}
function normalizeVideoPreferences(value: unknown): CreationVideoPreferences | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const preferences = {
...(nonEmptyString(raw.ratio) ? { ratio: raw.ratio } : {}),
...(nonEmptyString(raw.seconds) ? { seconds: raw.seconds } : {}),
...(nonEmptyString(raw.videoQuality) ? { videoQuality: raw.videoQuality } : {}),
};
return Object.keys(preferences).length ? preferences : undefined;
}
export function normalizeCreationComposerPreferences(value: unknown): CreationComposerPreferences {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const raw = value as Record<string, unknown>;
const image = normalizeImagePreferences(raw.image);
const video = normalizeVideoPreferences(raw.video);
return {
...(raw.mode === "text" || raw.mode === "image" || raw.mode === "video" ? { mode: raw.mode } : {}),
...(image ? { image } : {}),
...(video ? { video } : {}),
};
}
export const useCreationPreferencesStore = create<CreationPreferencesStore>()(
persist(
(set) => ({
hydrated: false,
preferences: {},
rememberMode: (mode) => set((state) => ({ preferences: { ...state.preferences, mode } })),
rememberImageSettings: (settings) => set((state) => ({ preferences: { ...state.preferences, image: { ...state.preferences.image, ...settings } } })),
rememberVideoSettings: (settings) => set((state) => ({ preferences: { ...state.preferences, video: { ...state.preferences.video, ...settings } } })),
}),
{
name: CREATION_PREFERENCES_STORE_KEY,
storage: createJSONStorage(() => scopedLocalStorage),
partialize: (state) => ({ preferences: state.preferences }),
merge: (persisted, current) => {
const stored = (persisted || {}) as Partial<CreationPreferencesStore>;
return { ...current, preferences: normalizeCreationComposerPreferences(stored.preferences) };
},
onRehydrateStorage: () => () => {
useCreationPreferencesStore.setState({ hydrated: true });
},
},
),
);

View File

@@ -11107,7 +11107,12 @@ html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37,
.creation-media-preview-modal .ant-modal-container { overflow: hidden; border: 0; border-radius: 12px; background: #111215; padding: 0; }
.creation-media-preview-modal .ant-modal-close { color: #fff; }
.creation-media-preview-image, .creation-media-preview-video { display: block; max-width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; }
.creation-media-preview-viewport { position: relative; display: flex; width: 100%; max-height: 82vh; align-items: center; justify-content: center; overflow: hidden; overscroll-behavior: contain; touch-action: none; user-select: none; }
.creation-media-preview-image { display: block; max-width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; transform-origin: center center; user-select: none; will-change: transform; }
.creation-media-preview-viewport.is-zoomed .creation-media-preview-image { cursor: grab; }
.creation-media-preview-viewport.is-dragging .creation-media-preview-image { cursor: grabbing; }
.creation-media-preview-hint { position: absolute; right: 12px; bottom: 10px; pointer-events: none; border-radius: 999px; background: rgb(0 0 0 / 52%); color: rgb(255 255 255 / 78%); font-size: 11px; line-height: 1; padding: 7px 9px; }
.creation-media-preview-video { display: block; width: 100%; max-height: 82vh; margin: 0 auto; object-fit: contain; }
.creation-media-preview-video { width: 100%; background: #050505; }
html:not(.dark) .creation-user-message { background: rgba(28, 32, 37, .07); }