feat(*): 创作平台 - 升级短剧分镜工作流并完善模型计费与界面状态
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Alert, App, Button, Tooltip } from "antd";
|
||||
import { ArrowLeft, BookOpenText, Images, LayoutDashboard, LayoutGrid, Plus, Settings2, type LucideIcon } from "lucide-react";
|
||||
import { Alert, App, Button, Select, Tooltip } from "antd";
|
||||
import { ArrowLeft, BookOpenText, Clapperboard, Images, LayoutDashboard, LayoutGrid, Plus, Settings2, type LucideIcon } from "lucide-react";
|
||||
import { Link, Navigate, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync";
|
||||
@@ -14,23 +14,25 @@ import ProjectCanvasesView from "./detail/canvases";
|
||||
import ProjectChaptersView from "./detail/chapters";
|
||||
import ProjectOverviewView from "./detail/overview";
|
||||
import ProjectSettingsView from "./detail/settings";
|
||||
import ProjectWorkflowView from "./detail/workflow";
|
||||
|
||||
type DetailView = "overview" | "chapters" | "canvases" | "assets" | "settings";
|
||||
type DetailView = "overview" | "chapters" | "workflow" | "canvases" | "assets" | "settings";
|
||||
|
||||
const views: Array<{ key: DetailView; label: string; shortLabel: string; icon: LucideIcon }> = [
|
||||
{ key: "overview", label: "制作概览", shortLabel: "概览", icon: LayoutDashboard },
|
||||
{ key: "chapters", label: "剧情章节", shortLabel: "章节", icon: BookOpenText },
|
||||
{ key: "workflow", label: "分镜制作", shortLabel: "分镜", icon: Clapperboard },
|
||||
{ key: "canvases", label: "项目画布", shortLabel: "画布", icon: LayoutGrid },
|
||||
{ key: "assets", label: "角色与资产", shortLabel: "资产", icon: Images },
|
||||
{ key: "settings", label: "项目设置", shortLabel: "设置", icon: Settings2 },
|
||||
];
|
||||
|
||||
export default function ProjectDetailPage() {
|
||||
const { projectId = "", view, chapterId } = useParams();
|
||||
const { projectId = "", view, chapterId, unitId, stage } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
const activeView: DetailView = chapterId ? "chapters" : views.some((item) => item.key === view) ? view as DetailView : "overview";
|
||||
const activeView: DetailView = unitId || view === "workflow" ? "workflow" : chapterId ? "chapters" : views.some((item) => item.key === view) ? view as DetailView : "overview";
|
||||
const detail = useQuery({ queryKey: ["project", projectId], queryFn: () => getProject(projectId), enabled: Boolean(projectId), refetchOnMount: "always" });
|
||||
const refreshProject = () => { void queryClient.invalidateQueries({ queryKey: ["project", projectId] }); void queryClient.invalidateQueries({ queryKey: ["projects"] }); };
|
||||
const createCanvas = () => {
|
||||
@@ -63,36 +65,48 @@ export default function ProjectDetailPage() {
|
||||
navigate(`/canvas/${id}`);
|
||||
}).catch((error) => message.error(error instanceof Error ? error.message : "画布创建失败"));
|
||||
};
|
||||
|
||||
if (detail.isLoading) return <WorkspacePage><WorkspaceLoadingState label="正在打开项目工作台" detail="读取章节、画布、资产和当前进度" /></WorkspacePage>;
|
||||
if (detail.isError || !detail.data) return <WorkspacePage><WorkspaceErrorState title="项目不可用" description="项目不存在、已被删除,或当前账号没有访问权限。" actionLabel="返回项目中心" onRetry={() => navigate("/projects")} /></WorkspacePage>;
|
||||
if (!chapterId && (!view || !views.some((item) => item.key === view))) return <Navigate to={`/projects/${projectId}/overview`} replace />;
|
||||
if (!chapterId && !unitId && (!view || !views.some((item) => item.key === view))) return <Navigate to={`/projects/${projectId}/overview`} replace />;
|
||||
if (activeView === "workflow" && !unitId && detail.data.units.length) return <Navigate to={`/projects/${projectId}/workflow/${detail.data.units.slice().sort((left, right) => left.position - right.position)[0].id}/video`} replace />;
|
||||
const chapterHref = projectChapterHref(detail.data.units, projectId, chapterId);
|
||||
const workflowHref = projectWorkflowHref(detail.data.units, projectId, unitId, stage);
|
||||
return (
|
||||
<WorkspacePage className="project-workbench-page !overflow-hidden" fluid>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="project-workbench-header shrink-0 bg-workspace-glass px-3 py-2 backdrop-blur-md sm:px-4 lg:px-5 lg:py-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 lg:min-h-16 lg:flex-nowrap">
|
||||
<header className="project-workbench-header shrink-0 border-b border-border/60 bg-workspace-glass px-3 backdrop-blur-md sm:px-4 lg:px-5">
|
||||
<div className="flex min-h-11 min-w-0 items-center gap-x-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 lg:w-[250px] lg:flex-none xl:w-[290px]">
|
||||
<button type="button" onClick={() => navigate("/projects")} className="grid size-9 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
|
||||
<button type="button" onClick={() => navigate("/projects")} className="grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-[var(--fs-body)] font-semibold text-foreground/90">{detail.data.project.name}</h1>
|
||||
<span className={`size-1.5 shrink-0 rounded-full ${detail.data.project.status === "archived" ? "bg-foreground/30" : "bg-[var(--workspace-accent)]"}`} />
|
||||
<span className="hidden shrink-0 text-[var(--fs-tiny)] text-foreground/42 sm:inline">{detail.data.project.status === "archived" ? "已归档" : "进行中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="thin-scrollbar order-last mt-1 flex h-11 w-full min-w-0 items-center gap-0.5 overflow-x-auto lg:order-none lg:mt-0 lg:h-16 lg:flex-1 lg:pl-3" aria-label="项目导航">
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[var(--fs-body)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-surface-hover hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
<nav className="thin-scrollbar flex h-11 min-w-0 flex-1 items-center gap-0.5 overflow-x-auto lg:pl-3" aria-label="项目导航">
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : item.key === "workflow" ? workflowHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-9 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-[var(--fs-caption)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-surface-hover hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-3.5 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
</nav>
|
||||
<Tooltip title={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
|
||||
{activeView === "workflow" ? (
|
||||
<Select
|
||||
size="small"
|
||||
className="hidden min-w-36 shrink-0 lg:block"
|
||||
value={unitId || detail.data.units[0]?.id}
|
||||
options={detail.data.units.slice().sort((left, right) => left.position - right.position).map((item, index) => ({ value: item.id, label: `${String(index + 1).padStart(2, "0")} · ${item.title}` }))}
|
||||
onChange={(nextUnitId) => navigate(`/projects/${projectId}/workflow/${nextUnitId}/${stage || "video"}`)}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{detail.data.project.status === "archived" ? <Alert type="warning" showIcon banner message="项目已归档,恢复后才能创建画布和生成任务" className="!border-x-0 !border-t-0" /> : null}
|
||||
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className={activeView === "chapters" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
|
||||
<div className={activeView === "overview" ? "w-full" : activeView === "chapters" ? "h-full w-full" : "w-full"}>
|
||||
<div className={activeView === "chapters" || activeView === "workflow" ? "min-h-0 flex-1" : "thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-5 sm:px-5 lg:px-8 lg:py-7"}>
|
||||
<div className={activeView === "overview" ? "w-full" : activeView === "chapters" || activeView === "workflow" ? "h-full w-full" : "w-full"}>
|
||||
{activeView === "overview" ? <ProjectOverviewView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "chapters" ? <ProjectChaptersView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "workflow" ? <ProjectWorkflowView detail={detail.data} projectId={projectId} unitId={unitId || ""} stage={stage || "video"} /> : null}
|
||||
{activeView === "canvases" ? <ProjectCanvasesView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "assets" ? <ProjectAssetsView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
{activeView === "settings" ? <ProjectSettingsView detail={detail.data} refreshProject={refreshProject} onCreateCanvas={createCanvas} /> : null}
|
||||
@@ -104,6 +118,11 @@ export default function ProjectDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function projectWorkflowHref(units: Array<{ id: string; position: number }>, projectId: string, routeUnitId?: string, routeStage?: string) {
|
||||
const targetId = [routeUnitId, sessionStorage.getItem(`project-active-chapter:${projectId}`) || ""].find((id) => id && units.some((unit) => unit.id === id)) || units.slice().sort((left, right) => left.position - right.position)[0]?.id;
|
||||
return targetId ? `/projects/${projectId}/workflow/${targetId}/${routeStage || "video"}` : `/projects/${projectId}/workflow`;
|
||||
}
|
||||
|
||||
function projectChapterHref(units: Array<{ id: string; position: number }>, projectId: string, routeChapterId?: string) {
|
||||
const rememberedId = sessionStorage.getItem(`project-active-chapter:${projectId}`) || "";
|
||||
const targetId = [routeChapterId, rememberedId].find((id) => id && units.some((unit) => unit.id === id)) || units.slice().sort((left, right) => left.position - right.position)[0]?.id;
|
||||
|
||||
478
web/src/pages/projects/detail/workflow-production-workbench.tsx
Normal file
478
web/src/pages/projects/detail/workflow-production-workbench.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { App, Button, Empty, Form, Input, InputNumber, Segmented, Select, Tag } from "antd";
|
||||
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, SlidersHorizontal, UsersRound, WandSparkles } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { runBackendGenerationTask } from "@/services/api/generation-task";
|
||||
import {
|
||||
linkShotAsset,
|
||||
createUnitWorkflow,
|
||||
registerProjectTaskOutput,
|
||||
saveProjectShot,
|
||||
type ProjectAsset,
|
||||
type ProjectDetail,
|
||||
type ProjectShot,
|
||||
type ShotArtifact,
|
||||
type ShotRevisionInput,
|
||||
type WorkflowStep,
|
||||
} from "@/services/api/projects";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { modelDisplayName, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
|
||||
import {
|
||||
ArtifactStatus,
|
||||
artifactTypeForStage,
|
||||
assetCategoryLabel,
|
||||
currentArtifact,
|
||||
currentRevision,
|
||||
formatDuration,
|
||||
type ShortDramaWorkflowStage,
|
||||
} from "./workflow-shared";
|
||||
|
||||
type ShotEditorValues = Omit<ShotRevisionInput, "durationMs"> & {
|
||||
title: string;
|
||||
durationSeconds: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
activeStage: ShortDramaWorkflowStage;
|
||||
detail: ProjectDetail;
|
||||
projectId: string;
|
||||
unitId: string;
|
||||
workflowStep?: WorkflowStep;
|
||||
selectedShot?: ProjectShot;
|
||||
onSelectShot: (id: string) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
onAddShot: () => void;
|
||||
addingShot: boolean;
|
||||
};
|
||||
|
||||
const productionStageCopy: Record<"storyboard" | "previz" | "video", { label: string; action: string; empty: string }> = {
|
||||
storyboard: { label: "分镜图", action: "生成分镜图", empty: "生成静态分镜图,确认构图、景别与角色位置" },
|
||||
previz: { label: "动作预演", action: "生成黑白预演", empty: "生成黑白动作预演,确认表演节拍与镜头运动" },
|
||||
video: { label: "镜头视频", action: "生成镜头视频", empty: "选择视频模型后生成当前镜头" },
|
||||
};
|
||||
|
||||
export default function WorkflowProductionWorkbench(props: Props) {
|
||||
const { activeStage, detail, projectId, unitId, workflowStep, selectedShot, onSelectShot, onRefresh, onAddShot, addingShot } = props;
|
||||
const { message, modal } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const effectiveConfig = useEffectiveConfig();
|
||||
const isAiConfigReady = useConfigStore((state) => state.isAiConfigReady);
|
||||
const [form] = Form.useForm<ShotEditorValues>();
|
||||
const watchedDuration = Form.useWatch("durationSeconds", form);
|
||||
const watchedTitle = Form.useWatch("title", form);
|
||||
const [leftTab, setLeftTab] = useState<"assets" | "episodes" | "shots">("assets");
|
||||
const [previewTab, setPreviewTab] = useState<"latest" | "history">("latest");
|
||||
const [previewArtifactId, setPreviewArtifactId] = useState("");
|
||||
const [editorDirty, setEditorDirty] = useState(false);
|
||||
const shots = useMemo(() => (detail.shots || []).filter((item) => item.unitId === unitId).slice().sort((left, right) => left.position - right.position), [detail.shots, unitId]);
|
||||
const shotIndex = selectedShot ? shots.findIndex((item) => item.id === selectedShot.id) : -1;
|
||||
const revision = currentRevision(detail, selectedShot);
|
||||
const artifactType = artifactTypeForStage(activeStage);
|
||||
const artifacts = useMemo(() => selectedShot ? (detail.shotArtifacts || []).filter((item) => item.shotId === selectedShot.id && item.type === artifactType).slice().sort((left, right) => right.version - left.version) : [], [artifactType, detail.shotArtifacts, selectedShot]);
|
||||
const newestArtifact = artifacts.find((item) => item.selected) || artifacts[0];
|
||||
const previewArtifact = artifacts.find((item) => item.id === previewArtifactId) || newestArtifact;
|
||||
const modelOptions = (activeStage === "video" ? effectiveConfig.videoModels : effectiveConfig.imageModels) || [];
|
||||
const defaultModel = activeStage === "video" ? effectiveConfig.videoModel : effectiveConfig.imageModel;
|
||||
const [selectedModel, setSelectedModel] = useState(defaultModel || modelOptions[0] || "");
|
||||
const [aspectRatio, setAspectRatio] = useState(detail.project.aspectRatio || "16:9");
|
||||
const [resolution, setResolution] = useState(effectiveConfig.vquality || "720");
|
||||
const modelSummary = selectedModel ? modelDisplayName(effectiveConfig, selectedModel) : "未选择模型";
|
||||
const durationSummary = `${Number(watchedDuration || Math.max(0.5, (selectedShot?.durationMs || 3000) / 1000))}s`;
|
||||
const resolutionSummary = `${resolution.replace(/p$/i, "")}p`;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedModel(defaultModel || modelOptions[0] || "");
|
||||
}, [activeStage, defaultModel, modelOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
title: selectedShot?.title || "",
|
||||
plotDescription: revision?.plotDescription || selectedShot?.description || "",
|
||||
action: revision?.action || "",
|
||||
dialogue: revision?.dialogue || "",
|
||||
shotSize: revision?.shotSize || "",
|
||||
cameraAngle: revision?.cameraAngle || "",
|
||||
cameraMovement: revision?.cameraMovement || "",
|
||||
durationSeconds: Math.max(0.5, (revision?.durationMs || selectedShot?.durationMs || 3000) / 1000),
|
||||
imagePrompt: revision?.imagePrompt || "",
|
||||
videoPrompt: revision?.videoPrompt || "",
|
||||
negativePrompt: revision?.negativePrompt || "",
|
||||
continuityNotes: revision?.continuityNotes || "",
|
||||
});
|
||||
setPreviewArtifactId("");
|
||||
setEditorDirty(!revision);
|
||||
}, [form, revision?.id, selectedShot?.id]);
|
||||
|
||||
const saveShot = useMutation({
|
||||
mutationFn: async (values: ShotEditorValues) => {
|
||||
if (!selectedShot) throw new Error("请先选择镜头");
|
||||
return saveProjectShot(projectId, {
|
||||
id: selectedShot.id,
|
||||
unitId,
|
||||
title: values.title,
|
||||
description: values.plotDescription,
|
||||
position: selectedShot.position,
|
||||
durationMs: Math.round(values.durationSeconds * 1000),
|
||||
status: selectedShot.status,
|
||||
revision: revisionInput(values),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => { setEditorDirty(false); await onRefresh(); message.success("镜头脚本已保存为新版本"); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "镜头保存失败"),
|
||||
});
|
||||
|
||||
const bindAsset = useMutation({
|
||||
mutationFn: async (asset: ProjectAsset) => {
|
||||
if (!selectedShot || !asset.primaryVersionId) throw new Error("该资产还没有可绑定版本");
|
||||
return linkShotAsset(projectId, selectedShot.id, { assetVersionId: asset.primaryVersionId, role: "reference" });
|
||||
},
|
||||
onSuccess: async () => { await onRefresh(); message.success("资产已绑定到当前镜头"); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "资产绑定失败"),
|
||||
});
|
||||
|
||||
const generateArtifact = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedShot) throw new Error("请先选择镜头");
|
||||
let productionStep = workflowStep;
|
||||
if (!productionStep) {
|
||||
const initialized = await createUnitWorkflow(projectId, unitId);
|
||||
productionStep = (initialized.workflow.steps || []).find((step) => step.stepKey === activeStage);
|
||||
}
|
||||
if (!productionStep) throw new Error("当前生成阶段不可用,请刷新页面后重试");
|
||||
if (productionStep.status === "failed") throw new Error("当前生成阶段失败,请刷新后重试");
|
||||
if (!selectedModel) throw new Error(activeStage === "video" ? "请先配置视频模型" : "请先配置图片模型");
|
||||
if (selectedModel.startsWith("local:dreamina-cli")) throw new Error("本机即梦任务暂不能登记到分镜产物,请选择后端模型渠道");
|
||||
const values = await form.validateFields();
|
||||
const saved = await saveProjectShot(projectId, {
|
||||
id: selectedShot.id,
|
||||
unitId,
|
||||
title: values.title,
|
||||
description: values.plotDescription,
|
||||
position: selectedShot.position,
|
||||
durationMs: Math.round(values.durationSeconds * 1000),
|
||||
status: selectedShot.status,
|
||||
revision: revisionInput(values),
|
||||
});
|
||||
const mode = activeStage === "video" ? "video" as const : "image" as const;
|
||||
const config = {
|
||||
...effectiveConfig,
|
||||
model: selectedModel,
|
||||
imageModel: mode === "image" ? selectedModel : effectiveConfig.imageModel,
|
||||
videoModel: mode === "video" ? selectedModel : effectiveConfig.videoModel,
|
||||
size: aspectRatio,
|
||||
vquality: resolution,
|
||||
videoSeconds: String(Math.max(1, Math.round(values.durationSeconds))),
|
||||
};
|
||||
if (!isAiConfigReady(config, selectedModel)) throw new Error("当前模型渠道配置不完整,请先到设置中补齐");
|
||||
const prompt = mode === "video"
|
||||
? [values.videoPrompt || values.plotDescription, values.action, values.continuityNotes].filter(Boolean).join("\n")
|
||||
: [values.imagePrompt || values.plotDescription, values.action, "黑白分镜草图,清晰动作节拍,电影构图"].filter(Boolean).join("\n");
|
||||
let taskId = "";
|
||||
const result = await runBackendGenerationTask({
|
||||
projectId,
|
||||
mode,
|
||||
prompt,
|
||||
config,
|
||||
referenceImages: shotReferenceImages(detail, selectedShot.id),
|
||||
metadata: {
|
||||
workflowStepId: productionStep.id,
|
||||
domainProjectId: projectId,
|
||||
unitId,
|
||||
shotId: saved.shot.id,
|
||||
artifactType,
|
||||
role: "output",
|
||||
source: "short-drama-workflow",
|
||||
},
|
||||
onTaskUpdate: (task) => { taskId = task.id; },
|
||||
});
|
||||
const storageKey = mode === "video" ? result.video?.storageKey : result.images?.[0]?.storageKey;
|
||||
const resourceId = resourceIdFromStorageKey(storageKey);
|
||||
if (!taskId || !resourceId) throw new Error("生成已结束,但产物没有可登记的资源标识");
|
||||
await registerProjectTaskOutput(projectId, productionStep.id, {
|
||||
taskId,
|
||||
unitId,
|
||||
shotId: saved.shot.id,
|
||||
artifactType,
|
||||
resourceId,
|
||||
mediaType: mode,
|
||||
role: "output",
|
||||
metadataJson: JSON.stringify({ model: selectedModel, aspectRatio, resolution, durationSeconds: values.durationSeconds }),
|
||||
});
|
||||
},
|
||||
onSuccess: async () => { setEditorDirty(false); await onRefresh(); message.success(`${productionStageCopy[activeStage as "storyboard" | "previz" | "video"].label}已生成`); },
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "生成失败"),
|
||||
});
|
||||
|
||||
const referencedVersionIds = new Set(selectedShot ? (detail.shotReferences || []).filter((item) => item.shotId === selectedShot.id).map((item) => item.assetVersionId) : []);
|
||||
const stageCopy = productionStageCopy[activeStage as "storyboard" | "previz" | "video"];
|
||||
|
||||
if (!selectedShot) {
|
||||
return <div className="workflow-empty-shot"><Empty description="当前章节还没有分镜"><Button type="primary" icon={<Plus className="size-4" />} loading={addingShot} onClick={onAddShot}>新增第一个分镜</Button></Empty></div>;
|
||||
}
|
||||
|
||||
const requestShotSelection = (nextShotId: string) => {
|
||||
if (nextShotId === selectedShot.id) return;
|
||||
if (!editorDirty) {
|
||||
onSelectShot(nextShotId);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "当前镜头有未保存修改",
|
||||
content: "切换镜头会放弃这些修改。",
|
||||
okText: "放弃修改并切换",
|
||||
cancelText: "继续编辑",
|
||||
onOk: () => onSelectShot(nextShotId),
|
||||
});
|
||||
};
|
||||
|
||||
const requestAddShot = () => {
|
||||
if (!editorDirty) {
|
||||
onAddShot();
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "当前镜头有未保存修改",
|
||||
content: "新增镜头会离开当前编辑内容。",
|
||||
okText: "放弃修改并新增",
|
||||
cancelText: "继续编辑",
|
||||
onOk: onAddShot,
|
||||
});
|
||||
};
|
||||
|
||||
const selectRelativeShot = (offset: number) => {
|
||||
const next = shots[shotIndex + offset];
|
||||
if (next) requestShotSelection(next.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="workflow-production-shell">
|
||||
<div className="workflow-production-main">
|
||||
<aside className="workflow-library-panel">
|
||||
<Segmented
|
||||
block
|
||||
size="small"
|
||||
value={leftTab}
|
||||
onChange={(value) => setLeftTab(value as typeof leftTab)}
|
||||
options={[{ value: "assets", label: "资产" }, { value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }]}
|
||||
/>
|
||||
<div className="workflow-library-scroll thin-scrollbar">
|
||||
{leftTab === "assets" ? <AssetLibrary detail={detail} referencedVersionIds={referencedVersionIds} binding={bindAsset.isPending} onBind={(asset) => bindAsset.mutate(asset)} /> : null}
|
||||
{leftTab === "episodes" ? <EpisodeLibrary detail={detail} activeUnitId={unitId} projectId={projectId} activeStage={activeStage} /> : null}
|
||||
{leftTab === "shots" ? <ShotLibrary detail={detail} shots={shots} selectedShotId={selectedShot.id} onSelectShot={requestShotSelection} /> : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="workflow-shot-editor">
|
||||
<header className="workflow-panel-header">
|
||||
<div className="workflow-shot-heading">
|
||||
<span className="workflow-shot-number">SC.{String(shotIndex + 1).padStart(2, "0")}</span>
|
||||
<h2>{watchedTitle || selectedShot.title || "未命名镜头"}</h2>
|
||||
<Tag className="!m-0" color={saveShot.isPending ? "blue" : editorDirty ? "orange" : revision ? "green" : undefined}>{saveShot.isPending ? "保存中" : editorDirty ? "有未保存修改" : revision ? "已保存" : "草稿"}</Tag>
|
||||
</div>
|
||||
<div className="flex items-center gap-1"><span className="mr-1 text-[var(--fs-micro)] text-foreground/45">{shotIndex + 1} / {shots.length}</span><Button type="text" size="small" icon={<ChevronLeft className="size-4" />} disabled={shotIndex <= 0} onClick={() => selectRelativeShot(-1)} aria-label="上一个镜头" /><Button type="text" size="small" icon={<ChevronRight className="size-4" />} disabled={shotIndex >= shots.length - 1} onClick={() => selectRelativeShot(1)} aria-label="下一个镜头" /></div>
|
||||
</header>
|
||||
<Form form={form} layout="vertical" className="workflow-shot-form" onValuesChange={() => setEditorDirty(true)} onFinish={(values) => saveShot.mutate(values)}>
|
||||
<div className="workflow-shot-form-scroll thin-scrollbar">
|
||||
<div className="workflow-form-section-heading"><span>镜头脚本</span><small>先写清镜头里发生什么,再调整生成参数</small></div>
|
||||
<Form.Item name="title" label="镜头名称" rules={[{ required: true, message: "请输入镜头名称" }]}><Input placeholder="用一句话概括这个镜头" /></Form.Item>
|
||||
<Form.Item name="plotDescription" label="镜头画面" rules={[{ required: true, message: "请输入镜头画面" }]}><Input.TextArea autoSize={{ minRows: 4, maxRows: 8 }} placeholder="描述主体、场景、动作、构图、光线,以及观众在这一镜看到的信息" /></Form.Item>
|
||||
<BoundAssets detail={detail} shotId={selectedShot.id} />
|
||||
<div className="workflow-form-grid">
|
||||
<Form.Item name="action" label="表演与动作"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="按动作节拍描述人物表演、走位和物体运动" /></Form.Item>
|
||||
<Form.Item name="dialogue" label="对白 / 旁白"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="填写对白、旁白或需要保留的声音信息" /></Form.Item>
|
||||
</div>
|
||||
<WorkflowDisclosure
|
||||
icon={<SlidersHorizontal />}
|
||||
title="生成设置"
|
||||
description="生成规格与镜头语言"
|
||||
summary={<><span>{durationSummary}</span><span>{aspectRatio}</span><span>{resolutionSummary}</span><span className="is-model">{modelSummary}</span></>}
|
||||
>
|
||||
<div className="workflow-settings-section">
|
||||
<div className="workflow-settings-section-title">生成规格</div>
|
||||
<Form.Item label="生成模型"><Select showSearch value={selectedModel || undefined} placeholder={activeStage === "video" ? "选择视频模型" : "选择图片模型"} onChange={setSelectedModel} options={modelOptions.map((value) => ({ value, label: modelDisplayName(effectiveConfig, value) }))} /></Form.Item>
|
||||
<div className="workflow-form-grid is-three">
|
||||
<Form.Item name="durationSeconds" label="镜头时长(秒)"><InputNumber className="w-full" min={0.5} max={60} step={0.5} /></Form.Item>
|
||||
<Form.Item label="画幅"><Select value={aspectRatio} onChange={setAspectRatio} options={[detail.project.aspectRatio, "16:9", "9:16", "1:1"].filter((value, index, array) => value && array.indexOf(value) === index).map((value) => ({ value, label: value }))} /></Form.Item>
|
||||
<Form.Item label="分辨率"><Select value={resolution} onChange={setResolution} options={[{ value: "480", label: "480p" }, { value: "720", label: "720p" }, { value: "1080", label: "1080p" }]} /></Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workflow-settings-section">
|
||||
<div className="workflow-settings-section-title">镜头语言</div>
|
||||
<div className="workflow-form-grid is-three">
|
||||
<Form.Item name="shotSize" label="景别"><Select allowClear placeholder="自动" options={["特写", "近景", "中景", "全景", "远景"].map((value) => ({ value, label: value }))} /></Form.Item>
|
||||
<Form.Item name="cameraAngle" label="机位角度"><Select allowClear placeholder="自动" options={["平视", "俯拍", "仰拍", "侧面", "过肩"].map((value) => ({ value, label: value }))} /></Form.Item>
|
||||
<Form.Item name="cameraMovement" label="运镜方式"><Select allowClear placeholder="自动" options={["固定", "推镜", "拉镜", "摇镜", "移镜", "跟拍"].map((value) => ({ value, label: value }))} /></Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</WorkflowDisclosure>
|
||||
<WorkflowDisclosure
|
||||
className="is-advanced"
|
||||
icon={<WandSparkles />}
|
||||
title="生成补充"
|
||||
description="仅在模型需要额外约束时填写"
|
||||
summary={<span>提示词 · 排除内容 · 接戏</span>}
|
||||
>
|
||||
<div className="workflow-form-grid">
|
||||
<Form.Item name="imagePrompt" label="画面提示词"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="留空时根据镜头画面自动生成" /></Form.Item>
|
||||
<Form.Item name="videoPrompt" label="动态提示词"><Input.TextArea autoSize={{ minRows: 3, maxRows: 6 }} placeholder="补充动作节奏、运镜变化和动态细节" /></Form.Item>
|
||||
<Form.Item name="negativePrompt" label="排除内容"><Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="填写不希望出现的元素、动作或画面问题" /></Form.Item>
|
||||
<Form.Item name="continuityNotes" label="接戏备注"><Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="记录人物位置、朝向、服装、道具及前后镜延续关系" /></Form.Item>
|
||||
</div>
|
||||
</WorkflowDisclosure>
|
||||
</div>
|
||||
<footer className="workflow-editor-actions">
|
||||
<div className="flex items-center gap-2"><Button htmlType="submit" icon={<Save className="size-4" />} loading={saveShot.isPending} disabled={!editorDirty}>保存脚本</Button><Button type="primary" icon={<Play className="size-4" />} loading={generateArtifact.isPending} onClick={() => generateArtifact.mutate()}>{stageCopy.action}</Button></div>
|
||||
</footer>
|
||||
</Form>
|
||||
</section>
|
||||
|
||||
<aside className="workflow-preview-panel">
|
||||
<header className="workflow-preview-header">
|
||||
<div className="workflow-preview-header-row">
|
||||
<div className="workflow-preview-title"><Film className="size-4 shrink-0" /><span>产物预览</span></div>
|
||||
<Segmented size="small" value={previewTab} onChange={(value) => setPreviewTab(value as typeof previewTab)} options={[{ value: "latest", label: "最新" }, { value: "history", label: `历史 ${artifacts.length}` }]} />
|
||||
</div>
|
||||
<Segmented
|
||||
block
|
||||
size="small"
|
||||
className="workflow-preview-stage-switch"
|
||||
value={activeStage}
|
||||
options={[{ value: "storyboard", label: "分镜图" }, { value: "previz", label: "动作预演" }, { value: "video", label: "镜头视频" }]}
|
||||
onChange={(nextStage) => navigate(`/projects/${projectId}/workflow/${unitId}/${nextStage}`)}
|
||||
/>
|
||||
</header>
|
||||
<div className="workflow-preview-scroll thin-scrollbar">
|
||||
{previewTab === "latest" ? <LatestPreview artifact={previewArtifact} emptyText={stageCopy.empty} /> : <ArtifactHistory artifacts={artifacts} activeId={previewArtifact?.id} onSelect={(artifact) => { setPreviewArtifactId(artifact.id); setPreviewTab("latest"); }} />}
|
||||
<div className="workflow-preview-summary"><div className="flex items-center justify-between gap-2"><span className="text-xs font-medium">当前产物</span><ArtifactStatus artifact={newestArtifact} compact /></div><div className="mt-1 text-[var(--fs-micro)] text-foreground/45">{newestArtifact ? `${formatDuration(selectedShot.durationMs)} · ${resolution}p · v${newestArtifact.version}` : "当前镜头还没有生成产物"}</div></div>
|
||||
<div className="workflow-preview-actions"><Button icon={<RefreshCcw className="size-3.5" />} loading={generateArtifact.isPending} onClick={() => generateArtifact.mutate()}>重新生成</Button><Button icon={<Download className="size-3.5" />} disabled={!previewArtifact?.resourceId} onClick={() => previewArtifact?.resourceId && void downloadArtifact(previewArtifact, selectedShot.title, message.error)}>下载{activeStage === "video" ? "视频" : "图片"}</Button></div>
|
||||
<ArtifactHistory artifacts={artifacts.slice(0, 4)} activeId={previewArtifact?.id} onSelect={(artifact) => setPreviewArtifactId(artifact.id)} compact />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ShotTimeline detail={detail} shots={shots} selectedShotId={selectedShot.id} onSelectShot={requestShotSelection} onAddShot={requestAddShot} addingShot={addingShot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowDisclosure({ icon, title, description, summary, className = "", children }: { icon: ReactNode; title: string; description: string; summary: ReactNode; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
<details className={`workflow-disclosure ${className}`}>
|
||||
<summary>
|
||||
<span className="workflow-disclosure-heading"><span className="workflow-disclosure-icon">{icon}</span><span><strong>{title}</strong><small>{description}</small></span></span>
|
||||
<span className="workflow-disclosure-summary">{summary}<ChevronDown className="workflow-disclosure-chevron" /></span>
|
||||
</summary>
|
||||
<div className="workflow-disclosure-body"><div className="workflow-disclosure-content">{children}</div></div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetLibrary({ detail, referencedVersionIds, binding, onBind }: { detail: ProjectDetail; referencedVersionIds: Set<string>; binding: boolean; onBind: (asset: ProjectAsset) => void }) {
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, ProjectAsset[]>();
|
||||
detail.assets.forEach((asset) => map.set(asset.category || "other", [...(map.get(asset.category || "other") || []), asset]));
|
||||
return Array.from(map.entries());
|
||||
}, [detail.assets]);
|
||||
if (!groups.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="项目还没有资产" />;
|
||||
return <div className="workflow-asset-groups">{groups.map(([category, assets]) => <section key={category}><h3>{assetCategoryLabel(category)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const active = Boolean(asset.primaryVersionId && referencedVersionIds.has(asset.primaryVersionId)); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={binding || !asset.primaryVersionId || active} onClick={() => onBind(asset)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定当前镜头" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>)}</div>;
|
||||
}
|
||||
|
||||
function EpisodeLibrary({ detail, activeUnitId, projectId, activeStage }: { detail: ProjectDetail; activeUnitId: string; projectId: string; activeStage: ShortDramaWorkflowStage }) {
|
||||
return <div className="workflow-simple-list">{detail.units.slice().sort((left, right) => left.position - right.position).map((unit, index) => <Link key={unit.id} to={`/projects/${projectId}/workflow/${unit.id}/${activeStage}`} className={unit.id === activeUnitId ? "is-active" : ""}><span>{String(index + 1).padStart(2, "0")}</span><strong>{unit.title}</strong></Link>)}</div>;
|
||||
}
|
||||
|
||||
function ShotLibrary({ detail, shots, selectedShotId, onSelectShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void }) {
|
||||
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{String(index + 1).padStart(2, "0")}</span><span className="min-w-0 flex-1"><strong>{shot.title}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
|
||||
}
|
||||
|
||||
function BoundAssets({ detail, shotId }: { detail: ProjectDetail; shotId: string }) {
|
||||
const references = (detail.shotReferences || []).filter((item) => item.shotId === shotId);
|
||||
const assetByVersionId = useMemo(() => new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset])), [detail.assets]);
|
||||
return (
|
||||
<div className="workflow-bound-assets">
|
||||
<div className="workflow-bound-assets-heading"><span className="workflow-field-label">镜头资产</span><small>{references.length ? `已绑定 ${references.length} 项` : "从左侧资产栏点击绑定"}</small></div>
|
||||
<div className="workflow-bound-assets-content">
|
||||
{references.length ? references.map((reference) => {
|
||||
const asset = assetByVersionId.get(reference.assetVersionId);
|
||||
return <span key={reference.id} className="workflow-bound-asset-chip"><em>{asset ? assetCategoryLabel(asset.category) : "历史"}</em><span>{asset?.title || "历史资产版本"}</span></span>;
|
||||
}) : <span>尚未绑定角色、场景或道具</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LatestPreview({ artifact, emptyText }: { artifact?: ShotArtifact; emptyText: string }) {
|
||||
if (!artifact?.resourceId) return <div className="workflow-media-empty"><span><Play className="size-7" /></span><p>{emptyText}</p></div>;
|
||||
const src = resourceFileUrl(artifact.resourceId);
|
||||
if (artifact.type === "video") return <video className="workflow-preview-media" src={src} controls preload="metadata" />;
|
||||
return <img className={`workflow-preview-media ${artifact.type === "action_board" ? "grayscale" : ""}`} src={src} alt="镜头生成预览" loading="eager" />;
|
||||
}
|
||||
|
||||
function ArtifactHistory({ artifacts, activeId, onSelect, compact = false }: { artifacts: ShotArtifact[]; activeId?: string; onSelect: (artifact: ShotArtifact) => void; compact?: boolean }) {
|
||||
if (!artifacts.length) return compact ? null : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无历史版本" />;
|
||||
return <section className={`workflow-history ${compact ? "is-compact" : ""}`}><div className="workflow-history-title">历史版本</div>{artifacts.map((artifact) => <button key={artifact.id} type="button" className={artifact.id === activeId ? "is-active" : ""} onClick={() => onSelect(artifact)}>{artifact.resourceId ? artifact.type === "video" ? <video src={resourceFileUrl(artifact.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(artifact.resourceId)} alt="" loading="lazy" /> : <span className="workflow-history-placeholder"><Layers3 /></span>}<span className="min-w-0 flex-1"><strong>v{artifact.version}{artifact.selected ? " · 当前" : ""}</strong><small>{new Date(artifact.createdAt).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })}</small></span><ArtifactStatus artifact={artifact} compact /></button>)}</section>;
|
||||
}
|
||||
|
||||
function ShotTimeline({ detail, shots, selectedShotId, onSelectShot, onAddShot, addingShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void; onAddShot: () => void; addingShot: boolean }) {
|
||||
return <section className="workflow-shot-timeline"><header><div><strong>{detail.units.find((item) => item.id === shots[0]?.unitId)?.title || "本集"}</strong><span>{shots.length} 镜 · 总时长 {formatDuration(shots.reduce((total, item) => total + item.durationMs, 0))}</span></div><div className="flex items-center gap-1 text-[var(--fs-micro)] text-foreground/40"><List className="size-3.5" /> 共 {shots.length} 镜</div></header><div className="workflow-shot-track thin-scrollbar">{shots.map((shot, index) => <TimelineShot key={shot.id} detail={detail} shot={shot} index={index} selected={shot.id === selectedShotId} onSelect={() => onSelectShot(shot.id)} />)}<button type="button" className="workflow-add-shot-card" disabled={addingShot} onClick={onAddShot}><Plus className="size-5" /><span>新增分镜</span></button></div></section>;
|
||||
}
|
||||
|
||||
function TimelineShot({ detail, shot, index, selected, onSelect }: { detail: ProjectDetail; shot: ProjectShot; index: number; selected: boolean; onSelect: () => void }) {
|
||||
const video = currentArtifact(detail, shot.id, "video");
|
||||
const previz = currentArtifact(detail, shot.id, "action_board");
|
||||
const preview = video?.resourceId ? video : previz?.resourceId ? previz : undefined;
|
||||
const stateArtifact = video || previz;
|
||||
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span><strong>SC.{String(index + 1).padStart(2, "0")}</strong><small>{formatDuration(shot.durationMs)}</small></span><em>{shot.title}</em><ArtifactStatus artifact={stateArtifact} compact /></span></button>;
|
||||
}
|
||||
|
||||
function revisionInput(values: ShotEditorValues): ShotRevisionInput {
|
||||
return {
|
||||
plotDescription: values.plotDescription,
|
||||
action: values.action,
|
||||
dialogue: values.dialogue,
|
||||
shotSize: values.shotSize,
|
||||
cameraAngle: values.cameraAngle,
|
||||
cameraMovement: values.cameraMovement,
|
||||
durationMs: Math.round(values.durationSeconds * 1000),
|
||||
imagePrompt: values.imagePrompt,
|
||||
videoPrompt: values.videoPrompt,
|
||||
negativePrompt: values.negativePrompt,
|
||||
continuityNotes: values.continuityNotes,
|
||||
};
|
||||
}
|
||||
|
||||
function shotReferenceImages(detail: ProjectDetail, shotId: string): ReferenceImage[] {
|
||||
const versionIds = new Set((detail.shotReferences || []).filter((item) => item.shotId === shotId).map((item) => item.assetVersionId));
|
||||
return detail.assets.filter((asset) => asset.primaryVersionId && versionIds.has(asset.primaryVersionId) && asset.mediaType === "image" && asset.storageKey).map((asset) => {
|
||||
const resourceId = resourceIdFromStorageKey(asset.storageKey);
|
||||
return { id: asset.id, name: asset.title, type: "image/*", dataUrl: "", ...(resourceId ? { url: resourceFileUrl(resourceId) } : {}), storageKey: asset.storageKey };
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadArtifact(artifact: ShotArtifact, shotTitle: string, onError: (content: string) => void) {
|
||||
if (!artifact.resourceId) return;
|
||||
try {
|
||||
const response = await fetch(resourceFileUrl(artifact.resourceId), { credentials: "include" });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `${shotTitle || "shot"}-v${artifact.version}.${artifact.type === "video" ? "mp4" : "png"}`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? `下载失败:${error.message}` : "下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
function assetPreviewUrl(asset: ProjectAsset) {
|
||||
const representation = asset.character?.representations?.find((item) => item.role === "primary") || asset.character?.representations?.[0];
|
||||
if (representation?.resourceId) return resourceFileUrl(representation.resourceId);
|
||||
const resourceId = resourceIdFromStorageKey(asset.storageKey);
|
||||
return resourceId && asset.mediaType === "image" ? resourceFileUrl(resourceId) : "";
|
||||
}
|
||||
83
web/src/pages/projects/detail/workflow-shared.tsx
Normal file
83
web/src/pages/projects/detail/workflow-shared.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Tag } from "antd";
|
||||
import { Check, CircleAlert } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { ProjectDetail, ProjectShot, ShotArtifact, ShotRevision, WorkflowStep } from "@/services/api/projects";
|
||||
|
||||
export type ShortDramaWorkflowStage = "story" | "assets" | "storyboard" | "previz" | "video" | "delivery";
|
||||
|
||||
export const workflowStages: Array<{ key: ShortDramaWorkflowStage; label: string; shortLabel: string }> = [
|
||||
{ key: "story", label: "剧情与章节", shortLabel: "剧情" },
|
||||
{ key: "assets", label: "资产拆分", shortLabel: "资产" },
|
||||
{ key: "storyboard", label: "分镜脚本", shortLabel: "分镜" },
|
||||
{ key: "previz", label: "黑白动作预演", shortLabel: "预演" },
|
||||
{ key: "video", label: "视频生成", shortLabel: "视频" },
|
||||
{ key: "delivery", label: "交付与打包", shortLabel: "交付" },
|
||||
];
|
||||
|
||||
export function WorkflowStageLink({ href, active, step, index, label, shortLabel }: { href: string; active: boolean; step?: WorkflowStep; index: number; label: string; shortLabel: string }) {
|
||||
const completed = step?.status === "completed" || step?.status === "skipped";
|
||||
const failed = step?.status === "failed";
|
||||
return (
|
||||
<Link to={href} className={`workflow-stage-link ${active ? "is-active" : ""}`} aria-current={active ? "step" : undefined}>
|
||||
<span className={`workflow-stage-index ${completed ? "is-completed" : failed ? "is-failed" : ""}`}>
|
||||
{completed ? <Check className="size-3" /> : failed ? <CircleAlert className="size-3" /> : index + 1}
|
||||
</span>
|
||||
<span className="sm:hidden">{shortLabel}</span>
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function StageHeading({ eyebrow, title, description }: { eyebrow: string; title: string; description: string }) {
|
||||
return <div><div className="text-[var(--fs-micro)] font-medium uppercase tracking-[.18em] text-[var(--workspace-accent)]">{eyebrow}</div><h2 className="mt-2 text-xl font-semibold tracking-tight text-foreground sm:text-2xl">{title}</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-foreground/50">{description}</p></div>;
|
||||
}
|
||||
|
||||
export function MetricCard({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||||
return <div className="rounded-xl border border-border/70 bg-surface p-5"><div className="text-foreground/35">{icon}</div><div className="mt-4 text-2xl font-semibold">{value}</div><div className="mt-1 text-xs text-foreground/45">{label}</div></div>;
|
||||
}
|
||||
|
||||
export function ArtifactStatus({ artifact, compact = false }: { artifact?: ShotArtifact; compact?: boolean }) {
|
||||
if (!artifact) return <Tag className={compact ? "!m-0" : ""}>待生成</Tag>;
|
||||
const color = artifact.status === "ready" ? "green" : artifact.status === "failed" ? "red" : artifact.status === "stale" ? "orange" : "blue";
|
||||
const label = artifact.status === "ready" ? "已生成" : artifact.status === "failed" ? "失败" : artifact.status === "stale" ? "已过期" : "生成中";
|
||||
return <Tag className={compact ? "!m-0" : ""} color={color}>{label}</Tag>;
|
||||
}
|
||||
|
||||
export function currentRevision(detail: ProjectDetail, shot?: ProjectShot): ShotRevision | undefined {
|
||||
if (!shot) return undefined;
|
||||
const revisions = detail.shotRevisions || [];
|
||||
return revisions.find((item) => item.id === shot.currentRevisionId)
|
||||
|| revisions.filter((item) => item.shotId === shot.id).slice().sort((left, right) => right.version - left.version)[0];
|
||||
}
|
||||
|
||||
export function currentArtifact(detail: ProjectDetail, shotId: string, type: string) {
|
||||
const artifacts = (detail.shotArtifacts || []).filter((item) => item.shotId === shotId && item.type === type).slice().sort((left, right) => right.version - left.version);
|
||||
return artifacts.find((item) => item.selected) || artifacts[0];
|
||||
}
|
||||
|
||||
export function artifactTypeForStage(stage: ShortDramaWorkflowStage) {
|
||||
if (stage === "video") return "video";
|
||||
if (stage === "previz") return "action_board";
|
||||
return "storyboard";
|
||||
}
|
||||
|
||||
export function assetCategoryLabel(category: string) {
|
||||
return ({ character: "角色", environment: "场景", wardrobe: "服饰", accessory: "配饰", prop: "道具", weapon: "武器", style: "画风" } as Record<string, string>)[category] || category || "其他";
|
||||
}
|
||||
|
||||
export function formatDuration(durationMs: number) {
|
||||
if (durationMs < 60_000) return `${Math.round(durationMs / 100) / 10}s`;
|
||||
const minutes = Math.floor(durationMs / 60_000);
|
||||
const seconds = Math.round((durationMs % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function stageActionLabel(step?: WorkflowStep) {
|
||||
if (!step || step.status === "pending") return "等待上一步";
|
||||
if (step.status === "running" || step.status === "review") return "完成阶段";
|
||||
if (step.status === "completed") return "重新打开";
|
||||
if (step.status === "failed") return "重新开始";
|
||||
return "开始阶段";
|
||||
}
|
||||
25
web/src/pages/projects/detail/workflow-stage-views.tsx
Normal file
25
web/src/pages/projects/detail/workflow-stage-views.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Button } from "antd";
|
||||
import { Clock3, Film, Layers3, PackageCheck } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { ProjectDetail } from "@/services/api/projects";
|
||||
|
||||
import { assetCategoryLabel, formatDuration, MetricCard, StageHeading } from "./workflow-shared";
|
||||
|
||||
export function StoryStage({ detail, projectId, unitId }: { detail: ProjectDetail; projectId: string; unitId: string }) {
|
||||
const unit = detail.units.find((item) => item.id === unitId)!;
|
||||
return <section className="mx-auto max-w-5xl"><StageHeading eyebrow="01 / 剧情与章节" title={unit.title} description="章节原文是资产拆分、分镜版本和生成提示的唯一来源。" /><div className="mt-6 rounded-xl border border-border/70 bg-surface p-5"><div className="mb-3 flex items-center justify-between"><span className="text-xs font-medium text-foreground/55">章节原文</span><Link to={`/projects/${projectId}/chapters/${unit.id}`}><Button size="small">编辑章节</Button></Link></div><div className="max-h-[60vh] whitespace-pre-wrap text-sm leading-7 text-foreground/78">{unit.sourceText || "当前章节还没有正文。请先在剧情章节中上传小说或添加内容。"}</div></div></section>;
|
||||
}
|
||||
|
||||
export function AssetsStage({ detail, projectId, unitId }: { detail: ProjectDetail; projectId: string; unitId: string }) {
|
||||
const candidates = detail.assetCandidates.filter((item) => !item.unitId || item.unitId === unitId);
|
||||
const categories = ["character", "environment", "wardrobe", "prop", "weapon"];
|
||||
return <section className="mx-auto max-w-6xl"><StageHeading eyebrow="02 / 资产拆分" title="确认镜头真正会使用的资产" description="角色、场景、服饰、配饰与武器先建立稳定版本,镜头再绑定具体版本。" /><div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{categories.map((category) => { const confirmed = detail.assets.filter((item) => item.category === category).length; const pending = candidates.filter((item) => item.category === category && item.status === "pending_confirmation").length; return <div key={category} className="border-t border-border/70 py-4"><div className="text-xs font-medium text-foreground/55">{assetCategoryLabel(category)}</div><div className="mt-3 text-2xl font-semibold">{confirmed}</div><div className="mt-1 text-[var(--fs-micro)] text-foreground/42">已确认 · {pending} 待处理</div></div>; })}</div><div className="mt-5 flex items-center justify-between border-y border-border/70 py-5"><div><h3 className="text-sm font-semibold">资产库承担版本确认与设定维护</h3><p className="mt-1 text-xs text-foreground/48">确认后可直接在分镜工作台左栏绑定到镜头。</p></div><Link to={`/projects/${projectId}/assets`}><Button type="primary">打开资产库</Button></Link></div></section>;
|
||||
}
|
||||
|
||||
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 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>;
|
||||
}
|
||||
661
web/src/pages/projects/detail/workflow.css
Normal file
661
web/src/pages/projects/detail/workflow.css
Normal file
@@ -0,0 +1,661 @@
|
||||
.workflow-page-root {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
|
||||
.workflow-stage-content {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workflow-overview-scroll {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 28px 32px 64px;
|
||||
}
|
||||
|
||||
.workflow-production-shell {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr) clamp(168px, 20vh, 210px);
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
|
||||
.workflow-production-main {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-columns: 220px minmax(500px, 1fr) 370px;
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
}
|
||||
|
||||
.workflow-library-panel,
|
||||
.workflow-shot-editor,
|
||||
.workflow-preview-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--workspace-surface);
|
||||
animation: workflow-panel-enter var(--motion-panel) var(--motion-ease-out) both;
|
||||
}
|
||||
|
||||
.workflow-shot-editor { animation-delay: 35ms; }
|
||||
.workflow-preview-panel { animation-delay: 70ms; }
|
||||
|
||||
.workflow-library-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-right: 1px solid var(--workspace-border);
|
||||
padding: 12px 10px 0;
|
||||
}
|
||||
|
||||
.workflow-library-panel .ant-segmented {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.workflow-library-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 2px 0 14px;
|
||||
}
|
||||
|
||||
.workflow-asset-groups {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.workflow-asset-groups h3 {
|
||||
margin: 0 8px 6px;
|
||||
color: color-mix(in srgb, var(--foreground) 47%, transparent);
|
||||
font-size: var(--fs-micro);
|
||||
font-weight: 650;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.workflow-asset-groups h3 span { color: color-mix(in srgb, var(--foreground) 28%, transparent); font-weight: 500; }
|
||||
.workflow-asset-list { display: grid; gap: 2px; }
|
||||
|
||||
.workflow-asset-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: var(--r-md);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
transition: background-color var(--motion-instant) ease-out, transform var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.workflow-asset-row:hover:not(:disabled),
|
||||
.workflow-asset-row:focus-visible { background: var(--surface-hover); }
|
||||
.workflow-asset-row:hover:not(:disabled) { transform: translateX(2px); }
|
||||
.workflow-asset-row:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
|
||||
.workflow-asset-row.is-active { background: var(--workspace-accent-soft); }
|
||||
.workflow-asset-row:disabled { cursor: default; opacity: 1; }
|
||||
|
||||
.workflow-asset-thumb {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 92%, var(--workspace-accent) 8%);
|
||||
color: color-mix(in srgb, var(--foreground) 44%, transparent);
|
||||
}
|
||||
|
||||
.workflow-asset-thumb svg { width: 16px; height: 16px; }
|
||||
.workflow-asset-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.workflow-asset-row strong { display: block; overflow: hidden; color: color-mix(in srgb, var(--foreground) 84%, transparent); font-size: var(--fs-caption); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workflow-asset-row small { display: block; margin-top: 2px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 39%, transparent); font-size: var(--fs-micro); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workflow-bound-dot { width: 7px; height: 7px; flex: 0 0 auto; border-radius: var(--r-full); background: var(--workspace-accent); }
|
||||
|
||||
.workflow-simple-list { display: grid; gap: 3px; }
|
||||
.workflow-simple-list a,
|
||||
.workflow-simple-list button {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border-radius: var(--r-md);
|
||||
padding: 7px 9px;
|
||||
color: color-mix(in srgb, var(--foreground) 56%, transparent);
|
||||
text-align: left;
|
||||
}
|
||||
.workflow-simple-list a:hover,
|
||||
.workflow-simple-list button:hover,
|
||||
.workflow-simple-list a.is-active,
|
||||
.workflow-simple-list button.is-active { background: var(--surface-hover); color: var(--foreground); }
|
||||
.workflow-simple-list a.is-active,
|
||||
.workflow-simple-list button.is-active { background: var(--workspace-accent-soft); }
|
||||
.workflow-simple-list a > span:first-child,
|
||||
.workflow-simple-list button > span:first-child { color: color-mix(in srgb, var(--foreground) 34%, transparent); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; }
|
||||
.workflow-simple-list strong { display: block; overflow: hidden; font-size: var(--fs-caption); font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workflow-simple-list small { display: block; margin-top: 2px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); }
|
||||
|
||||
.workflow-shot-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workflow-panel-header {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
flex: 0 0 48px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.workflow-shot-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.workflow-shot-heading h2 {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--fs-body);
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-shot-number {
|
||||
flex: 0 0 auto;
|
||||
color: color-mix(in srgb, var(--foreground) 42%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
.workflow-preview-header {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--workspace-border);
|
||||
padding: 9px 12px 10px;
|
||||
}
|
||||
|
||||
.workflow-preview-header-row {
|
||||
display: flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workflow-preview-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.workflow-preview-stage-switch { width: 100%; }
|
||||
|
||||
.workflow-shot-form {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workflow-shot-form-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
|
||||
.workflow-shot-form .ant-form-item { margin-bottom: 14px; }
|
||||
.workflow-shot-form .ant-form-item-label { padding-bottom: 5px; }
|
||||
.workflow-shot-form .ant-form-item-label > label,
|
||||
.workflow-field-label { color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-micro); font-weight: 560; }
|
||||
|
||||
.workflow-form-section-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.workflow-form-section-heading > span {
|
||||
color: color-mix(in srgb, var(--foreground) 82%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workflow-form-section-heading > small {
|
||||
overflow: hidden;
|
||||
color: color-mix(in srgb, var(--foreground) 36%, transparent);
|
||||
font-size: var(--fs-micro);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }
|
||||
.workflow-form-grid.is-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
|
||||
.workflow-disclosure {
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 76%, transparent);
|
||||
transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.workflow-disclosure:hover { border-color: var(--workspace-border-strong); }
|
||||
.workflow-disclosure[open] {
|
||||
border-color: color-mix(in srgb, var(--workspace-accent) 36%, var(--workspace-border));
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 90%, transparent);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
}
|
||||
|
||||
.workflow-disclosure > summary {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 9px 12px;
|
||||
transition: background-color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.workflow-disclosure > summary::-webkit-details-marker { display: none; }
|
||||
.workflow-disclosure > summary:hover { background: var(--surface-hover); }
|
||||
.workflow-disclosure > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
|
||||
|
||||
.workflow-disclosure-heading {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workflow-disclosure-heading strong { display: block; font-size: var(--fs-caption); font-weight: 650; }
|
||||
.workflow-disclosure-heading small { display: block; margin-top: 2px; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-micro); }
|
||||
|
||||
.workflow-disclosure-icon {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--workspace-accent-soft);
|
||||
color: var(--workspace-accent);
|
||||
}
|
||||
|
||||
.workflow-disclosure-icon svg { width: 15px; height: 15px; }
|
||||
|
||||
.workflow-disclosure-summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
color: color-mix(in srgb, var(--foreground) 48%, transparent);
|
||||
}
|
||||
|
||||
.workflow-disclosure-summary > span {
|
||||
max-width: 104px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-full);
|
||||
background: var(--workspace-surface);
|
||||
padding: 3px 7px;
|
||||
font-size: var(--fs-micro);
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-disclosure-summary > span.is-model { max-width: 150px; }
|
||||
.workflow-disclosure.is-advanced .workflow-disclosure-summary > span { max-width: 190px; }
|
||||
|
||||
.workflow-disclosure-chevron {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: 2px;
|
||||
transition: transform var(--motion-instant) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.workflow-disclosure[open] .workflow-disclosure-chevron { transform: rotate(180deg); }
|
||||
|
||||
.workflow-disclosure-body {
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
animation: workflow-disclosure-reveal var(--motion-state) var(--motion-ease-out) both;
|
||||
}
|
||||
|
||||
.workflow-disclosure-content { padding: 13px 14px 4px; }
|
||||
.workflow-disclosure .ant-form-item { margin-bottom: 10px; }
|
||||
|
||||
.workflow-settings-section + .workflow-settings-section {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.workflow-settings-section-title {
|
||||
margin-bottom: 9px;
|
||||
color: color-mix(in srgb, var(--foreground) 58%, transparent);
|
||||
font-size: var(--fs-micro);
|
||||
font-weight: 650;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
.workflow-bound-assets { margin-bottom: 14px; }
|
||||
.workflow-bound-assets-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.workflow-bound-assets-heading small {
|
||||
color: color-mix(in srgb, var(--foreground) 34%, transparent);
|
||||
font-size: var(--fs-micro);
|
||||
}
|
||||
.workflow-bound-assets-content {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 5px;
|
||||
border: 1px dashed var(--workspace-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
padding: 8px 10px;
|
||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
transition: border-color var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.workflow-bound-assets-content:hover { border-color: var(--workspace-border-strong); }
|
||||
|
||||
.workflow-bound-asset-chip {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--workspace-surface);
|
||||
color: color-mix(in srgb, var(--foreground) 72%, transparent);
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
|
||||
.workflow-bound-asset-chip em {
|
||||
align-self: stretch;
|
||||
padding: 4px 6px;
|
||||
background: var(--workspace-accent-soft);
|
||||
color: color-mix(in srgb, var(--foreground) 54%, transparent);
|
||||
font-size: var(--fs-micro);
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.workflow-bound-asset-chip > span {
|
||||
overflow: hidden;
|
||||
padding: 4px 7px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-editor-actions {
|
||||
display: flex;
|
||||
min-height: 58px;
|
||||
flex: 0 0 58px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--workspace-border);
|
||||
padding: 9px 16px;
|
||||
background: color-mix(in srgb, var(--workspace-surface) 92%, transparent);
|
||||
box-shadow: 0 -8px 24px color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.workflow-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--workspace-border);
|
||||
}
|
||||
|
||||
.workflow-preview-scroll {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.workflow-preview-media,
|
||||
.workflow-media-empty {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: var(--r-lg);
|
||||
background: color-mix(in srgb, var(--foreground) 5%, var(--workspace-surface));
|
||||
object-fit: cover;
|
||||
transition: border-color var(--motion-instant) ease-out, transform var(--motion-instant) var(--motion-ease-out);
|
||||
}
|
||||
|
||||
.workflow-preview-media:hover { transform: scale(1.005); }
|
||||
|
||||
.workflow-media-empty {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 10px;
|
||||
border: 1px dashed var(--workspace-border-strong);
|
||||
color: color-mix(in srgb, var(--foreground) 36%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workflow-media-empty > span {
|
||||
display: grid;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
place-items: center;
|
||||
border-radius: var(--r-full);
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 88%, var(--workspace-accent) 12%);
|
||||
color: var(--workspace-accent);
|
||||
}
|
||||
.workflow-media-empty p { max-width: 220px; font-size: var(--fs-caption); line-height: 1.6; }
|
||||
|
||||
.workflow-preview-summary {
|
||||
margin-top: 12px;
|
||||
border-block: 1px solid var(--workspace-border);
|
||||
padding: 11px 2px;
|
||||
}
|
||||
|
||||
.workflow-preview-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.workflow-history { margin-top: 16px; }
|
||||
.workflow-history-title { margin-bottom: 7px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-caption); font-weight: 650; }
|
||||
.workflow-history button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-md);
|
||||
padding: 6px;
|
||||
text-align: left;
|
||||
transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, transform var(--motion-instant) ease-out;
|
||||
}
|
||||
.workflow-history button + button { margin-top: 7px; }
|
||||
.workflow-history button:hover,
|
||||
.workflow-history button.is-active { border-color: var(--workspace-border-strong); background: var(--surface-hover); }
|
||||
.workflow-history button:hover { transform: translateY(-1px); }
|
||||
.workflow-history button.is-active { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 55%, transparent); }
|
||||
.workflow-history img,
|
||||
.workflow-history video,
|
||||
.workflow-history-placeholder { width: 66px; height: 40px; flex: 0 0 auto; border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 6%, transparent); object-fit: cover; }
|
||||
.workflow-history-placeholder { display: grid; place-items: center; color: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
.workflow-history-placeholder svg { width: 16px; height: 16px; }
|
||||
.workflow-history strong { display: block; font-size: var(--fs-caption); font-weight: 600; }
|
||||
.workflow-history small { display: block; margin-top: 2px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); }
|
||||
|
||||
.workflow-shot-timeline {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: var(--workspace-surface);
|
||||
}
|
||||
|
||||
.workflow-shot-timeline > header {
|
||||
display: flex;
|
||||
min-height: 42px;
|
||||
flex: 0 0 42px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.workflow-shot-timeline > header strong { font-size: var(--fs-caption); font-weight: 650; }
|
||||
.workflow-shot-timeline > header span { margin-left: 8px; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-micro); }
|
||||
|
||||
.workflow-shot-track {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 0 14px 12px;
|
||||
}
|
||||
|
||||
.workflow-timeline-shot,
|
||||
.workflow-add-shot-card {
|
||||
width: 148px;
|
||||
min-width: 148px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--workspace-border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--workspace-surface-strong);
|
||||
text-align: left;
|
||||
transition: border-color var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out, box-shadow var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.workflow-timeline-shot:hover,
|
||||
.workflow-timeline-shot:focus-visible { border-color: var(--workspace-border-strong); background: var(--surface-hover); }
|
||||
.workflow-timeline-shot:focus-visible,
|
||||
.workflow-add-shot-card:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
|
||||
.workflow-timeline-shot.is-active { border-color: var(--workspace-accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 42%, transparent); }
|
||||
|
||||
.workflow-timeline-media {
|
||||
display: grid;
|
||||
height: 62px;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--foreground) 5%, var(--workspace-surface));
|
||||
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
}
|
||||
.workflow-timeline-media img,
|
||||
.workflow-timeline-media video { width: 100%; height: 100%; object-fit: cover; }
|
||||
.workflow-timeline-media > svg { width: 20px; height: 20px; }
|
||||
.workflow-timeline-copy { display: block; padding: 7px 9px 8px; }
|
||||
.workflow-timeline-copy > span:first-child { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
|
||||
.workflow-timeline-copy strong { font-size: var(--fs-micro); font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.workflow-timeline-copy small { color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: 10px; }
|
||||
.workflow-timeline-copy em { display: block; overflow: hidden; margin: 3px 0 6px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-micro); font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.workflow-add-shot-card {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 6px;
|
||||
border-style: dashed;
|
||||
color: color-mix(in srgb, var(--foreground) 42%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
.workflow-add-shot-card:hover { border-color: var(--workspace-accent); color: var(--foreground); }
|
||||
.workflow-add-shot-card:hover { background: var(--workspace-accent-soft); }
|
||||
.workflow-add-shot-card span { font-size: var(--fs-caption); }
|
||||
|
||||
.workflow-empty-shot {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
place-items: center;
|
||||
border: 1px dashed var(--workspace-border-strong);
|
||||
margin: 14px;
|
||||
border-radius: var(--r-lg);
|
||||
}
|
||||
|
||||
.workflow-workbench-loading {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
place-items: center;
|
||||
color: color-mix(in srgb, var(--foreground) 44%, transparent);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
@keyframes workflow-panel-enter {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes workflow-disclosure-reveal {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.workflow-production-main { grid-template-columns: 204px minmax(420px, 1fr) 320px; }
|
||||
.workflow-disclosure-summary > span.is-model { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.workflow-production-main { grid-template-columns: minmax(420px, 1fr) 300px; }
|
||||
.workflow-library-panel { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.workflow-production-shell { grid-template-rows: minmax(0, 1fr) 148px; }
|
||||
.workflow-production-main { display: block; overflow: hidden; }
|
||||
.workflow-shot-editor { height: 100%; }
|
||||
.workflow-preview-panel { display: none; }
|
||||
.workflow-form-grid,
|
||||
.workflow-form-grid.is-three { grid-template-columns: 1fr; }
|
||||
.workflow-disclosure-summary > span { display: none; }
|
||||
.workflow-shot-form-scroll { padding-inline: 14px; }
|
||||
.workflow-timeline-shot,
|
||||
.workflow-add-shot-card { width: 138px; min-width: 138px; }
|
||||
.workflow-overview-scroll { padding: 20px 16px 48px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workflow-library-panel,
|
||||
.workflow-shot-editor,
|
||||
.workflow-preview-panel,
|
||||
.workflow-disclosure-body { animation: none; }
|
||||
.workflow-asset-row,
|
||||
.workflow-preview-media,
|
||||
.workflow-history button,
|
||||
.workflow-timeline-shot,
|
||||
.workflow-add-shot-card,
|
||||
.workflow-disclosure-chevron { transition: none; }
|
||||
}
|
||||
81
web/src/pages/projects/detail/workflow.tsx
Normal file
81
web/src/pages/projects/detail/workflow.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { App, Button, Empty } from "antd";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { saveProjectShot, type ProjectDetail } from "@/services/api/projects";
|
||||
|
||||
import { AssetsStage, DeliveryStage, StoryStage } from "./workflow-stage-views";
|
||||
import { type ShortDramaWorkflowStage, workflowStages } from "./workflow-shared";
|
||||
import "./workflow.css";
|
||||
|
||||
const WorkflowProductionWorkbench = lazy(() => import("./workflow-production-workbench"));
|
||||
|
||||
type Props = {
|
||||
detail: ProjectDetail;
|
||||
projectId: string;
|
||||
unitId: string;
|
||||
stage: string;
|
||||
};
|
||||
|
||||
export default function ProjectWorkflowView({ detail, projectId, unitId, stage }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const { message } = App.useApp();
|
||||
const orderedUnits = useMemo(() => detail.units.slice().sort((left, right) => left.position - right.position), [detail.units]);
|
||||
const unit = orderedUnits.find((item) => item.id === unitId) || orderedUnits[0];
|
||||
const activeStage = workflowStages.some((item) => item.key === stage) ? stage as ShortDramaWorkflowStage : "video";
|
||||
const shots = useMemo(() => (detail.shots || []).filter((item) => item.unitId === unit?.id).slice().sort((left, right) => left.position - right.position), [detail.shots, unit?.id]);
|
||||
const workflow = useMemo(() => (detail.workflows || []).find((item) => item.instance?.unitId === unit?.id && (item.steps || []).some((step) => step.stepKey === "previz")), [detail.workflows, unit?.id]);
|
||||
const rememberedShotId = sessionStorage.getItem(`project-workflow-selected-shot:${projectId}`) || "";
|
||||
const [selectedShotId, setSelectedShotId] = useState(rememberedShotId);
|
||||
const selectedShot = shots.find((item) => item.id === selectedShotId) || shots[0];
|
||||
const activeStep = workflow?.steps?.find((item) => item.stepKey === activeStage);
|
||||
const productionStage = activeStage === "storyboard" || activeStage === "previz" || activeStage === "video";
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedShot) return;
|
||||
if (selectedShot.id !== selectedShotId) setSelectedShotId(selectedShot.id);
|
||||
sessionStorage.setItem(`project-workflow-selected-shot:${projectId}`, selectedShot.id);
|
||||
}, [projectId, selectedShot, selectedShotId]);
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["project", projectId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||
]);
|
||||
};
|
||||
const addShot = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!unit) throw new Error("请先添加章节");
|
||||
return saveProjectShot(projectId, {
|
||||
unitId: unit.id,
|
||||
title: `SC.${String(shots.length + 1).padStart(2, "0")}`,
|
||||
description: "待补充分镜画面",
|
||||
position: shots.length,
|
||||
durationMs: 3000,
|
||||
revision: { plotDescription: "待补充分镜画面", durationMs: 3000 },
|
||||
});
|
||||
},
|
||||
onSuccess: async ({ shot }) => {
|
||||
setSelectedShotId(shot.id);
|
||||
sessionStorage.setItem(`project-workflow-selected-shot:${projectId}`, shot.id);
|
||||
await refresh();
|
||||
message.success("已新增分镜");
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "新增分镜失败"),
|
||||
});
|
||||
if (!unit) {
|
||||
return <div className="grid h-full place-items-center"><Empty description="先添加一个章节,再进入分镜制作"><Link to={`/projects/${projectId}/chapters`}><Button type="primary">添加章节</Button></Link></Empty></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workflow-page-root">
|
||||
<main className={`workflow-stage-content ${productionStage ? "is-production" : ""}`}>
|
||||
{activeStage === "story" ? <div className="workflow-overview-scroll thin-scrollbar"><StoryStage detail={detail} projectId={projectId} unitId={unit.id} /></div> : null}
|
||||
{activeStage === "assets" ? <div className="workflow-overview-scroll thin-scrollbar"><AssetsStage detail={detail} projectId={projectId} unitId={unit.id} /></div> : null}
|
||||
{productionStage ? <Suspense fallback={<div className="workflow-workbench-loading">正在准备分镜工作台…</div>}><WorkflowProductionWorkbench activeStage={activeStage} detail={detail} projectId={projectId} unitId={unit.id} workflowStep={activeStep} selectedShot={selectedShot} onSelectShot={setSelectedShotId} onRefresh={refresh} onAddShot={() => addShot.mutate()} addingShot={addShot.isPending} /></Suspense> : null}
|
||||
{activeStage === "delivery" ? <div className="workflow-overview-scroll thin-scrollbar"><DeliveryStage detail={detail} unitId={unit.id} /></div> : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user