feat(*): 分镜创作体系 - 重构画风、提示词模板与视频生成链路
This commit is contained in:
@@ -6,7 +6,7 @@ import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, P
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import {
|
||||
bindProjectCharacterVoice,
|
||||
@@ -118,7 +118,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
const model = effectiveConfig.imageModel || effectiveConfig.model;
|
||||
const config = { ...effectiveConfig, model };
|
||||
if (!isAiConfigReady(config, model)) throw new Error("请先在设置中配置可用的图片模型");
|
||||
const projectStyle = canvasStylePresets.find((preset) => preset.id === detail.project.stylePresetId);
|
||||
const projectStyle = resolveCanvasStylePreset(detail.project.stylePresetId);
|
||||
await generateCharacterTurnaround({ projectId: detail.project.id, assetId: asset.id, versionId: asset.character.versionId, name: asset.title, definition: asset.character.definition, projectStyle, config });
|
||||
return getProjectCharacter(detail.project.id, asset.id);
|
||||
},
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { WorkspaceErrorState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
|
||||
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
|
||||
import { upsertProjectChapterStoryboard } from "@/lib/canvas/project-chapter-storyboard";
|
||||
@@ -249,7 +249,7 @@ export default function ProjectChaptersView({ detail, refreshProject, onCreateCa
|
||||
chapterId: unit.id,
|
||||
chapterTitle: unit.title,
|
||||
sourceText,
|
||||
projectStyle: canvasStylePresets.find((preset) => preset.id === detail.project.stylePresetId)?.prompt || "",
|
||||
projectStyle: resolveCanvasStylePreset(detail.project.stylePresetId)?.prompt || "",
|
||||
config: effectiveConfig,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,29 +13,24 @@ type ChapterAnalysisInput = {
|
||||
};
|
||||
|
||||
export async function extractChapterCharacters(input: ChapterAnalysisInput): Promise<CharacterBreakdown[]> {
|
||||
const prompt = [
|
||||
`请从短剧项目《${input.projectName}》的章节“${input.chapterTitle}”中提取需要建立可复用视觉资产的角色。`,
|
||||
"提取本章实际出场、发言或对剧情产生明确作用,并且后续制作中需要保持视觉或声音一致的角色;忽略系统播报、纯物件、无身份群众、不影响剧情的一次性路人,以及只在单个镜头出现且没有持续角色价值的匿名录像或历史影像人物。合并同一角色的姓名、专属称谓和别名。",
|
||||
"每个角色必须填写剧情定位,以及至少三项可执行的稳定设定;正文未明确的信息要明确写“正文未明确”,不得留空,也不能改变人物关系和时代背景。角色名称必须来自正文中的姓名、昵称或稳定称谓,不得自行编造编号式名称。",
|
||||
`【项目画风】\n${input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。"}`,
|
||||
"只返回 JSON,不要 Markdown 或解释。JSON 结构必须严格为:",
|
||||
'{"characters":[{"name":"角色名","aliases":["唯一别名"],"role":"剧情定位与人物关系","appearance":"年龄、脸型、五官、肤色、发型等稳定外貌","clothing":"固定服装版型、颜色、纹样和材质","physique":"身高、头身、体型和体态","personality":"稳定气质与表演基线","props":"固定道具及佩戴位置,没有则为空字符串","consistencyPrompt":"跨图片和镜头必须保持不变的角色约束","multiViewPrompt":"正面、侧面、背面转面展示需要强调的结构细节","voiceLanguage":"语言、口音和表达习惯","voiceAge":"适合选角的声音年龄感","voiceTimbre":"音色、语速、力度和声音气质"}]}',
|
||||
"【章节正文】",
|
||||
input.sourceText,
|
||||
].join("\n\n");
|
||||
const result = await runProjectTextTask(input, "chapter_character_breakdown", prompt);
|
||||
const result = await runProjectTextTask(input, "chapter_character_breakdown", {
|
||||
项目名称: input.projectName,
|
||||
章节名称: input.chapterTitle,
|
||||
项目画风: input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。",
|
||||
章节正文: input.sourceText,
|
||||
});
|
||||
return parseCharacterBreakdown(result);
|
||||
}
|
||||
|
||||
async function runProjectTextTask(input: ChapterAnalysisInput, operation: string, prompt: string) {
|
||||
async function runProjectTextTask(input: ChapterAnalysisInput, operation: string, promptTemplateVariables: Record<string, string>) {
|
||||
const model = input.config.textModel || input.config.model;
|
||||
const result = await runBackendCanvasGenerationTask({
|
||||
projectId: input.projectId,
|
||||
nodeId: `${operation}:${input.chapterId}`,
|
||||
mode: "text",
|
||||
prompt,
|
||||
prompt: "使用当前启用的角色卡提取模板。",
|
||||
config: { ...input.config, model },
|
||||
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation },
|
||||
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "character_extract", promptTemplateVariables },
|
||||
});
|
||||
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");
|
||||
return result.text;
|
||||
|
||||
@@ -4,38 +4,30 @@ import type { AiConfig } from "@/stores/use-config-store";
|
||||
type ProjectStylePrompt = { id: string; title: string; prompt: string };
|
||||
|
||||
export async function generateCharacterTurnaround(input: { projectId: string; assetId: string; versionId: string; name: string; definition: Record<string, unknown>; projectStyle?: ProjectStylePrompt; config: AiConfig }) {
|
||||
const prompt = characterTurnaroundPrompt(input.name, input.definition, input.projectStyle);
|
||||
const promptTemplateVariables = characterTurnaroundVariables(input.name, input.definition, input.projectStyle);
|
||||
await runBackendCanvasGenerationTask({
|
||||
projectId: input.projectId,
|
||||
nodeId: `character-turnaround:${input.assetId}`,
|
||||
mode: "image",
|
||||
prompt,
|
||||
prompt: "使用当前启用的角色三视图模板。",
|
||||
config: { ...input.config, model: input.config.imageModel || input.config.model, count: "1" },
|
||||
metadata: { operation: "character_turnaround", characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
metadata: { operation: "character_turnaround", promptTemplateOperation: "character_turnaround", promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] },
|
||||
});
|
||||
}
|
||||
|
||||
export function characterTurnaroundPrompt(name: string, definition: Record<string, unknown>, projectStyle?: ProjectStylePrompt) {
|
||||
function characterTurnaroundVariables(name: string, definition: Record<string, unknown>, projectStyle?: ProjectStylePrompt) {
|
||||
const visual = [definition.role, definition.appearance, definition.physique, definition.clothing, definition.personality, definition.props, definition.consistencyPrompt, definition.multiViewPrompt]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
.join(";");
|
||||
if (!visual) throw new Error("请先填写剧情定位、角色外貌、体型或服装,再初始化三视图");
|
||||
return [
|
||||
`为角色“${name}”制作专业人物三视图设定表。`,
|
||||
"画面严格分成三个等宽竖向区域,从左到右依次为正面全身、右侧面全身、背面全身。",
|
||||
"三个视角必须是同一个人、同一服装、同一发型、同一体型和同一比例,站立中性姿势,完整显示头顶到脚底。",
|
||||
"使用纯净中性浅色背景和均匀设定稿光线;背景只负责分离轮廓,不得改变项目画风的绘画或渲染媒介。",
|
||||
"不添加文字、边框、道具说明、表情变化或额外人物。",
|
||||
characterStyleConstraint(projectStyle),
|
||||
`角色设定:${visual}`,
|
||||
].filter(Boolean).join("\n");
|
||||
return { 角色名称: name, 项目画风: characterStyleConstraint(projectStyle) || "项目尚未指定画风,保持角色媒介中性。", 角色设定: visual };
|
||||
}
|
||||
|
||||
function characterStyleConstraint(projectStyle?: ProjectStylePrompt) {
|
||||
if (!projectStyle) return "";
|
||||
// 三视图只继承角色资产相关规范,避免把建筑、运镜等项目规则错误塞进静态设定表。
|
||||
const characterSections = new Set(["项目定位", "项目色彩系统", "角色设计系统", "服饰与材质系统", "资产一致性", "全局禁用"]);
|
||||
const characterSections = new Set(["风格组合", "项目定位", "题材世界观", "视觉媒介", "角色设计系统", "项目色彩与光影", "服饰、材质与场景", "资产一致性", "全局禁用"]);
|
||||
const rules = projectStyle.prompt.split("\n").filter((line) => {
|
||||
const title = line.match(/^【([^】]+)】/)?.[1];
|
||||
return title ? characterSections.has(title) : false;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { App, Button, Input, Modal, Select } from "antd";
|
||||
import { Archive, Check, Eye, Save, ShieldAlert } from "lucide-react";
|
||||
import { Archive, Check, Eye, Palette, Save, ShieldAlert } from "lucide-react";
|
||||
|
||||
import { CanvasStyleDetailModal, canvasStylePresets, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { CanvasStyleDetailModal, CanvasStylePickerModal, resolveCanvasStylePreset, type CanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { updateProject } from "@/services/api/projects";
|
||||
|
||||
import type { ProjectDetailViewProps } from "./shared";
|
||||
@@ -17,9 +17,11 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
const [sourceType, setSourceType] = useState(project.sourceType);
|
||||
const [stylePresetId, setStylePresetId] = useState(project.stylePresetId || "");
|
||||
const [styleDetail, setStyleDetail] = useState<CanvasStylePreset | null>(null);
|
||||
const [stylePickerOpen, setStylePickerOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
useEffect(() => { setName(project.name); setDescription(project.description || ""); setAspectRatio(project.aspectRatio); setSourceType(project.sourceType); setStylePresetId(project.stylePresetId || ""); }, [project]);
|
||||
const dirty = useMemo(() => name.trim() !== project.name || description !== (project.description || "") || aspectRatio !== project.aspectRatio || sourceType !== project.sourceType || stylePresetId !== (project.stylePresetId || ""), [aspectRatio, description, name, project, sourceType, stylePresetId]);
|
||||
const selectedStyle = useMemo(() => resolveCanvasStylePreset(stylePresetId), [stylePresetId]);
|
||||
const saveMutation = useMutation({ mutationFn: () => updateProject(project.id, { name: name.trim(), description, aspectRatio, sourceType, stylePresetId }), onSuccess: () => { refreshProject(); message.success("项目设置已保存"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目设置保存失败") });
|
||||
const archiveMutation = useMutation({ mutationFn: () => updateProject(project.id, { status: project.status === "archived" ? "active" : "archived" }), onSuccess: () => { setArchiveOpen(false); refreshProject(); message.success(project.status === "archived" ? "项目已恢复" : "项目已归档"); }, onError: (error) => message.error(error instanceof Error ? error.message : "项目状态更新失败") });
|
||||
|
||||
@@ -39,11 +41,14 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
|
||||
<section className="border-b border-border/70 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目画风</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">选择后会同步到项目画布中的画风节点</p></div>{stylePresetId ? <span className="text-[var(--fs-label)] text-[var(--workspace-accent)]">已选择</span> : <span className="text-[var(--fs-label)] text-foreground/40">未设置</span>}</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{canvasStylePresets.map((preset) => {
|
||||
const active = preset.id === stylePresetId;
|
||||
return <div key={preset.id} className={`group flex min-w-0 items-center overflow-hidden rounded-lg border transition-colors ${active ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/25"}`}><button type="button" onClick={() => setStylePresetId(preset.id)} className="grid min-w-0 flex-1 grid-cols-[72px_minmax(0,1fr)_24px] items-center gap-2 p-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"><img src={preset.imageUrl} width="72" height="44" alt={`${preset.title}画风示意`} loading="lazy" className="h-11 w-[72px] rounded object-cover" /><span className="min-w-0"><span className="block truncate text-xs font-medium">{preset.title}</span><span className="mt-0.5 block truncate text-[var(--fs-tiny)] text-foreground/42">{preset.description}</span></span><span className={`grid size-5 place-items-center rounded-full ${active ? "bg-[var(--workspace-accent)] text-white" : "border border-border text-transparent"}`}><Check className="size-3" /></span></button><button type="button" className="mr-1 grid size-8 shrink-0 place-items-center rounded-md text-foreground/42 hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={() => setStyleDetail(preset)} aria-label={`查看${preset.title}详情`} title="查看画风详情"><Eye className="size-3.5" /></button></div>;
|
||||
})}
|
||||
<div className="flex flex-col gap-3 border-y border-border/70 py-3 sm:flex-row sm:items-center">
|
||||
{selectedStyle ? <img src={selectedStyle.imageUrl} width="160" height="90" alt={`${selectedStyle.title}画风示意`} className="aspect-video w-40 shrink-0 rounded-md object-cover" /> : <span className="grid aspect-video w-40 shrink-0 place-items-center rounded-md bg-foreground/[.04] text-foreground/35"><Palette className="size-5" /></span>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">{selectedStyle?.title || "尚未设置项目画风"}</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-foreground/48">{selectedStyle?.description || "选择题材世界、叙事气质、视觉媒介和角色造型。"}</p>
|
||||
{selectedStyle ? <div className="mt-2 flex flex-wrap gap-1">{selectedStyle.tags.map((tag) => <span key={tag} className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/55">{tag}</span>)}</div> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2"><Button icon={<Eye className="size-3.5" />} disabled={!selectedStyle} onClick={() => setStyleDetail(selectedStyle || null)}>查看规范</Button><Button icon={<Palette className="size-3.5" />} onClick={() => setStylePickerOpen(true)}>{selectedStyle ? "更换画风" : "选择画风"}</Button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -55,6 +60,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
</section>
|
||||
|
||||
<Modal title={project.status === "archived" ? "恢复项目" : "归档项目"} open={archiveOpen} okText={project.status === "archived" ? "确认恢复" : "确认归档"} cancelText="取消" okButtonProps={{ danger: project.status !== "archived", loading: archiveMutation.isPending }} onCancel={() => setArchiveOpen(false)} onOk={() => archiveMutation.mutate()} width={440} styles={{ body: { paddingTop: 12 } }}><p className="m-0 text-sm leading-6 text-foreground/65">{project.status === "archived" ? "恢复后项目会重新进入可编辑状态。" : "归档不会删除章节、画布或资产,画布文档仍可在创作画布中打开。"}</p></Modal>
|
||||
<CanvasStylePickerModal open={stylePickerOpen} value={stylePresetId} onClose={() => setStylePickerOpen(false)} onSelect={(preset) => { setStylePresetId(preset.id); setStylePickerOpen(false); }} />
|
||||
<CanvasStyleDetailModal open={Boolean(styleDetail)} preset={styleDetail} selected={styleDetail?.id === stylePresetId} onClose={() => setStyleDetail(null)} onSelect={(preset) => { setStylePresetId(preset.id); setStyleDetail(null); }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
|
||||
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
|
||||
import { createProject, listProjects, type ProjectSummary } from "@/services/api/projects";
|
||||
|
||||
@@ -42,7 +43,7 @@ export default function ProjectsPage() {
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
return [...(query.data?.projects || [])]
|
||||
.filter(({ project }) => status === "all" || project.status === status)
|
||||
.filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId}`.toLowerCase().includes(normalizedKeyword))
|
||||
.filter(({ project }) => !normalizedKeyword || `${project.name} ${project.description} ${project.stylePresetId} ${resolveCanvasStylePreset(project.stylePresetId)?.title || ""}`.toLowerCase().includes(normalizedKeyword))
|
||||
.sort((left, right) => {
|
||||
if (sort === "name") return left.project.name.localeCompare(right.project.name, "zh-CN");
|
||||
if (sort === "progress") return projectSummaryCompletion(right) - projectSummaryCompletion(left);
|
||||
@@ -104,11 +105,12 @@ export default function ProjectsPage() {
|
||||
function ProjectRow({ row }: { row: ProjectSummary }) {
|
||||
const completion = projectSummaryCompletion(row);
|
||||
const stage = projectSummaryStage(row);
|
||||
const styleTitle = resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
|
||||
return (
|
||||
<Link to={`/projects/${row.project.id}/overview`} className="group block min-w-0 px-3 py-3 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:px-4 lg:grid lg:min-h-[76px] lg:grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] lg:items-center lg:gap-4 lg:py-2.5">
|
||||
<span className="flex min-w-0 items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.06] text-foreground/50"><FolderKanban className="size-4" /></span>
|
||||
<span className="min-w-0"><span className="flex min-w-0 items-center gap-2"><strong className="truncate text-sm font-semibold">{row.project.name}</strong>{row.project.status === "archived" ? <span className="shrink-0 rounded bg-foreground/[.07] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/45">已归档</span> : null}</span><span className="mt-1 block truncate text-[var(--fs-label)] text-foreground/42">{row.project.stylePresetId || "未设置画风"} · {row.project.aspectRatio} · {sourceTypeLabel(row.project.sourceType)}</span></span>
|
||||
<span className="min-w-0"><span className="flex min-w-0 items-center gap-2"><strong className="truncate text-sm font-semibold">{row.project.name}</strong>{row.project.status === "archived" ? <span className="shrink-0 rounded bg-foreground/[.07] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/45">已归档</span> : null}</span><span className="mt-1 block truncate text-[var(--fs-label)] text-foreground/42">{styleTitle} · {row.project.aspectRatio} · {sourceTypeLabel(row.project.sourceType)}</span></span>
|
||||
</span>
|
||||
|
||||
<span className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] items-start gap-2 lg:mt-0 lg:block">
|
||||
|
||||
Reference in New Issue
Block a user