feat(*): 分镜创作体系 - 重构画风、提示词模板与视频生成链路

This commit is contained in:
ddCat
2026-08-04 15:03:41 +08:00
parent 98382ba976
commit d4018123e1
46 changed files with 2667 additions and 572 deletions

View File

@@ -24,6 +24,7 @@ import { CanvasNodeType, type CanvasAssistantMessage, type CanvasAssistantPendin
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
import { previewCanvasAgentOps, summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentOperationImpact, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { canvasAgentPromptCacheKey } from "@/lib/openai-prompt-cache";
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
export const CANVAS_AGENT_PANEL_MOTION_MS = 500;
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
@@ -316,6 +317,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
const runCinematicSession = async (sessionId: string, text: string, current: CanvasAgentSnapshot, config: AiConfig, onCreated?: (backendSessionId: string) => void) => {
const requestConfig = resolveModelRequestConfig(config, config.textModel || config.model);
const storyboardContext = resolveStoryboardGenerationContext(current.nodes);
const controller = new AbortController();
const requestKey = `creating:${nanoid()}`;
let backendSessionId = "";
@@ -326,6 +328,8 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, snapshot, project
projectId,
prompt: text,
canvasSnapshot: compactSnapshot(current) as unknown as Record<string, unknown>,
projectStyle: storyboardContext.projectStyle,
characters: storyboardContext.characters,
config: backendAgentProviderConfig(requestConfig),
},
{
@@ -1549,6 +1553,9 @@ function compactMetadata(metadata: CanvasNodeData["metadata"]) {
workflowKind: metadata?.workflowKind,
workflowTitle: metadata?.workflowTitle,
workflowDescription: metadata?.workflowDescription,
characterName: metadata?.characterName,
characterAssetId: metadata?.characterAssetId,
characterVersionId: metadata?.characterVersionId,
chapterId: metadata?.chapterId,
chapterTitle: metadata?.chapterTitle,
shotIndex: metadata?.shotIndex,

View File

@@ -20,7 +20,7 @@ import {
} from "lucide-react";
import { Link } from "react-router";
import { canvasStylePresets } from "@/components/canvas/canvas-style-picker-modal";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { getProject, getProjectUnit, type ProjectDetail, type ProjectUnit } from "@/services/api/projects";
export const CANVAS_PROJECT_CHAPTER_DND_TYPE = "application/x-infinite-canvas-project-chapter";
@@ -46,7 +46,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
const deferredQuery = useDeferredValue(query.trim().toLocaleLowerCase("zh-CN"));
const projectQuery = useQuery({ queryKey: ["project", projectId], queryFn: () => getProject(projectId), enabled: Boolean(projectId) && !detail });
const projectDetail = detail || projectQuery.data;
const style = canvasStylePresets.find((preset) => preset.id === projectDetail?.project.stylePresetId);
const style = resolveCanvasStylePreset(projectDetail?.project.stylePresetId);
const mediaAssetCount = projectDetail?.assets.filter((asset) => asset.mediaType === "image" || asset.mediaType === "video").length || 0;
const orderedUnits = useMemo(() => projectDetail?.units.slice().sort((left, right) => left.position - right.position) || [], [projectDetail?.units]);
const visibleUnits = useMemo(() => {

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { Button, Checkbox, Input, InputNumber, Modal, Popover, Select, Table, Tooltip } from "antd";
import { Button, Checkbox, Input, InputNumber, Modal, Popover, Segmented, Select, Table, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import { ChevronDown, ChevronUp, Clapperboard, Copy, Expand, Film, Grid3X3, Image as ImageIcon, ListTree, Merge, Minus, Plus, RefreshCw, Send, Square, Trash2, Video, X } from "lucide-react";
@@ -13,7 +13,7 @@ import { navigateToSettings } from "@/lib/settings-navigation";
import { canvasThemes } from "@/lib/canvas-theme";
import { useEffectiveConfig } from "@/stores/use-config-store";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchItemStatus, CanvasNodeData, CanvasNodeStatus, CanvasWorkspaceMode, StoryboardColumn, StoryboardRow, StoryboardShotCount, StoryboardShotDuration } from "@/types/canvas";
import type { CanvasGenerationBatch, CanvasGenerationBatchItem, CanvasGenerationBatchItemStatus, CanvasNodeData, CanvasNodeStatus, CanvasWorkspaceMode, StoryboardColumn, StoryboardRow, StoryboardShotCount, StoryboardShotDuration, StoryboardVideoInputMode } from "@/types/canvas";
export const STORYBOARD_ROW_HEIGHT = 48;
export const STORYBOARD_HEADER_HEIGHT = 124;
@@ -43,6 +43,9 @@ const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
{ label: "时长", value: "durationSeconds" },
{ label: "画面描述", value: "plotDescription" },
{ label: "台词/旁白", value: "dialogue" },
{ label: "镜头意图", value: "narrativeIntent" },
{ label: "观众视点", value: "viewerPOV" },
{ label: "表演调度", value: "performanceBlocking" },
{ label: "景别", value: "shotSize" },
{ label: "情绪", value: "emotion" },
{ label: "光影氛围", value: "lightingAndAtmosphere" },
@@ -52,10 +55,11 @@ const columnOptions: Array<{ label: string; value: StoryboardColumn }> = [
{ label: "时间节拍", value: "timeBeats" },
{ label: "图片提示词", value: "imageGenerationPrompt" },
{ label: "视频提示词", value: "videoMotionPrompt" },
{ label: "连续性出口", value: "continuityOut" },
{ label: "负面要求", value: "negativePrompt" },
];
export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionReferences, onOpen, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onMergeVideos, onCreateActionBoards, onRetryBatch, onRetryBatchItem, onStopBatch, onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, onPromptChange, onGenerateScript, onModelChange, onShotDurationChange, onShotCountChange, onComposerHeightChange, onConnectStart, onScrollTopChange, workspaceMode = "professional" }: {
export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionReferences, onOpen, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onVideoInputModeChange, onMergeVideos, onCreateActionBoards, onRetryBatch, onRetryBatchItem, onStopBatch, onCancelBatchItem, onAddRow, onRemoveRow, onUpdateRow, onPromptChange, onGenerateScript, onModelChange, onShotDurationChange, onShotCountChange, onComposerHeightChange, onConnectStart, onScrollTopChange, workspaceMode = "professional" }: {
node: CanvasNodeData;
batch?: CanvasGenerationBatch;
pipeline: CanvasStoryboardPipelineProgress;
@@ -66,6 +70,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
onCreateVideoNodes: () => void;
onGenerateImages: () => void;
onGenerateVideos: () => void;
onVideoInputModeChange: (mode: StoryboardVideoInputMode) => void;
onMergeVideos: () => void;
onCreateActionBoards: () => void;
onRetryBatch: (batchId: string) => void;
@@ -99,6 +104,7 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
const totalDuration = rows.reduce((sum, row) => sum + (Number(row.durationSeconds) || 0), 0);
const shotDuration = node.metadata?.storyboardShotDuration || "auto";
const shotCount = node.metadata?.storyboardShotCount || "auto";
const videoInputMode = node.metadata?.storyboardVideoInputMode || "direct";
const batchItemByRowId = useMemo(() => new Map((batch?.items || []).map((item) => [item.rowId, item])), [batch?.items]);
const batchSummary = batch ? generationBatchSummary(batch) : null;
const hasFailedBatchItems = Boolean(batch?.items.some((item) => item.status === "failed"));
@@ -146,6 +152,8 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
onCreateVideoNodes={onCreateVideoNodes}
onGenerateImages={onGenerateImages}
onGenerateVideos={onGenerateVideos}
videoInputMode={videoInputMode}
onVideoInputModeChange={onVideoInputModeChange}
onMergeVideos={onMergeVideos}
/>
<div className="grid h-9 shrink-0 items-center border-b text-xs font-semibold" style={{ borderColor: theme.node.stroke, color: theme.node.muted, gridTemplateColumns: SCRIPT_GRID_TEMPLATE }}>
@@ -275,15 +283,17 @@ export function CanvasScriptNodeContent({ node, batch, pipeline, scale, mentionR
);
}
function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onMergeVideos }: {
function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, videoInputMode, onCreateImageNodes, onCreateVideoNodes, onGenerateImages, onGenerateVideos, onVideoInputModeChange, onMergeVideos }: {
pipeline: CanvasStoryboardPipelineProgress;
simpleMode: boolean;
disabled: boolean;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
videoInputMode: StoryboardVideoInputMode;
onCreateImageNodes: () => void;
onCreateVideoNodes: () => void;
onGenerateImages: () => void;
onGenerateVideos: () => void;
onVideoInputModeChange: (mode: StoryboardVideoInputMode) => void;
onMergeVideos: () => void;
}) {
const missingImages = Math.max(0, pipeline.images.total - pipeline.images.created);
@@ -291,10 +301,10 @@ function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, onCreate
const canMerge = pipeline.successfulVideoNodeIds.length >= 2 && pipeline.final.success === 0;
return (
<div className="grid h-12 shrink-0 grid-cols-3 border-b" style={{ borderColor: theme.node.stroke, background: theme.node.fill }} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
<PipelineStageCell label="分镜图" stage={pipeline.images} theme={theme}>
<PipelineStageCell label={videoInputMode === "keyframe" ? "首帧" : "分镜图(可选)"} stage={pipeline.images} theme={theme}>
{simpleMode ? (
<Button size="small" type="text" icon={<ImageIcon className="size-3" />} disabled={disabled || pipeline.images.incomplete === 0} onClick={onGenerateImages}>
{pipeline.images.incomplete ? `生成 ${pipeline.images.incomplete} 张分镜图` : "分镜图已完成"}
{pipeline.images.incomplete ? `生成 ${pipeline.images.incomplete} 张${videoInputMode === "keyframe" ? "首帧" : "分镜图"}` : `${videoInputMode === "keyframe" ? "首帧" : "分镜图"}已完成`}
</Button>
) : (
<>
@@ -304,14 +314,20 @@ function StoryboardPipelineBar({ pipeline, simpleMode, disabled, theme, onCreate
)}
</PipelineStageCell>
<PipelineStageCell label="镜头视频" stage={pipeline.videos} theme={theme}>
<Segmented<StoryboardVideoInputMode>
size="small"
value={videoInputMode}
options={[{ value: "direct", label: "直接生成" }, { value: "keyframe", label: "先做首帧" }]}
onChange={onVideoInputModeChange}
/>
{simpleMode ? (
<Button size="small" type="text" icon={<Video className="size-3" />} disabled={disabled || pipeline.videos.incomplete === 0} onClick={onGenerateVideos}>
{pipeline.videos.incomplete ? `生成 ${pipeline.videos.incomplete} 个镜头视频` : "镜头视频已完成"}
{pipeline.videos.incomplete ? videoInputMode === "keyframe" ? "确认首帧并生成" : `生成 ${pipeline.videos.incomplete} 个镜头视频` : "镜头视频已完成"}
</Button>
) : (
<>
<Button size="small" type="text" disabled={disabled || missingVideos === 0} onClick={onCreateVideoNodes}>{missingVideos ? `创建 ${missingVideos} 个视频节点` : "视频节点已创建"}</Button>
<Button size="small" type="text" disabled={disabled || pipeline.videos.incomplete === 0} onClick={onGenerateVideos}>生成未完成的视频</Button>
<Button size="small" type="text" disabled={disabled || pipeline.videos.incomplete === 0} onClick={onGenerateVideos}>{videoInputMode === "keyframe" ? "确认首帧并生成" : "生成未完成的视频"}</Button>
</>
)}
</PipelineStageCell>
@@ -381,7 +397,7 @@ function batchItemTone(item?: CanvasGenerationBatchItem): CanvasNodeStatus | und
return "loading";
}
export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibleColumnsChange, onGenerateImages, onGenerateVideos }: {
export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibleColumnsChange, onGenerateImages, onGenerateVideos, onVideoInputModeChange }: {
node: CanvasNodeData | null;
open: boolean;
onClose: () => void;
@@ -389,11 +405,13 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
onVisibleColumnsChange: (columns: StoryboardColumn[]) => void;
onGenerateImages: (rowIds: string[]) => void;
onGenerateVideos: (rowIds: string[]) => void;
onVideoInputModeChange: (mode: StoryboardVideoInputMode) => void;
}) {
const [query, setQuery] = useState("");
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const rows = node?.metadata?.storyboard?.rows || EMPTY_STORYBOARD_ROWS;
const visibleColumns = node?.metadata?.storyboard?.visibleColumns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"];
const videoInputMode = node?.metadata?.storyboardVideoInputMode || "direct";
const filteredRows = useMemo(() => {
const keyword = query.trim().toLowerCase();
return keyword ? rows.filter((row) => [row.plotDescription, row.dialogue, row.camera, row.motion, row.timeBeats, row.imageGenerationPrompt, row.videoMotionPrompt, row.negativePrompt].some((value) => String(value || "").toLowerCase().includes(keyword))) : rows;
@@ -441,8 +459,9 @@ export function CanvasScriptEditor({ node, open, onClose, onUpdateRows, onVisibl
<Checkbox.Group className="script-column-picker" options={columnOptions} value={visibleColumns} onChange={(values) => onVisibleColumnsChange(values as StoryboardColumn[])} />
<span className="min-w-0 flex-1" />
<Button icon={<Plus className="size-4" />} onClick={() => onUpdateRows([...rows, editorRow(rows.length + 1)])}>新增镜头</Button>
<Button icon={<ImageIcon className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateImages(selectedIds)}>生成分镜图</Button>
<Button type="primary" icon={<Film className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateVideos(selectedIds)}>生成视频</Button>
<Button icon={<ImageIcon className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateImages(selectedIds)}>生成{videoInputMode === "keyframe" ? "首帧" : "分镜图"}</Button>
<Segmented<StoryboardVideoInputMode> value={videoInputMode} options={[{ value: "direct", label: "直接生成" }, { value: "keyframe", label: "先做首帧" }]} onChange={onVideoInputModeChange} />
<Button type="primary" icon={<Film className="size-4" />} disabled={!selectedIds.length} onClick={() => onGenerateVideos(selectedIds)}>{videoInputMode === "keyframe" ? "确认首帧并生成" : "生成视频"}</Button>
</div>
<Table<StoryboardRow> rowKey="id" size="small" bordered sticky pagination={false} scroll={{ x: Math.max(900, columns.length * 180), y: "calc(78vh - 170px)" }} dataSource={filteredRows} columns={columns} rowSelection={{ selectedRowKeys: selectedIds, onChange: (keys) => setSelectedIds(keys.map(String)) }} />
</Modal>
@@ -462,7 +481,7 @@ function SmallButton({ title, children, onClick }: { title: string; children: Re
}
function editorRow(shotNumber: number): StoryboardRow {
return { id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, shotNumber, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", negativePrompt: "", referenceNodeIds: [], status: "idle" };
return { id: `shot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, shotNumber, durationSeconds: 6, plotDescription: "", dialogue: "", characters: [], narrativeIntent: "", viewerPOV: "", performanceBlocking: "", shotSize: "", emotion: "", lightingAndAtmosphere: "", audioEffects: "", camera: "", motion: "", timeBeats: "", imageGenerationPrompt: "", videoMotionPrompt: "", mustHave: [], optionalDetails: [], continuityOut: "", negativePrompt: "", referenceNodeIds: [], status: "idle" };
}
function RowHandle({ side, top, scale, tone, theme, title, onPointerDown }: { side: "left" | "right"; top: number; scale: number; tone?: StoryboardRow["status"]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; title?: string; onPointerDown: (event: ReactPointerEvent) => void }) {

View File

@@ -1,24 +1,30 @@
import { useState, type CSSProperties } from "react";
import { Check, Clapperboard, Eye, Palette, Sparkles } from "lucide-react";
import { Button, Modal } from "antd";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { Check, Eye, Palette } from "lucide-react";
import { Button, Modal, Segmented, Select } from "antd";
import { canvasThemes } from "@/lib/canvas-theme";
import {
compatibleProjectStyleCharacters,
compileCanvasStylePreset,
customCanvasStylePreset,
defaultProjectStyleSelection,
parseCanvasStyleSelection,
projectStyleMedia,
projectStyleTones,
projectStyleWorlds,
recommendedCanvasStylePresets,
type CanvasStylePreset,
type ProjectStyleMediumId,
type ProjectStyleSelection,
} from "@/lib/canvas/canvas-style-system";
import { useThemeStore } from "@/stores/use-theme-store";
export type CanvasStylePreset = {
id: string;
title: string;
category: string;
description: string;
tags: string[];
prompt: string;
imageUrl: string;
};
export type { CanvasStylePreset } from "@/lib/canvas/canvas-style-system";
// 分类按短剧制作语境组织:先看媒介,再看题材与视觉气质,避免用品牌名称代替画风。
const PROJECT_STYLE_SCOPE = "【使用边界】本规范是全项目美术与影像风格基线,用于统一角色资产、服装材质、建筑世界观、色彩语言和成片质感;它不是某张图片或某个镜头的提示词。具体场景内容、构图、景别、机位、运镜、动作、光源位置、天气和单场情绪由剧情与分镜节点决定,不得从本规范机械复制。";
export const canvasStylePresets: CanvasStylePreset[] = [
const legacyCanvasStylePresets: CanvasStylePreset[] = [
{
id: "urban-live-action",
title: "都市真人短剧",
@@ -363,43 +369,80 @@ export const canvasStylePresets: CanvasStylePreset[] = [
},
];
const legacyStyleUpgrades: Partial<Record<string, ProjectStyleSelection>> = {
"urban-live-action": { world: "urban", tone: "romantic", medium: "live-action", character: "realistic" },
"period-live-action": { world: "historical", tone: "epic", medium: "live-action", character: "realistic" },
"suspense-noir": { world: "suspense", tone: "dark", medium: "live-action", character: "realistic" },
"chinese-2d": { world: "xianxia", tone: "epic", medium: "2d-guoman", character: "semi-real" },
"ink-narrative": { world: "xianxia", tone: "healing", medium: "ink", character: "semi-real" },
"three-d-cartoon": { world: "urban", tone: "light-comedy", medium: "3d-cartoon", character: "stylized" },
"fantasy-3d": { world: "xianxia", tone: "epic", medium: "3d-anime", character: "semi-real" },
"future-tech": { world: "science-fiction", tone: "epic", medium: "live-action", character: "realistic" },
"cyberpunk-neon": { world: "science-fiction", tone: "dark", medium: "live-action", character: "realistic" },
"space-opera": { world: "science-fiction", tone: "epic", medium: "3d-anime", character: "semi-real" },
"nature-healing": { world: "pastoral", tone: "healing", medium: "live-action", character: "realistic" },
"real-life-documentary": { world: "urban", tone: "healing", medium: "live-action", character: "realistic" },
};
const resolvedLegacyCanvasStylePresets = legacyCanvasStylePresets.map((preset) => {
const upgrade = legacyStyleUpgrades[preset.id];
return upgrade ? { ...compileCanvasStylePreset(upgrade), id: preset.id, imageUrl: preset.imageUrl } : preset;
});
export const canvasStylePresets: CanvasStylePreset[] = [...recommendedCanvasStylePresets, ...resolvedLegacyCanvasStylePresets];
export function resolveCanvasStylePreset(id?: string) {
return canvasStylePresets.find((preset) => preset.id === id) || customCanvasStylePreset(id);
}
export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { open: boolean; value?: string; onClose: () => void; onSelect: (preset: CanvasStylePreset) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [detailPreset, setDetailPreset] = useState<CanvasStylePreset | null>(null);
const [mode, setMode] = useState<"recommended" | "custom">("recommended");
const [selection, setSelection] = useState<ProjectStyleSelection>(defaultProjectStyleSelection);
useEffect(() => {
if (!open) return;
const parsed = parseCanvasStyleSelection(value);
if (parsed) {
setSelection(parsed);
setMode(recommendedCanvasStylePresets.some((preset) => preset.id === value) ? "recommended" : "custom");
} else {
setSelection(defaultProjectStyleSelection);
setMode("recommended");
}
}, [open, value]);
const activePreset = resolveCanvasStylePreset(value);
const visiblePresets = activePreset && !recommendedCanvasStylePresets.some((preset) => preset.id === activePreset.id)
? [activePreset, ...recommendedCanvasStylePresets.filter((preset) => preset.prompt !== activePreset.prompt)]
: recommendedCanvasStylePresets;
return (
<>
<Modal open={open} title={null} footer={null} centered width="min(1040px, calc(100vw - 24px))" onCancel={onClose} styles={{ body: { padding: 0 } }}>
<div className="overflow-hidden rounded-lg" style={{ color: theme.node.text, background: theme.node.panel }}>
<header className="flex items-center gap-3 border-b px-4 py-4 sm:px-5" style={{ borderColor: theme.node.stroke }}>
<span className="grid size-9 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}><Palette className="size-4" /></span>
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold"><span>选择项目画风</span><span className="rounded-full px-2 py-0.5 text-[var(--fs-tiny)] font-medium" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{canvasStylePresets.length} 套视觉预设</span></div>
<div className="mt-0.5 truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>画风会统一角色三视图、项目资产、分镜和视频提示词。</div>
</div>
<span className="ml-auto hidden items-center gap-1 text-[var(--fs-label)] sm:flex" style={{ color: theme.node.faint }}><Sparkles className="size-3.5" />先看规范,再做选择</span>
<Modal rootClassName="canvas-style-picker-modal" open={open} title={null} footer={null} centered width="min(920px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
<div className="overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<header className="flex flex-col gap-3 border-b px-4 py-3 pr-12 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: theme.node.stroke }}>
<h2 className="text-sm font-semibold">选择项目画风</h2>
<Segmented size="small" value={mode} options={[{ value: "recommended", label: "推荐画风" }, { value: "custom", label: "自定义组合" }]} onChange={(next) => setMode(next as typeof mode)} />
</header>
<div className="thin-scrollbar grid max-h-[76vh] grid-cols-1 gap-3 overflow-y-auto p-3 sm:grid-cols-2 sm:p-4 lg:grid-cols-3">
{canvasStylePresets.map((preset) => {
{mode === "recommended" ? <div className="thin-scrollbar grid max-h-[76vh] grid-cols-1 gap-2 overflow-y-auto p-2 md:grid-cols-2 md:p-3">
{visiblePresets.map((preset) => {
const active = preset.id === value;
return (
<article key={preset.id} className="group relative">
<button type="button" className="h-full w-full overflow-hidden rounded-lg border text-left transition hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2" style={{ background: theme.node.panel, borderColor: active ? theme.node.activeStroke : theme.node.stroke, boxShadow: active ? `0 0 0 1px ${theme.node.activeStroke}` : undefined, "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={() => onSelect(preset)}>
<span className="relative block aspect-[16/9] overflow-hidden" style={{ background: theme.canvas.background }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} loading="lazy" />
<span className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 px-3 py-2 text-white" style={{ background: "linear-gradient(180deg, transparent, rgba(0,0,0,.72))" }}><span className="text-[var(--fs-tiny)] font-medium tracking-wide">{preset.category}</span>{active ? <span className="grid size-5 place-items-center rounded-full bg-white text-black"><Check className="size-3.5" /></span> : null}</span>
<button type="button" className="flex min-h-24 w-full overflow-hidden rounded-[var(--r-md)] border text-left transition-colors focus-visible:outline-none focus-visible:ring-2" style={{ background: theme.canvas.background, borderColor: active ? theme.node.activeStroke : theme.node.stroke, boxShadow: active ? `inset 0 0 0 1px ${theme.node.activeStroke}` : undefined, "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={() => onSelect(preset)}>
<span className="relative w-28 shrink-0 overflow-hidden border-r" style={{ background: theme.canvas.background, borderColor: theme.node.stroke }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} loading="lazy" />
</span>
<span className="block p-3">
<span className="flex items-center gap-2"><span className="text-sm font-semibold">{preset.title}</span>{active ? <span className="text-[var(--fs-tiny)]" style={{ color: theme.node.activeStroke }}>已选择</span> : null}</span>
<span className="mt-1 block text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</span>
<span className="mt-2 flex flex-wrap gap-1">{preset.tags.map((tag) => <span key={tag} className="rounded px-1.5 py-0.5 text-[var(--fs-tiny)]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{tag}</span>)}</span>
<span className="min-w-0 flex-1 px-3 py-2.5 pr-10">
<span className="flex items-center gap-1.5 text-xs font-semibold"><span className="truncate">{preset.title}</span>{active ? <span className="grid size-4 shrink-0 place-items-center rounded-[var(--r-full)]" style={{ background: theme.node.activeStroke, color: theme.canvas.background }}><Check className="size-3" /></span> : null}</span>
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{preset.category}</span>
<span className="mt-1 line-clamp-2 block text-[var(--fs-tiny)] leading-4" style={{ color: theme.node.muted }}>{preset.description}</span>
</span>
</button>
<button type="button" className="absolute right-2 top-2 grid size-8 place-items-center rounded-md bg-black/60 text-white shadow-sm backdrop-blur transition-colors hover:bg-black/75 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white" onClick={() => setDetailPreset(preset)} aria-label={`查看${preset.title}详情`} title="查看画风详情"><Eye className="size-4" /></button>
<button type="button" className="absolute right-2 top-2 grid size-7 place-items-center rounded-[var(--r-sm)] transition-colors focus-visible:outline-none focus-visible:ring-2" style={{ background: theme.toolbar.itemHover, color: theme.node.muted, "--tw-ring-color": theme.node.activeStroke } as CSSProperties} onClick={() => setDetailPreset(preset)} aria-label={`查看${preset.title}规范`} title="查看画风规范"><Eye className="size-3.5" /></button>
</article>
);
})}
</div>
<footer className="flex items-center gap-2 border-t px-4 py-3 text-[var(--fs-label)]" style={{ borderColor: theme.node.stroke, color: theme.node.faint }}><Clapperboard className="size-3.5" />图片是风格示意,实际效果取决于模型、角色参考图与分镜提示词。</footer>
</div> : <CanvasStyleComposer selection={selection} value={value} theme={theme} onChange={setSelection} onSelect={onSelect} onDetail={setDetailPreset} />}
</div>
</Modal>
<CanvasStyleDetailModal open={Boolean(detailPreset)} preset={detailPreset} selected={detailPreset?.id === value} onClose={() => setDetailPreset(null)} onSelect={(preset) => { setDetailPreset(null); onSelect(preset); }} />
@@ -407,27 +450,83 @@ export function CanvasStylePickerModal({ open, value, onClose, onSelect }: { ope
);
}
function CanvasStyleComposer({ selection, value, theme, onChange, onSelect, onDetail }: {
selection: ProjectStyleSelection;
value?: string;
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onChange: (selection: ProjectStyleSelection) => void;
onSelect: (preset: CanvasStylePreset) => void;
onDetail: (preset: CanvasStylePreset) => void;
}) {
const preset = useMemo(() => compileCanvasStylePreset(selection), [selection]);
const characterOptions = compatibleProjectStyleCharacters(selection.medium);
const updateMedium = (medium: ProjectStyleMediumId) => {
const compatible = compatibleProjectStyleCharacters(medium);
const character = compatible.some((item) => item.id === selection.character) ? selection.character : compatible[0].id;
onChange({ ...selection, medium, character });
};
return (
<div className="thin-scrollbar grid max-h-[76vh] overflow-y-auto md:grid-cols-5">
<section className="grid content-start gap-3 border-b p-4 md:col-span-2 md:border-b-0 md:border-r" style={{ borderColor: theme.node.stroke }}>
<StyleSelect label="题材世界" value={selection.world} options={projectStyleWorlds} onChange={(world) => onChange({ ...selection, world })} />
<StyleSelect label="叙事气质" value={selection.tone} options={projectStyleTones} onChange={(tone) => onChange({ ...selection, tone })} />
<StyleSelect label="视觉媒介" value={selection.medium} options={projectStyleMedia} onChange={updateMedium} />
<StyleSelect label="角色造型" value={selection.character} options={characterOptions} onChange={(character) => onChange({ ...selection, character })} />
</section>
<section className="flex min-h-0 flex-col md:col-span-3">
<div className="relative aspect-video overflow-hidden" style={{ background: theme.canvas.background }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-cover" />
</div>
<div className="flex min-h-0 flex-1 flex-col p-4">
<div className="text-sm font-semibold">{preset.title}</div>
<p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 border-y py-3 text-[var(--fs-tiny)]" style={{ borderColor: theme.node.stroke }}>
<StyleSummary label="题材" value={projectStyleWorlds.find((item) => item.id === selection.world)?.label} muted={theme.node.muted} />
<StyleSummary label="气质" value={projectStyleTones.find((item) => item.id === selection.tone)?.label} muted={theme.node.muted} />
<StyleSummary label="媒介" value={projectStyleMedia.find((item) => item.id === selection.medium)?.label} muted={theme.node.muted} />
<StyleSummary label="角色" value={characterOptions.find((item) => item.id === selection.character)?.label} muted={theme.node.muted} />
</div>
<div className="mt-auto flex justify-end gap-2 pt-4">
<Button icon={<Eye className="size-3.5" />} onClick={() => onDetail(preset)}>查看规范</Button>
<Button type="primary" disabled={preset.id === value} icon={preset.id === value ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{preset.id === value ? "当前画风" : "应用组合"}</Button>
</div>
</div>
</section>
</div>
);
}
function StyleSummary({ label, value, muted }: { label: string; value?: string; muted: string }) {
return <div className="flex min-w-0 items-center gap-2"><span className="shrink-0" style={{ color: muted }}>{label}</span><span className="truncate font-medium">{value}</span></div>;
}
function StyleSelect<T extends string>({ label, value, options, onChange }: { label: string; value: T; options: Array<{ id: T; label: string; description: string }>; onChange: (value: T) => void }) {
return (
<label className="grid gap-1.5 text-xs">
<span className="font-semibold">{label}</span>
<Select<T> value={value} optionLabelProp="label" onChange={onChange} options={options.map((option) => ({ value: option.id, label: option.label, title: option.description }))} optionRender={(option) => <div className="py-1"><div className="text-xs font-medium">{option.label}</div><div className="mt-0.5 whitespace-normal text-[var(--fs-tiny)] opacity-60">{option.data.title}</div></div>} />
</label>
);
}
export function CanvasStyleDetailModal({ open, preset, selected = false, onClose, onSelect }: { open: boolean; preset: CanvasStylePreset | null; selected?: boolean; onClose: () => void; onSelect?: (preset: CanvasStylePreset) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const sections = preset ? parseStyleSections(preset.prompt) : [];
return (
<Modal open={open} title={null} footer={null} centered destroyOnHidden width="min(920px, calc(100vw - 24px))" onCancel={onClose} styles={{ body: { padding: 0 } }}>
{preset ? <div className="flex max-h-[82vh] flex-col overflow-hidden rounded-lg md:grid md:grid-cols-[300px_minmax(0,1fr)]" style={{ color: theme.node.text, background: theme.node.panel }}>
<aside className="shrink-0 border-b md:border-b-0 md:border-r" style={{ borderColor: theme.node.stroke, background: theme.canvas.background }}>
<div className="aspect-[16/7] overflow-hidden md:aspect-auto md:h-full md:min-h-[540px]"><img src={preset.imageUrl} width="960" height="640" alt={`${preset.title}画风示意`} className="h-full w-full object-cover" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} /></div>
</aside>
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
<header className="border-b px-5 py-4" style={{ borderColor: theme.node.stroke }}>
<div className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}>{preset.category}</div>
<h2 className="mt-1 text-xl font-semibold">{preset.title}</h2>
<p className="mt-2 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
<div className="mt-3 flex flex-wrap gap-1.5">{preset.tags.map((tag) => <span key={tag} className="rounded px-2 py-1 text-[var(--fs-tiny)]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{tag}</span>)}</div>
</header>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-2">
{sections.map((section) => <div key={section.title} className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">{section.title}</h3><p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{section.content}</p></div>)}
</div>
<footer className="flex flex-col gap-2 border-t px-5 py-3 sm:flex-row sm:items-center sm:justify-between" style={{ borderColor: theme.node.stroke }}><span className="text-[var(--fs-tiny)]" style={{ color: theme.node.faint }}>角色三视图会自动提取其中的色彩、造型和材质规范</span><div className="flex shrink-0 justify-end gap-2"><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</div></footer>
</section>
<Modal rootClassName="canvas-style-detail-modal" open={open} title={null} footer={null} centered destroyOnHidden width="min(820px, calc(100vw - 24px))" onCancel={onClose} styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
{preset ? <div className="flex max-h-[84vh] flex-col overflow-hidden" style={{ color: theme.node.text, background: theme.node.panel }}>
<div className="flex h-44 shrink-0 items-center justify-center overflow-hidden border-b sm:h-52" style={{ borderColor: theme.node.stroke, background: theme.canvas.background }}>
<img src={preset.imageUrl} width="960" height="540" alt={`${preset.title}画风示意`} className="h-full w-full object-contain" style={preset.id === "black-white-noir" ? { filter: "grayscale(1) contrast(1.08)" } : undefined} />
</div>
<header className="border-b px-4 py-3 pr-12 sm:px-5 sm:pr-12" style={{ borderColor: theme.node.stroke }}>
<div className="text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}>{preset.category}</div>
<h2 className="mt-1 text-base font-semibold">{preset.title}</h2>
<p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{preset.description}</p>
</header>
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 sm:px-5">
{sections.map((section) => <section key={section.title} className="border-b py-3 last:border-b-0" style={{ borderColor: theme.node.stroke }}><h3 className="text-xs font-semibold">{section.title}</h3><p className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{section.content}</p></section>)}
</div>
<footer className="flex shrink-0 justify-end gap-2 border-t px-4 py-3 sm:px-5" style={{ borderColor: theme.node.stroke }}><Button onClick={onClose}>关闭</Button>{onSelect ? <Button type="primary" disabled={selected} icon={selected ? <Check className="size-3.5" /> : <Palette className="size-3.5" />} onClick={() => onSelect(preset)}>{selected ? "当前画风" : "选择该画风"}</Button> : null}</footer>
</div> : null}
</Modal>
);

View File

@@ -0,0 +1,56 @@
import { EditorView } from "@codemirror/view";
import CodeMirror from "@uiw/react-codemirror";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { cn } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
export type PromptCodeEditorHandle = {
insertText: (text: string) => void;
focus: () => void;
};
type PromptCodeEditorProps = {
value: string;
onChange?: (value: string) => void;
readOnly?: boolean;
ariaLabel: string;
className?: string;
};
export const PromptCodeEditor = forwardRef<PromptCodeEditorHandle, PromptCodeEditorProps>(function PromptCodeEditor({ value, onChange, readOnly = false, ariaLabel, className }, ref) {
const theme = useThemeStore((state) => state.theme);
const viewRef = useRef<EditorView | null>(null);
useImperativeHandle(ref, () => ({
insertText(text) {
const view = viewRef.current;
if (!view || readOnly) return;
const selection = view.state.selection.main;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: text },
selection: { anchor: selection.from + text.length },
});
view.focus();
},
focus() {
viewRef.current?.focus();
},
}), [readOnly]);
return (
<CodeMirror
className={cn("prompt-code-editor h-full min-h-0", className)}
value={value}
height="100%"
theme={theme}
readOnly={readOnly}
editable={!readOnly}
extensions={[EditorView.lineWrapping]}
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: !readOnly, highlightActiveLineGutter: !readOnly }}
aria-label={ariaLabel}
onCreateEditor={(view) => { viewRef.current = view; }}
onChange={onChange}
/>
);
});