refactor(*): 稳定期治理 - 收敛核心架构、错误语义并修复跨项目数据竞态
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { App, Button, Dropdown, Form, Input, InputNumber, Modal, Popconfirm } from "antd";
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { Tooltip } from "@/components/ui/base/tooltip";
|
||||
import { useDeferredValue, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -754,7 +755,7 @@ function ImportNovelModal({ open, loading, onClose, onImport }: { open: boolean;
|
||||
setText(decodeNovelText(await file.arrayBuffer()));
|
||||
};
|
||||
return (
|
||||
<Modal title={null} open={open} footer={null} destroyOnHidden onCancel={onClose} width={760} styles={{ container: { padding: 0, overflow: "hidden" }, body: { padding: 0 } }}>
|
||||
<AppModal flush title={null} open={open} footer={null} onCancel={onClose} width={760}>
|
||||
<div className="flex min-h-[478px] flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center border-b border-border px-4"><div><h2 className="text-sm font-semibold">导入小说</h2><p className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">自动识别章节标题,确认后追加到当前项目</p></div></header>
|
||||
<div className="grid min-h-[430px] flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_240px]">
|
||||
@@ -770,7 +771,7 @@ function ImportNovelModal({ open, loading, onClose, onImport }: { open: boolean;
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -26,32 +26,21 @@ import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { normalizeTimelineProject } from "@/lib/timeline/timeline-tracks";
|
||||
import { formatTimelineTime } from "@/lib/timeline/timeline-view";
|
||||
import { useEditorStoreContext } from "@/components/editor/editor-context";
|
||||
import type { TimelineClip, TimelineProject } from "@/types/timeline";
|
||||
import type { TimelineProject } from "@/types/timeline";
|
||||
|
||||
const EDITOR_TIMELINE_KEY = "editor-timeline";
|
||||
|
||||
/** M2.4 演示种子:首次进入项目编辑器且无本地时间线时初始化一个可交互示例。
|
||||
* M4 接入真实数据源(画布节点 / 后端项目时间线)后移除。 */
|
||||
function createEditorSeed(projectId: string): TimelineProject {
|
||||
const clips: TimelineClip[] = [
|
||||
{ id: `${projectId}:demo-v1`, kind: "video", nodeId: "demo-node-a", trackId: "video-1", startMs: 0, durationMs: 5000, sourceStartMs: 0, sourceDurationMs: 5000 },
|
||||
{ id: `${projectId}:demo-v2`, kind: "video", nodeId: "demo-node-b", trackId: "video-1", startMs: 6000, durationMs: 3000, sourceStartMs: 1000, sourceDurationMs: 8000 },
|
||||
{ id: `${projectId}:demo-a1`, kind: "audio", nodeId: "demo-node-a", trackId: "audio-1", startMs: 0, durationMs: 5000, sourceStartMs: 0, sourceDurationMs: 5000, volume: 0.8 },
|
||||
{ id: `${projectId}:demo-s1`, kind: "subtitle", nodeId: "demo-node-a", trackId: "subtitle-1", startMs: 500, durationMs: 2000, subtitleEntryIndex: 0, text: "示例字幕" },
|
||||
];
|
||||
return normalizeTimelineProject({
|
||||
version: 2,
|
||||
tracks: [
|
||||
{ id: "video-1", kind: "video", label: "视频 1", order: 0 },
|
||||
{ id: "audio-1", kind: "audio", label: "音频 1", order: 1 },
|
||||
{ id: "subtitle-1", kind: "subtitle", label: "字幕 1", order: 2 },
|
||||
],
|
||||
clips,
|
||||
durationMs: 9000,
|
||||
});
|
||||
/**
|
||||
* 为尚未产生本地时间线的项目建立一个可编辑的初始时间线。
|
||||
* 该时间线只写入当前用户与项目作用域的本地存储,不代表服务端已有成片数据。
|
||||
*/
|
||||
function createEmptyEditorTimeline(): TimelineProject {
|
||||
// 没有真实素材时返回空时间线,避免把不存在的 demo node 当成可剪辑资产。
|
||||
// 用户添加素材后由编辑器命令创建轨道和片段;空项目不应被伪造为已有成片。
|
||||
return normalizeTimelineProject({ version: 2, tracks: [], clips: [], durationMs: 0 });
|
||||
}
|
||||
|
||||
/** 插槽堆叠:渲染该区域权限通过的插槽贡献(fail-closed,M5.2);
|
||||
/** 插槽堆叠:只渲染通过权限检查的插件贡献;无可用插件时显示明确的缺失能力提示。
|
||||
* 缺权限插件不渲染,改为一行诊断提示;无贡献时显示空态(停用插件可见)。 */
|
||||
function SlotStack({ slots, emptyHint }: { slots: EditorSlotRegistration[]; emptyHint: string }) {
|
||||
const allowed = slots.filter((slot) => pluginMayRenderEditorSlot(slot.pluginId, slot.slot).allowed);
|
||||
@@ -254,7 +243,7 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
|
||||
const [assets, setAssets] = useState<ProjectAsset[]>(detail.assets);
|
||||
const workbenchRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
// AI 助手浮层(顶部工具按钮触发,M6.3;Esc / 点击遮罩关闭)。
|
||||
// AI 助手浮层由顶栏按钮打开,支持 Esc 和遮罩点击关闭,避免长期遮挡编辑区。
|
||||
const [aiOpen, setAiOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!aiOpen) return;
|
||||
@@ -268,17 +257,37 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
|
||||
// 底部时间线高度(px):分隔条拖拽调整(120–480)。
|
||||
const [timelineH, setTimelineH] = useState(240);
|
||||
const splitterRef = useRef<{ startY: number; startH: number } | null>(null);
|
||||
const scope = getActiveUserScope();
|
||||
const projectId = detail.project.id;
|
||||
const assetOwnerKey = JSON.stringify([scope, projectId]);
|
||||
const activeAssetOwnerKeyRef = useRef(assetOwnerKey);
|
||||
const assetRefreshSequenceRef = useRef(0);
|
||||
activeAssetOwnerKeyRef.current = assetOwnerKey;
|
||||
|
||||
const refreshAssets = async (): Promise<ProjectAsset[] | null> => {
|
||||
const refreshAssets = useCallback(async (): Promise<ProjectAsset[] | null> => {
|
||||
const requestedOwnerKey = assetOwnerKey;
|
||||
const requestSequence = assetRefreshSequenceRef.current + 1;
|
||||
assetRefreshSequenceRef.current = requestSequence;
|
||||
try {
|
||||
const list = (await listProjectAssets(projectId)).assets;
|
||||
// 项目、账号或刷新序号变化都说明当前响应已经过期;旧响应不得覆盖新上下文的素材快照。
|
||||
if (
|
||||
activeAssetOwnerKeyRef.current !== requestedOwnerKey ||
|
||||
assetRefreshSequenceRef.current !== requestSequence
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
setAssets(list);
|
||||
return list;
|
||||
} catch {
|
||||
// 刷新失败保留现有列表;调用方可据此决定是否重试
|
||||
} catch (error) {
|
||||
// 过期请求的错误不属于当前编辑器上下文,也不应产生误导性告警。
|
||||
if (activeAssetOwnerKeyRef.current !== requestedOwnerKey) return null;
|
||||
// 资产列表属于读展示路径:保留当前快照,避免网络抖动让编辑器清空素材。
|
||||
// 但不能把快照当成同步成功;记录项目上下文,便于定位并在下次进入时重试。
|
||||
console.warn("刷新项目素材列表失败,已保留当前快照", { projectId, scope, error });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}, [assetOwnerKey, projectId, scope]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(document.fullscreenElement === workbenchRef.current);
|
||||
@@ -287,59 +296,91 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (document.fullscreenElement) {
|
||||
void document.exitFullscreen().catch(() => {});
|
||||
} else {
|
||||
void workbenchRef.current?.requestFullscreen().catch(() => {});
|
||||
}
|
||||
const operation = document.fullscreenElement
|
||||
? document.exitFullscreen()
|
||||
: workbenchRef.current?.requestFullscreen();
|
||||
void operation?.catch((error) => {
|
||||
console.warn("切换编辑器全屏状态失败", { projectId, error });
|
||||
});
|
||||
};
|
||||
|
||||
const scope = getActiveUserScope();
|
||||
const projectId = detail.project.id;
|
||||
const detailAssetsRef = useRef(detail.assets);
|
||||
detailAssetsRef.current = detail.assets;
|
||||
|
||||
// 挂载/切换项目时以服务端为准同步一次资产:detail.assets 只是详情页快照,
|
||||
// 上个会话导入但详情未携带(或导入后未刷新)的素材会在进入编辑器后补现。
|
||||
// 挂载、切换项目或切换账号时先恢复详情快照,再以服务端列表校准。
|
||||
// detail.assets 只用于切换瞬间占位;refreshAssets 内部负责丢弃过期和乱序响应。
|
||||
useEffect(() => {
|
||||
setAssets(detailAssetsRef.current);
|
||||
void refreshAssets();
|
||||
return () => {
|
||||
// 使当前 owner 下仍在途的请求失效,避免组件卸载后提交 state。
|
||||
assetRefreshSequenceRef.current += 1;
|
||||
};
|
||||
}, [assetOwnerKey, refreshAssets]);
|
||||
|
||||
const store = useMemo(
|
||||
() =>
|
||||
createEditorStore({
|
||||
saveTimeline: async (project: TimelineProject) => {
|
||||
await localForageStorageForScope(scope).setItem(`${EDITOR_TIMELINE_KEY}:${projectId}`, JSON.stringify(project));
|
||||
},
|
||||
}),
|
||||
[projectId, scope],
|
||||
);
|
||||
|
||||
// 进入编辑器时只加载当前用户和项目作用域的本地时间线;没有真实数据时保持空时间线,禁止注入虚构片段。
|
||||
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) => {
|
||||
await localForageStorageForScope(scope).setItem(`${EDITOR_TIMELINE_KEY}:${projectId}`, JSON.stringify(project));
|
||||
},
|
||||
}),
|
||||
)[0];
|
||||
|
||||
// 进入编辑器时加载本地时间线;无则初始化演示种子(不触发保存,历史从该状态起步)。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const storage = localForageStorageForScope(scope);
|
||||
const raw = await storage.getItem(`${EDITOR_TIMELINE_KEY}:${projectId}`);
|
||||
const loaded = raw ? normalizeTimelineProject(JSON.parse(raw)) : createEditorSeed(projectId);
|
||||
if (!cancelled) store.getState().load(loaded);
|
||||
try {
|
||||
const storage = localForageStorageForScope(scope);
|
||||
const raw = await storage.getItem(`${EDITOR_TIMELINE_KEY}:${projectId}`);
|
||||
let loaded = createEmptyEditorTimeline();
|
||||
if (raw) {
|
||||
try {
|
||||
loaded = normalizeTimelineProject(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
// 本地历史数据属于读展示路径:坏记录不能阻塞编辑器启动,
|
||||
// 但必须记录项目和存储键,后续可按键清理或迁移,而不是把坏数据伪装成有效时间线。
|
||||
console.warn("读取编辑器本地时间线失败,已使用空时间线", {
|
||||
projectId,
|
||||
storageKey: `${EDITOR_TIMELINE_KEY}:${projectId}`,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!cancelled) store.getState().load(loaded);
|
||||
} catch (error) {
|
||||
// IndexedDB/localforage 不可用时不写入空时间线,避免覆盖可能仍可恢复的本地数据。
|
||||
console.error("读取编辑器本地时间线存储失败,保留编辑器初始状态", { projectId, error });
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// 卸载前冲刷尚未落盘的改动(如切页发生在防抖窗口内),避免最后几次操作丢失。
|
||||
void store.getState().flushSave?.().catch(() => {});
|
||||
// flushSave 失败时 editor-store 会保留 isDirty;这里必须留下错误证据,不能假装已保存。
|
||||
const flushSave = store.getState().flushSave;
|
||||
if (flushSave) {
|
||||
void flushSave().catch((error) => {
|
||||
console.error("编辑器卸载前保存时间线失败,待下次操作重试", { projectId, error });
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [scope, projectId, store]);
|
||||
|
||||
// 画布产物自动同步:detail.shotArtifacts 中当前采用(storyboard/action_board/
|
||||
// 分镜/导出成片,且 ready + 有资源)尚未作为素材进本项目时,自动
|
||||
// linkProjectAsset(source=canvas) 并入素材库 —— 剪辑器“素材选项”直接可用画布产物。
|
||||
// 后端幂等(已链接直接返回),失败静默,下次进入编辑器自动补同步;
|
||||
// 已完成集合记录在 ref,避免同一会话重复请求。
|
||||
// 后端幂等(已链接直接返回);单个失败不阻塞其余资源,但会记录告警,
|
||||
// 下次进入编辑器时再次尝试补同步。
|
||||
// 该集合只表示“当前用户、当前项目会话已处理过”,切换账号或项目时必须清空,避免跨作用域复用 resource ID。
|
||||
const syncedCanvasResourcesRef = useRef<Set<string>>(new Set());
|
||||
const syncedAssetOwnerKeyRef = useRef(assetOwnerKey);
|
||||
useEffect(() => {
|
||||
if (syncedAssetOwnerKeyRef.current === assetOwnerKey) return;
|
||||
syncedAssetOwnerKeyRef.current = assetOwnerKey;
|
||||
syncedCanvasResourcesRef.current.clear();
|
||||
}, [assetOwnerKey]);
|
||||
const canvasSyncKeys = detail.shotArtifacts
|
||||
.filter(
|
||||
(a): a is ShotArtifact & { resourceId: string } =>
|
||||
@@ -365,26 +406,24 @@ export default function ProjectEditorView({ detail }: { detail: ProjectDetail })
|
||||
const resourceId = key.slice("resource:".length);
|
||||
try {
|
||||
await linkProjectAsset(projectId, { assetId: resourceId, category: "material", source: "canvas" });
|
||||
// 请求返回时组件可能已经切换项目;旧请求不能把结果记入新项目的会话集合。
|
||||
if (cancelled) break;
|
||||
syncedCanvasResourcesRef.current.add(key);
|
||||
} catch {
|
||||
// 单个同步失败不阻塞其余;留待下次进入编辑器重试
|
||||
} catch (error) {
|
||||
// 单个资源同步失败不阻塞其他资源;失败项不加入已完成集合,
|
||||
// 这样下次进入编辑器仍会重试,而不是把未同步伪装成成功。
|
||||
console.warn("画布产物同步到项目素材失败", { projectId, resourceId, error });
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
// 直接拉取最新列表(setAssets 稳定)而非经 refreshAssets,
|
||||
// 避免闭包依赖变化导致 effect 反复触发
|
||||
try {
|
||||
const list = (await listProjectAssets(projectId)).assets;
|
||||
if (!cancelled) setAssets(list);
|
||||
} catch {
|
||||
// 刷新失败保留现有列表,下次进入编辑器再同步
|
||||
}
|
||||
// 统一经过带 owner 与请求序号校验的刷新入口,防止同步后的旧请求覆盖新项目快照。
|
||||
await refreshAssets();
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canvasSyncKeys, projectId, assets]);
|
||||
}, [assets, canvasSyncKeys, projectId, refreshAssets]);
|
||||
|
||||
return (
|
||||
<EditorStoreProvider store={store} host={{ projectId, assets, refreshAssets }}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts";
|
||||
import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
|
||||
import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown";
|
||||
import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
|
||||
@@ -43,7 +44,7 @@ export function chapterAssetsFromGenerationTask(task: GenerationTask) {
|
||||
const result = parseBackendGenerationResult(task);
|
||||
if (!result.text?.trim()) throw new Error("模型没有返回可用的章节资产提取结果");
|
||||
// 历史角色任务的输出契约只有角色;仅按明确的模板操作恢复,不能给新任务缺字段兜底。
|
||||
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === "character_extract") {
|
||||
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === PromptTemplateOperation.CharacterExtract) {
|
||||
return { characters: parseCharacterBreakdown(result.text), scenes: [], props: [] };
|
||||
}
|
||||
return parseChapterAssetBreakdown(result.text);
|
||||
@@ -123,9 +124,9 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
|
||||
projectId: input.projectId,
|
||||
nodeId: `${operation}:${input.chapterId}`,
|
||||
mode: "text",
|
||||
prompt: "使用当前启用的章节角色、场景与道具提取模板。",
|
||||
prompt: promptTemplateTaskPlaceholder("章节角色、场景与道具提取"),
|
||||
config: { ...input.config, model },
|
||||
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "chapter_assets_extract", promptTemplateVariables },
|
||||
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: PromptTemplateOperation.ChapterAssetsExtract, promptTemplateVariables },
|
||||
onTaskCreated: options?.onTaskUpdate,
|
||||
});
|
||||
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
|
||||
import { PromptTemplateOperation, promptTemplateTaskPlaceholder } from "@/lib/prompts";
|
||||
import { resolveStyleExecutionPlan, serializeStyleProfile, type StyleProfileSnapshot } from "@/lib/canvas/style-profile";
|
||||
import { logicalModelIDForConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -14,9 +15,9 @@ export async function generateCharacterTurnaround(input: { projectId: string; as
|
||||
projectId: input.projectId,
|
||||
nodeId: `character-turnaround:${input.assetId}`,
|
||||
mode: "image",
|
||||
prompt: "使用当前启用的角色三视图模板。",
|
||||
prompt: promptTemplateTaskPlaceholder("角色三视图"),
|
||||
config: generationConfig,
|
||||
metadata: { operation: "character_turnaround", promptTemplateOperation: "character_turnaround", promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, styleProfileJson: input.projectStyle?.profile ? serializeStyleProfile(input.projectStyle.profile) : undefined, styleExecutionPlan, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
metadata: { operation: PromptTemplateOperation.CharacterTurnaround, promptTemplateOperation: PromptTemplateOperation.CharacterTurnaround, promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, styleProfileJson: input.projectStyle?.profile ? serializeStyleProfile(input.projectStyle.profile) : undefined, styleExecutionPlan, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { Input, Modal } from "antd";
|
||||
import { Input } from "antd";
|
||||
import { BookOpenText, ChevronDown, ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
|
||||
import type { ProjectUnit } from "@/services/api/projects";
|
||||
|
||||
type Props = {
|
||||
@@ -58,7 +59,7 @@ export function WorkflowChapterNavigator({ projectId, units, unitId, stage }: Pr
|
||||
</button>
|
||||
<button type="button" disabled={currentIndex >= orderedUnits.length - 1} onClick={() => goTo(orderedUnits[currentIndex + 1])} aria-label="下一章" title="下一章"><ChevronRight /></button>
|
||||
</div>
|
||||
<Modal open={open} footer={null} title={null} destroyOnHidden className="workspace-modal workspace-modal-wide workflow-chapter-modal" onCancel={() => setOpen(false)} styles={{ body: { padding: 0 } }}>
|
||||
<AppModal flush open={open} footer={null} title={null} className="workspace-modal workspace-modal-wide workflow-chapter-modal" onCancel={() => setOpen(false)}>
|
||||
<div className="workflow-chapter-modal-shell">
|
||||
<header className="workflow-chapter-modal-head">
|
||||
<div className="workflow-chapter-modal-title"><span><BookOpenText /></span><div><strong>定位章节</strong><p>快速搜索并跳转到需要制作的剧情章节</p></div></div>
|
||||
@@ -77,7 +78,7 @@ export function WorkflowChapterNavigator({ projectId, units, unitId, stage }: Pr
|
||||
</div>
|
||||
<footer className="workflow-chapter-modal-pagination"><PaginationBar alwaysShow current={safePage} pageSize={pageSize} total={filteredUnits.length} pageSizeOptions={[100, 200, 500]} itemLabel="章" onChange={(nextPage, nextPageSize) => { setPage(nextPage); setPageSize(nextPageSize); }} /></footer>
|
||||
</div>
|
||||
</Modal>
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { ImageSizePicker } from "@/components/image-size-picker";
|
||||
import { imageResolutionUsesQuality } from "@/lib/image-size-presets";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Form, Image, Input, InputNumber, Modal, Select } from "antd";
|
||||
import { App, Button, Form, Image, Input, InputNumber, Select } from "antd";
|
||||
import { SegmentedControl } from "@/components/ui/base/segmented-control";
|
||||
import { EmptyState } from "@/components/ui/product/empty-state";
|
||||
import { StatusBadge } from "@/components/ui/base/badges";
|
||||
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
|
||||
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Maximize2, Play, Plus, RefreshCcw, Save, Search, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
@@ -576,18 +577,17 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
<AppModal
|
||||
flush
|
||||
open={Boolean(imagePreviewArtifact?.resourceId)}
|
||||
title={imagePreviewArtifact?.type === "action_board" ? "动作预演预览" : "分镜图预览"}
|
||||
footer={null}
|
||||
centered
|
||||
destroyOnHidden
|
||||
width="min(960px, calc(100vw - 32px))"
|
||||
onCancel={() => setImagePreviewArtifact(null)}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
{imagePreviewArtifact?.resourceId ? <img className={`workflow-image-preview-modal ${imagePreviewArtifact.type === "action_board" ? "grayscale" : ""}`} src={resourceFileUrl(imagePreviewArtifact.resourceId)} alt={imagePreviewArtifact.type === "action_board" ? "动作预演大图" : "分镜图大图"} /> : null}
|
||||
</Modal>
|
||||
</AppModal>
|
||||
|
||||
<ShotTimeline activeStage={activeStage} detail={detail} shots={shots} selectedShotId={selectedShot.id} submittingShotIds={submittingShotIds} onSelectShot={requestShotSelection} onAddShot={requestAddShot} addingShot={addingShot} />
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,41 @@ export function AssetsStage({ detail, projectId, unitId }: { detail: ProjectDeta
|
||||
|
||||
export function DeliveryStage({ detail, unitId }: { detail: ProjectDetail; unitId: string }) {
|
||||
const shots = detail.shots.filter((item) => item.unitId === unitId);
|
||||
const readyVideos = shots.filter((shot) => detail.shotArtifacts.some((item) => item.shotId === shot.id && item.type === "video" && item.selected && item.status === "ready"));
|
||||
const readyVideos = shots.filter((shot) =>
|
||||
detail.shotArtifacts.some((item) => item.shotId === shot.id && item.type === "video" && item.selected && item.status === "ready"),
|
||||
);
|
||||
const stale = detail.shotArtifacts.filter((item) => item.unitId === unitId && item.status === "stale").length;
|
||||
return <section className="mx-auto max-w-5xl"><StageHeading eyebrow="06 / 交付与打包" title="交付前质量门禁" description="所有镜头视频就绪、过期产物清零后,再打包成片与生产资料。" /><div className="mt-6 grid gap-4 sm:grid-cols-3"><MetricCard icon={<Film className="size-5" />} label="视频已就绪" value={`${readyVideos.length} / ${shots.length}`} /><MetricCard icon={<Clock3 className="size-5" />} label="总时长" value={formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))} /><MetricCard icon={<Layers3 className="size-5" />} label="过期产物" value={String(stale)} /></div><div className="mt-5 border-y border-border/70 py-5"><div className="flex items-start gap-3"><PackageCheck className="mt-0.5 size-5 text-[var(--workspace-accent)]" /><div><h3 className="text-sm font-semibold">计划交付内容</h3><p className="mt-1 text-xs leading-5 text-foreground/48">成片 MP4、字幕 SRT、分镜 JSON/CSV、资产清单和生成参数 ZIP。</p></div></div><Button className="mt-5" disabled>生成交付包(后端打包尚未接入)</Button></div></section>;
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl">
|
||||
<StageHeading
|
||||
eyebrow="06 / 交付与打包"
|
||||
title="交付前质量门禁"
|
||||
description="所有镜头视频就绪、过期产物清零后,再打包成片与生产资料。"
|
||||
/>
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-3">
|
||||
<MetricCard icon={<Film className="size-5" />} label="视频已就绪" value={`${readyVideos.length} / ${shots.length}`} />
|
||||
<MetricCard
|
||||
icon={<Clock3 className="size-5" />}
|
||||
label="总时长"
|
||||
value={formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}
|
||||
/>
|
||||
<MetricCard icon={<Layers3 className="size-5" />} label="过期产物" value={String(stale)} />
|
||||
</div>
|
||||
<div className="mt-5 border-y border-border/70 py-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<PackageCheck className="mt-0.5 size-5 text-[var(--workspace-accent)]" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">计划交付内容</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/48">
|
||||
成片 MP4、字幕 SRT、分镜 JSON/CSV、资产清单和生成参数 ZIP。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-5 text-xs leading-5 text-foreground/48">
|
||||
当前版本只提供交付前检查,不提供交付包生成入口;待后端打包任务、产物存储和下载权限具备后再开放导出。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ModelPicker } from "@/components/model-picker";
|
||||
import { createStyleProfileSnapshot, parseStyleProfile, serializeStyleProfile } from "@/lib/canvas/style-profile";
|
||||
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
|
||||
import { settingsPath } from "@/lib/settings-navigation";
|
||||
import { PromptTemplateOperation, parseGeneratedStory, promptTemplateTaskPlaceholder, shortDramaOutlineVariables } from "@/lib/prompts";
|
||||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||||
import { createProject, deleteProject, importProjectUnits, listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
@@ -89,13 +90,26 @@ export default function ProjectsPage() {
|
||||
try {
|
||||
const project = await createUniqueProjectName(story, selectedStyle);
|
||||
setGenerationStatus("AI 正在生成故事大纲与章节…");
|
||||
const systemPrompt = `你是短剧编剧。根据用户的一句话故事,生成一部短剧的标题、一句话简介和 ${generateChapterCount} 个章节。生成要求:叙事采用${generateStructure}结构,每章约 ${generateWordCount} 字,使用${generatePerspective}视角,整体基调${generateTone},主要角色约 ${generateCharacterScale},章节篇幅${generateChapterLength}。只输出一个 JSON 对象,不要输出 markdown 代码块或其他文字。JSON 结构:{"title":"剧名","synopsis":"一句话简介","chapters":[{"title":"章节标题","content":"本章情节"}]}`;
|
||||
const result = await runBackendGenerationTask({
|
||||
projectId: project.project.id,
|
||||
mode: "text",
|
||||
prompt: story,
|
||||
config: { ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel, systemPrompt },
|
||||
metadata: { source: "project-story-generator", projectId: project.project.id },
|
||||
prompt: promptTemplateTaskPlaceholder("短剧大纲"),
|
||||
config: { ...effectiveConfig, model: textModel, imageModel: textModel, videoModel: textModel, textModel },
|
||||
metadata: {
|
||||
source: "project-story-generator",
|
||||
projectId: project.project.id,
|
||||
promptTemplateOperation: PromptTemplateOperation.ShortDramaOutline,
|
||||
promptTemplateVariables: shortDramaOutlineVariables({
|
||||
story,
|
||||
chapterCount: generateChapterCount,
|
||||
structure: generateStructure,
|
||||
wordCount: generateWordCount,
|
||||
perspective: generatePerspective,
|
||||
tone: generateTone,
|
||||
characterScale: generateCharacterScale,
|
||||
chapterLength: generateChapterLength,
|
||||
}),
|
||||
},
|
||||
onTextDelta: setGenerationPreview,
|
||||
});
|
||||
const answer = result.text || "";
|
||||
@@ -309,26 +323,6 @@ export default function ProjectsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function parseGeneratedStory(answer: string) {
|
||||
const cleaned = answer
|
||||
.replace(/```json/gi, "")
|
||||
.replace(/```/g, "")
|
||||
.trim();
|
||||
const match = cleaned.match(/\{[\s\S]*\}/);
|
||||
const payload = match ? JSON.parse(match[0]) : {};
|
||||
const title = String(payload.title || "").trim();
|
||||
const synopsis = String(payload.synopsis || "").trim();
|
||||
const chapters = Array.isArray(payload.chapters)
|
||||
? payload.chapters
|
||||
.map((chapter: unknown) => {
|
||||
const item = typeof chapter === "object" && chapter ? chapter as Record<string, unknown> : {};
|
||||
return { title: String(item.title || "").trim(), content: String(item.content || "").trim() };
|
||||
})
|
||||
.filter((chapter: { title: string; content: string }) => chapter.title && chapter.content)
|
||||
: [];
|
||||
return { title: title || storyTitleFromAnswer(answer), synopsis, chapters };
|
||||
}
|
||||
|
||||
async function createUniqueProjectName(story: string, selectedStyle: CanvasStylePreset | null) {
|
||||
const base = story.trim().slice(0, 24);
|
||||
const buildInput = (name: string) => ({
|
||||
@@ -352,11 +346,6 @@ async function createUniqueProjectName(story: string, selectedStyle: CanvasStyle
|
||||
}
|
||||
}
|
||||
|
||||
function storyTitleFromAnswer(answer: string) {
|
||||
const line = answer.split(/\r?\n/).find((item) => item.trim());
|
||||
return line ? line.trim().slice(0, 24) : "AI 生成短剧";
|
||||
}
|
||||
|
||||
const generationSteps = [
|
||||
{ label: "正在创建项目" },
|
||||
{ label: "AI 正在生成故事大纲与章节" },
|
||||
|
||||
Reference in New Issue
Block a user