feat(*): 分镜创作体系 - 重构画风、提示词模板与视频生成链路
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
56
web/src/components/prompt/prompt-code-editor.tsx
Normal file
56
web/src/components/prompt/prompt-code-editor.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user