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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -58,6 +58,9 @@ export function createStoryboardRow(shotNumber: number, patch: Partial<Storyboar
|
||||
plotDescription: "",
|
||||
dialogue: "",
|
||||
characters: [],
|
||||
narrativeIntent: "",
|
||||
viewerPOV: "",
|
||||
performanceBlocking: "",
|
||||
shotSize: "",
|
||||
emotion: "",
|
||||
lightingAndAtmosphere: "",
|
||||
@@ -67,6 +70,9 @@ export function createStoryboardRow(shotNumber: number, patch: Partial<Storyboar
|
||||
timeBeats: "",
|
||||
imageGenerationPrompt: "",
|
||||
videoMotionPrompt: "",
|
||||
mustHave: [],
|
||||
optionalDetails: [],
|
||||
continuityOut: "",
|
||||
negativePrompt: "",
|
||||
referenceNodeIds: [],
|
||||
status: "idle",
|
||||
@@ -74,14 +80,26 @@ export function createStoryboardRow(shotNumber: number, patch: Partial<Storyboar
|
||||
};
|
||||
}
|
||||
|
||||
// 有结构化变量时由服务端按最新平台模板和用户偏好编译;变量被清除表示用户已做镜头级手动覆盖。
|
||||
export function storyboardPromptTemplateMetadata(row: StoryboardRow, kind: "image" | "video"): Pick<CanvasNodeMetadata, "promptTemplateOperation" | "promptTemplateVariables"> {
|
||||
const variables = kind === "image" ? row.imagePromptTemplateVariables : row.videoPromptTemplateVariables;
|
||||
return variables
|
||||
? { promptTemplateOperation: kind === "image" ? "storyboard_first_frame" : "storyboard_video", promptTemplateVariables: variables }
|
||||
: { promptTemplateOperation: undefined, promptTemplateVariables: undefined };
|
||||
}
|
||||
|
||||
export function cinematicStoryboardColumns(columns?: StoryboardColumn[]): StoryboardColumn[] {
|
||||
return Array.from(new Set([
|
||||
...(columns || ["shotNumber", "durationSeconds", "plotDescription", "dialogue"]),
|
||||
"shotSize",
|
||||
"narrativeIntent",
|
||||
"viewerPOV",
|
||||
"performanceBlocking",
|
||||
"camera",
|
||||
"motion",
|
||||
"timeBeats",
|
||||
"lightingAndAtmosphere",
|
||||
"continuityOut",
|
||||
"negativePrompt",
|
||||
])) as StoryboardColumn[];
|
||||
}
|
||||
@@ -91,7 +109,19 @@ export function storyboardRowsFromTask(task: GenerationTask) {
|
||||
if (!Array.isArray(result.rows) || !result.rows.length) throw new Error("分镜任务没有返回镜头行");
|
||||
return {
|
||||
title: result.title?.trim(),
|
||||
rows: result.rows.map((row, index) => createStoryboardRow(index + 1, { ...row, id: `shot-${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 6)}`, shotNumber: index + 1, status: "idle", referenceNodeIds: Array.isArray(row.referenceNodeIds) ? row.referenceNodeIds : [] })),
|
||||
rows: result.rows.map((row, index) => {
|
||||
const next = createStoryboardRow(index + 1, {
|
||||
...row,
|
||||
id: `shot-${Date.now()}-${index + 1}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
shotNumber: index + 1,
|
||||
status: "idle",
|
||||
referenceNodeIds: Array.isArray(row.referenceNodeIds) ? row.referenceNodeIds : [],
|
||||
});
|
||||
next.characters = Array.isArray(row.characters) ? row.characters : [];
|
||||
next.mustHave = Array.isArray(row.mustHave) ? row.mustHave : [];
|
||||
next.optionalDetails = Array.isArray(row.optionalDetails) ? row.optionalDetails : [];
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,7 +197,7 @@ export function attachNodeToStoryboardRow(nodes: CanvasNodeData[], connection: P
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (row && node.id === linkedNode.id && scriptNodeId === connection.fromNodeId && node.type === CanvasNodeType.Video) {
|
||||
return { ...node, title: `镜头 ${row.shotNumber} · 视频`, metadata: { ...node.metadata, prompt: videoPrompt, composerContent: videoPrompt, workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: node.metadata?.videoEditOperation || "text_to_video", seconds: String(row.durationSeconds) } };
|
||||
return { ...node, title: `镜头 ${row.shotNumber} · 视频`, metadata: { ...node.metadata, prompt: videoPrompt, composerContent: videoPrompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: node.metadata?.videoEditOperation || "text_to_video", seconds: String(row.durationSeconds) } };
|
||||
}
|
||||
if (node.id !== scriptNodeId || node.type !== CanvasNodeType.Script) return node;
|
||||
const storyboard = node.metadata?.storyboard;
|
||||
|
||||
41
web/src/lib/canvas/canvas-storyboard-context.ts
Normal file
41
web/src/lib/canvas/canvas-storyboard-context.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
|
||||
export type StoryboardGenerationContext = {
|
||||
projectStyle: {
|
||||
presetId: string;
|
||||
title: string;
|
||||
prompt: string;
|
||||
};
|
||||
characters: Array<{
|
||||
assetId: string;
|
||||
versionId: string;
|
||||
name: string;
|
||||
definition: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
// 分镜的两条入口共用同一强校验,避免画风或角色版本在某条旁路里被遗漏。
|
||||
export function resolveStoryboardGenerationContext(nodes: CanvasNodeData[]): StoryboardGenerationContext {
|
||||
const styleNode = nodes.find((node) => node.metadata?.workflowKind === "styleboard");
|
||||
const stylePrompt = String(styleNode?.metadata?.content || styleNode?.metadata?.prompt || "").trim();
|
||||
const stylePresetId = String(styleNode?.metadata?.stylePresetId || "").trim();
|
||||
if (!styleNode || !stylePrompt || !stylePresetId) throw new Error("请先设置项目画风,再生成分镜");
|
||||
|
||||
const characterNodes = nodes.filter((node) => node.metadata?.workflowKind === "character");
|
||||
const invalidCharacter = characterNodes.find((node) => !node.metadata?.characterAssetId?.trim() || !node.metadata?.characterVersionId?.trim() || !(node.metadata?.characterName || node.title).trim());
|
||||
if (invalidCharacter) throw new Error(`角色卡“${invalidCharacter.metadata?.characterName || invalidCharacter.title || "未命名角色"}”版本未同步,请刷新角色资产后再生成分镜`);
|
||||
|
||||
return {
|
||||
projectStyle: {
|
||||
presetId: stylePresetId,
|
||||
title: styleNode.title.replace(/^(?:项目)?画风\s*[·::]?\s*/, "").trim() || styleNode.title,
|
||||
prompt: stylePrompt,
|
||||
},
|
||||
characters: characterNodes.map((node) => ({
|
||||
assetId: node.metadata!.characterAssetId!.trim(),
|
||||
versionId: node.metadata!.characterVersionId!.trim(),
|
||||
name: (node.metadata?.characterName || node.title).trim(),
|
||||
definition: node.metadata?.characterDefinition || { prompt: node.metadata?.characterPrompt || "" },
|
||||
})),
|
||||
};
|
||||
}
|
||||
332
web/src/lib/canvas/canvas-style-system.ts
Normal file
332
web/src/lib/canvas/canvas-style-system.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
export type ProjectStyleWorldId = "xianxia" | "urban" | "historical" | "suspense" | "science-fiction" | "pastoral";
|
||||
export type ProjectStyleToneId = "epic" | "dark" | "light-comedy" | "romantic" | "healing";
|
||||
export type ProjectStyleMediumId = "live-action" | "3d-anime" | "3d-cartoon" | "2d-guoman" | "ink";
|
||||
export type ProjectStyleCharacterId = "realistic" | "semi-real" | "anime" | "stylized";
|
||||
|
||||
export type ProjectStyleSelection = {
|
||||
world: ProjectStyleWorldId;
|
||||
tone: ProjectStyleToneId;
|
||||
medium: ProjectStyleMediumId;
|
||||
character: ProjectStyleCharacterId;
|
||||
};
|
||||
|
||||
export type CanvasStylePreset = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
prompt: string;
|
||||
imageUrl: string;
|
||||
selection?: ProjectStyleSelection;
|
||||
};
|
||||
|
||||
type StyleOption<T extends string> = {
|
||||
id: T;
|
||||
label: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
palette?: string;
|
||||
wardrobe?: string;
|
||||
environment?: string;
|
||||
motion?: string;
|
||||
forbidden: string;
|
||||
};
|
||||
|
||||
export const projectStyleWorlds: Array<StyleOption<ProjectStyleWorldId>> = [
|
||||
{
|
||||
id: "xianxia",
|
||||
label: "仙侠",
|
||||
description: "宗门、仙城、洞府、云海与法术规则共同构成东方修行世界。",
|
||||
prompt: "东方仙侠世界以宗门、境界、法器、灵兽和因果秩序为基础;奇观从中式山水、木构建筑、道家意象和修行体系演化,所有地点共享明确的时代、地理与力量规则。",
|
||||
palette: "云雾白、黛青、青玉、朱砂、古铜与阵营能量色",
|
||||
wardrobe: "袍服、劲装、甲胄、宗门纹样、发冠、腰封和法器按身份与阵营建立稳定资产层级",
|
||||
environment: "山门、殿宇、楼阁、洞府、仙城、云海、古战场和凡间城镇采用中式结构与东方山水空间",
|
||||
motion: "御剑、身法、法术和衣袍运动必须有起势、蓄力、受力与落点,力量等级通过规模和环境反馈表达",
|
||||
forbidden: "欧洲城堡、哥特教堂、日式鸟居、西式魔法阵、随机游戏 UI、宗门与法术规则漂移",
|
||||
},
|
||||
{
|
||||
id: "urban",
|
||||
label: "当代都市",
|
||||
description: "以可信中国城市、职业身份与生活关系组织现代短剧。",
|
||||
prompt: "当代中国都市世界以职业、家庭、收入、社区和城市公共生活为基础;人物行动、空间功能、中文标识与生活设施必须符合明确城市和社会语境。",
|
||||
palette: "中性白、雾灰、浅木、水泥色与克制的角色识别色",
|
||||
wardrobe: "服装按职业、收入、季节和性格建立衣橱,固定配饰承担角色识别",
|
||||
environment: "办公室、公寓、学校、医院、商场、街区、社区和公共交通保持本地尺度与使用痕迹",
|
||||
motion: "表演贴近日常重心、停顿和微表情,镜头运动服务人物关系与信息揭示",
|
||||
forbidden: "欧美城市替代、空洞样板间、错误中文、无职业逻辑服装、网红滤镜和广告摆拍",
|
||||
},
|
||||
{
|
||||
id: "historical",
|
||||
label: "古装历史",
|
||||
description: "先锁定单一时代,再统一礼制、服化道、器物与建筑。",
|
||||
prompt: "历史世界必须先依据故事锁定唯一朝代或年代子类型,人物制度、礼仪、服饰、兵器、器物、交通与建筑全部服从同一考据基线。",
|
||||
palette: "米白、黛灰、木褐、土黄、靛青、竹青与有限朱砂金色",
|
||||
wardrobe: "襟形、袖型、腰带、冠帽、鞋履和纹样按时代、阶层与身份建立制度",
|
||||
environment: "木构、院落、回廊、城镇、宫署、乡野和道路遵循所选时代的结构与材料",
|
||||
motion: "人物动作考虑礼制、服装重量、兵器惯性和具体生活劳动",
|
||||
forbidden: "朝代混搭、现代拉链家具、日式或欧式建筑、塑料饰品和现代偶像妆",
|
||||
},
|
||||
{
|
||||
id: "suspense",
|
||||
label: "悬疑犯罪",
|
||||
description: "真实城市纹理、线索系统和心理压力构成可读的悬疑世界。",
|
||||
prompt: "现代悬疑世界以案件、秘密、身份关系和可追踪线索构成,地点布局、关键道具、伤痕与时间线必须稳定,信息隐藏不能牺牲画面可读性。",
|
||||
palette: "炭灰、冷水泥、旧木、脏黄实景灯与少量危险强调色",
|
||||
wardrobe: "职业服饰、制服、便装和关键随身物按身份与时间线建立版本",
|
||||
environment: "旧居民楼、办公空间、街巷、仓储、地下空间和城郊设施保持同一城市地域",
|
||||
motion: "观察、跟随、遮挡和反应镜头服务线索揭示,避免把晃动当作悬疑",
|
||||
forbidden: "黑成不可读、霓虹灯海、过量烟雾、血浆猎奇、反派脸谱化和线索资产变形",
|
||||
},
|
||||
{
|
||||
id: "science-fiction",
|
||||
label: "未来科幻",
|
||||
description: "技术规则、社会结构与功能性空间共同支撑未来叙事。",
|
||||
prompt: "未来世界必须先确定技术水平、能源、交通、通讯和社会组织规则;科技资产具有可理解的功能、接口与使用限制,奇观必须反映文明尺度而非随机堆砌发光物。",
|
||||
palette: "深空黑、冷灰、银白、舱体白与单一阵营或能源强调色",
|
||||
wardrobe: "工作服、制服、航天或防护装备按功能与阵营模块化设计",
|
||||
environment: "舰船、空间站、未来城市、实验设施和居住空间遵循结构、维护与照明逻辑",
|
||||
motion: "运动体现设备重量、惯性、空间限制和技术反馈",
|
||||
forbidden: "无功能 HUD、随机霓虹、现实品牌、技术规则漂移、装备接口变化和无尺度参照",
|
||||
},
|
||||
{
|
||||
id: "pastoral",
|
||||
label: "自然乡野",
|
||||
description: "地域、季节、生活劳动和自然环境共同参与叙事。",
|
||||
prompt: "自然乡野世界以明确地域、季节、家庭关系和生活劳动为基础;植物、天气、住宅、工具与集市不是装饰,而是持续参与人物行动和关系变化的真实环境。",
|
||||
palette: "叶绿、苔藓绿、土色、旧木、天空色、米白与季节性角色点色",
|
||||
wardrobe: "棉麻、针织、旧牛仔、工作服、雨具和手作物保留褶皱与使用痕迹",
|
||||
environment: "村镇住宅、林间小屋、农场、河岸、菜园、集市和小城边缘具有地域证据",
|
||||
motion: "风、雨、植物、动物和细小生活动作形成自然节奏",
|
||||
forbidden: "商业民宿广告感、欧美乡村替代、随机换季、无生活痕迹和过度航拍空镜",
|
||||
},
|
||||
];
|
||||
|
||||
export const projectStyleTones: Array<StyleOption<ProjectStyleToneId>> = [
|
||||
{
|
||||
id: "epic",
|
||||
label: "宏大叙事",
|
||||
description: "用尺度、秩序与人物命运建立史诗感,不靠全程阴暗和特效堆积。",
|
||||
prompt: "叙事气质庄重、开阔、有历史与命运重量;宏大场面负责建立空间和力量关系,人物镜头负责承接选择与代价,奇观和表演之间保持清晰主次。",
|
||||
palette: "高明度空间层次、稳定阵营色与少量高亮奇观色,暗部保留细节",
|
||||
motion: "建立镜头克制而有尺度,高潮运动有明确起止,人物反应获得停顿",
|
||||
forbidden: "全片低照度、无休止慢动作、每镜航拍、特效盖住人物、只有规模没有情绪落点",
|
||||
},
|
||||
{
|
||||
id: "dark",
|
||||
label: "暗黑诡谲",
|
||||
description: "以未知、禁忌和心理压力形成黑暗气质,同时保持信息可读。",
|
||||
prompt: "叙事气质危险、克制、带未知感;恐惧来自空间、规则和人物反应,不依赖血浆、全黑画面或持续尖叫。",
|
||||
palette: "低调但可读的冷暖分区,危险色只用于禁忌、敌意或线索",
|
||||
motion: "更多观察、遮挡、缓慢揭示和反应停顿,爆发运动只用于关键破局",
|
||||
forbidden: "黑成不可读、血腥猎奇、廉价红黑滤镜、全屏烟雾、无来源闪光和持续镜头抖动",
|
||||
},
|
||||
{
|
||||
id: "light-comedy",
|
||||
label: "轻喜剧",
|
||||
description: "宏观处境与人物一本正经的反应形成反差,画面明快但不幼稚。",
|
||||
prompt: "叙事气质轻松、机敏、有人情味;笑点依靠人物关系、反应、停顿和处境反差,允许宏大世界承载小人物式幽默,不把喜剧等同于夸张鬼脸。",
|
||||
palette: "清晰明快的中高明度色彩,角色与背景保持稳定分离,危险场面也避免恐怖化压黑",
|
||||
motion: "动作有清楚预备、落点和喜剧停顿,反应镜头与正经表演优先于炫技运镜",
|
||||
forbidden: "阴森恐怖基调、儿童节目式糖果色、所有角色做鬼脸、连续快切和笑点被特效淹没",
|
||||
},
|
||||
{
|
||||
id: "romantic",
|
||||
label: "唯美浪漫",
|
||||
description: "以人物距离、光线和环境细节表达关系变化,避免滤镜化糖水感。",
|
||||
prompt: "叙事气质细腻、含蓄、具有关系张力;浪漫来自视线、距离、触碰、环境变化和未说出口的情绪,不依赖随机花瓣与持续柔焦。",
|
||||
palette: "自然肤色、柔和冷暖关系与克制的情绪点色",
|
||||
motion: "镜头靠近人物和细节,运动缓慢而有情绪动机,保留呼吸与停顿",
|
||||
forbidden: "全程柔焦、过曝肤色、随机花瓣、MV 式空镜、无表演的慢动作和滤镜漂移",
|
||||
},
|
||||
{
|
||||
id: "healing",
|
||||
label: "温暖治愈",
|
||||
description: "通过生活行动、环境触感和关系修复形成温暖,而不是泛化柔光。",
|
||||
prompt: "叙事气质温暖、松弛、具体;治愈来自可见的照料、劳动、陪伴和关系变化,环境材质与自然声音参与情绪表达。",
|
||||
palette: "自然中高明度、温暖但不过黄,保留木、布、植物和肤色的真实层次",
|
||||
motion: "观察细小动作、呼吸、眼神和环境响应,节奏舒展但不空洞",
|
||||
forbidden: "全片奶油滤镜、过度暖黄、无剧情自然空镜、广告式精致和人物永远微笑",
|
||||
},
|
||||
];
|
||||
|
||||
export const projectStyleMedia: Array<StyleOption<ProjectStyleMediumId> & { characters: ProjectStyleCharacterId[] }> = [
|
||||
{
|
||||
id: "live-action",
|
||||
label: "真人实拍",
|
||||
description: "真实演员、可信光学、服化道和物理环境。",
|
||||
prompt: "采用真人影视拍摄媒介:真实人物比例、自然皮肤与发丝、可信镜头光学和现场光源,服装、建筑、道具与特效保持真实材质响应。",
|
||||
motion: "动作遵循真实重心、惯性、衣料重量和摄影机物理运动",
|
||||
forbidden: "动画角色、游戏 CG、塑料皮肤、过度磨皮、虚假棚拍和无物理依据的材质",
|
||||
characters: ["realistic", "semi-real"],
|
||||
},
|
||||
{
|
||||
id: "3d-anime",
|
||||
label: "3D 动漫",
|
||||
description: "高品质三维动画、东方动漫造型与电影化场景。",
|
||||
prompt: "采用高品质 3D 动漫媒介:清晰三维体积、统一 PBR 或风格化 PBR 材质、受控次表面散射、稳定拓扑和电影级灯光;保持动画角色的设计感,不滑向真人恐怖谷或廉价游戏过场。",
|
||||
motion: "动画具备明确关键姿势、预备动作、跟随、受力和停顿,特效与角色动作共享同一空间和光照",
|
||||
forbidden: "廉价手游 CG、塑料手办反光、僵硬待机动作、真人毛孔皮肤、角色与背景渲染体系不一致",
|
||||
characters: ["semi-real", "anime"],
|
||||
},
|
||||
{
|
||||
id: "3d-cartoon",
|
||||
label: "3D 卡通",
|
||||
description: "风格化比例、清晰剪影、柔和材质和可读表情。",
|
||||
prompt: "采用风格化 3D 卡通动画媒介:简练几何、清晰剪影、半哑光材质、柔和次表面散射和可读表情;场景与角色使用同一圆角、粗糙度和细节密度体系。",
|
||||
motion: "角色运动使用适度挤压拉伸、跟随、缓入缓出和清晰停顿,夸张建立在稳定骨架与关节结构上",
|
||||
forbidden: "塑料公仔、所有角色同脸、过大玻璃眼、僵硬 T Pose、关节穿模和写实资产混入",
|
||||
characters: ["stylized"],
|
||||
},
|
||||
{
|
||||
id: "2d-guoman",
|
||||
label: "国漫 2D",
|
||||
description: "稳定线稿、赛璐璐角色与东方绘景体系。",
|
||||
prompt: "采用国漫 2D 动画媒介:有粗细变化的稳定线稿、两至三阶赛璐璐明暗、清晰角色剪影和东方绘景层次;全项目保持二维绘画语言。",
|
||||
motion: "使用明确关键姿势、有限但准确的二维运动,口型、发丝、衣摆与特效遵循统一帧间节奏",
|
||||
forbidden: "三维塑料质感、真人照片、线宽漂移、角色转面失真、背景与角色画法不一致",
|
||||
characters: ["semi-real", "anime", "stylized"],
|
||||
},
|
||||
{
|
||||
id: "ink",
|
||||
label: "水墨动画",
|
||||
description: "宣纸、墨阶、笔势与留白构成可识别的东方叙事。",
|
||||
prompt: "采用中国水墨动画媒介:宣纸纤维、干湿笔触、飞白、积墨、破墨与有限工笔勾线;留白、墨阶和固定点色共同塑造空间与人物身份。",
|
||||
motion: "轮廓先行、笔势跟随、墨迹生长与消散遵循统一规则,人物结构在变化中保持可识别",
|
||||
forbidden: "随机泼墨、角色五官消失、西式水彩、全屏脏灰、廉价纸纹滤镜和随机文字印章",
|
||||
characters: ["semi-real", "stylized"],
|
||||
},
|
||||
];
|
||||
|
||||
export const projectStyleCharacters: Array<StyleOption<ProjectStyleCharacterId>> = [
|
||||
{
|
||||
id: "realistic",
|
||||
label: "写实人物",
|
||||
description: "真实骨相、年龄、皮肤和身体比例。",
|
||||
prompt: "角色采用真实东亚骨相、自然年龄和真实身体比例,通过脸型、五官、发型、体型、姿态与生活痕迹建立差异;保留自然皮肤和微表情。",
|
||||
forbidden: "网红同脸、过度磨皮、幼态大眼、年龄漂移、欧美默认脸和夸张动漫比例",
|
||||
},
|
||||
{
|
||||
id: "semi-real",
|
||||
label: "半写实仿真人",
|
||||
description: "东方骨相与理想化造型平衡,保留可信结构。",
|
||||
prompt: "角色采用半写实东方造型:可信骨骼、肌肉和关节结构,五官适度理想化但保留年龄与身份差异;发丝、皮肤和服装细节服从所选媒介,不进入恐怖谷。",
|
||||
forbidden: "真人扫描僵硬感、统一偶像脸、毛孔级过度写实、假睫毛模板和身体比例漂移",
|
||||
},
|
||||
{
|
||||
id: "anime",
|
||||
label: "动漫角色",
|
||||
description: "修长比例、清晰五官设计和稳定角色剪影。",
|
||||
prompt: "角色采用东方动漫造型:约 7 至 8 头身,五官、发束、眼形和服装结构高度可识别;夸张集中在设计语言和表情,不破坏身体结构与身份稳定性。",
|
||||
forbidden: "低幼大头、过大玻璃眼、随机发色、所有角色同一脸型、五官在转面中漂移",
|
||||
},
|
||||
{
|
||||
id: "stylized",
|
||||
label: "风格化卡通",
|
||||
description: "夸张比例与可读表情服务喜剧和轻松叙事。",
|
||||
prompt: "角色采用统一的风格化卡通比例:成人约 5 至 6 头身,儿童约 3.5 至 4.5 头身,头手可适度夸张;通过眼鼻嘴比例、发型、体型和剪影建立显著差异。",
|
||||
forbidden: "所有角色同脸、头身随机变化、过大玻璃眼、关节结构错误、塑料玩具感和僵硬表情",
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultProjectStyleSelection: ProjectStyleSelection = {
|
||||
world: "xianxia",
|
||||
tone: "epic",
|
||||
medium: "3d-anime",
|
||||
character: "semi-real",
|
||||
};
|
||||
|
||||
const STYLE_SCOPE = "【使用边界】本规范锁定项目级题材世界、叙事气质、视觉媒介、角色造型、资产材质与影像基线。单镜头的剧情动作、构图、景别、机位、运镜、光源位置和天气由分镜按本规范创作,不得机械复制项目级描述。";
|
||||
|
||||
export function compatibleProjectStyleCharacters(mediumId: ProjectStyleMediumId) {
|
||||
const allowed = projectStyleMedia.find((item) => item.id === mediumId)?.characters || [];
|
||||
return projectStyleCharacters.filter((item) => allowed.includes(item.id));
|
||||
}
|
||||
|
||||
export function compileCanvasStylePreset(selection: ProjectStyleSelection): CanvasStylePreset {
|
||||
const world = requiredOption(projectStyleWorlds, selection.world);
|
||||
const tone = requiredOption(projectStyleTones, selection.tone);
|
||||
const medium = requiredOption(projectStyleMedia, selection.medium);
|
||||
const compatibleCharacters = compatibleProjectStyleCharacters(selection.medium);
|
||||
const character = compatibleCharacters.find((item) => item.id === selection.character) || compatibleCharacters[0];
|
||||
const resolvedSelection = { ...selection, character: character.id };
|
||||
const title = `${world.label} · ${tone.label} · ${medium.label}`;
|
||||
return {
|
||||
id: styleSelectionId(resolvedSelection),
|
||||
title,
|
||||
category: `${world.label} / ${medium.label}`,
|
||||
description: `${world.description}${tone.description}${medium.description}`,
|
||||
tags: [world.label, tone.label, medium.label, character.label],
|
||||
imageUrl: stylePreviewImage(resolvedSelection),
|
||||
selection: resolvedSelection,
|
||||
prompt: [
|
||||
`【风格组合】题材世界:${world.label};叙事气质:${tone.label};视觉媒介:${medium.label};角色造型:${character.label}。以下四项必须同时成立,后续角色、场景、分镜和视频不得擅自替换其中任一维度。`,
|
||||
`【项目定位】面向“${world.label}”题材的${tone.label}${medium.label}项目,以统一世界规则、角色资产和视觉媒介支撑连续叙事;项目规范只定义长期稳定的美术基线,不代替单镜头导演设计。`,
|
||||
`【题材世界观】${world.prompt}`,
|
||||
`【叙事气质】${tone.prompt}`,
|
||||
`【视觉媒介】${medium.prompt} 成片保持高清可读的主体轮廓、稳定材质和受控高光;高清不等于过度锐化、磨皮或无来源泛光。`,
|
||||
`【角色设计系统】${character.prompt}`,
|
||||
`【项目色彩与光影】基础色彩来自${world.palette};本项目采用${tone.palette}。色彩按角色、阵营、地点和叙事功能分配,保证人物与环境具有稳定的明度和色相分离。`,
|
||||
`【服饰、材质与场景】${world.wardrobe};${world.environment}。所有材质和细节密度遵循“${medium.label}”媒介,不混入其他渲染体系。`,
|
||||
`【影像与动态基线】${tone.motion};${medium.motion};${world.motion}。分镜必须先确定镜头叙事意图,再选择景别与运镜。`,
|
||||
"【资产一致性】角色固定脸型、五官比例、发型、头身、服装、道具和阵营色;主要场景固定空间布局、建筑模块、材质与照明基线;换装、受损、升级和时间变化必须形成明确资产版本。",
|
||||
`【全局禁用】${world.forbidden};${tone.forbidden};${medium.forbidden};${character.forbidden}。`,
|
||||
STYLE_SCOPE,
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCanvasStyleSelection(id?: string): ProjectStyleSelection | null {
|
||||
if (!id?.startsWith("v2-")) return null;
|
||||
const parts = id.slice(3).split("--");
|
||||
if (parts.length !== 4) return null;
|
||||
const selection = { world: parts[0], tone: parts[1], medium: parts[2], character: parts[3] } as ProjectStyleSelection;
|
||||
if (!projectStyleWorlds.some((item) => item.id === selection.world)
|
||||
|| !projectStyleTones.some((item) => item.id === selection.tone)
|
||||
|| !projectStyleMedia.some((item) => item.id === selection.medium)
|
||||
|| !projectStyleCharacters.some((item) => item.id === selection.character)) return null;
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function customCanvasStylePreset(id?: string) {
|
||||
const selection = parseCanvasStyleSelection(id);
|
||||
return selection ? compileCanvasStylePreset(selection) : undefined;
|
||||
}
|
||||
|
||||
const recommendedSelections: ProjectStyleSelection[] = [
|
||||
{ world: "xianxia", tone: "epic", medium: "3d-anime", character: "semi-real" },
|
||||
{ world: "xianxia", tone: "dark", medium: "3d-anime", character: "semi-real" },
|
||||
{ world: "xianxia", tone: "light-comedy", medium: "3d-cartoon", character: "stylized" },
|
||||
{ world: "xianxia", tone: "epic", medium: "live-action", character: "realistic" },
|
||||
{ world: "urban", tone: "light-comedy", medium: "live-action", character: "realistic" },
|
||||
{ world: "suspense", tone: "dark", medium: "live-action", character: "realistic" },
|
||||
{ world: "pastoral", tone: "healing", medium: "3d-cartoon", character: "stylized" },
|
||||
{ world: "historical", tone: "romantic", medium: "2d-guoman", character: "semi-real" },
|
||||
];
|
||||
|
||||
export const recommendedCanvasStylePresets = recommendedSelections.map(compileCanvasStylePreset);
|
||||
|
||||
function styleSelectionId(selection: ProjectStyleSelection) {
|
||||
return `v2-${selection.world}--${selection.tone}--${selection.medium}--${selection.character}`;
|
||||
}
|
||||
|
||||
function stylePreviewImage(selection: ProjectStyleSelection) {
|
||||
if (selection.world === "xianxia") {
|
||||
if (selection.medium === "live-action") return "/short-drama-styles/period-live-action.jpg";
|
||||
if (selection.medium === "3d-cartoon") return "/short-drama-styles/three-d-cartoon.jpg";
|
||||
if (selection.medium === "2d-guoman") return "/short-drama-styles/chinese-2d.jpg";
|
||||
if (selection.medium === "ink") return "/short-drama-styles/ink-narrative.jpg";
|
||||
return "/short-drama-styles/fantasy-3d.jpg";
|
||||
}
|
||||
if (selection.world === "suspense") return "/short-drama-styles/suspense-noir.jpg";
|
||||
if (selection.world === "science-fiction") return "/short-drama-styles/future-tech.jpg";
|
||||
if (selection.world === "pastoral") return "/short-drama-styles/nature-healing.jpg";
|
||||
if (selection.world === "historical") return selection.medium === "2d-guoman" ? "/short-drama-styles/chinese-2d.jpg" : "/short-drama-styles/period-live-action.jpg";
|
||||
return selection.medium === "3d-cartoon" ? "/short-drama-styles/three-d-cartoon.jpg" : "/short-drama-styles/urban-live-action.jpg";
|
||||
}
|
||||
|
||||
function requiredOption<T extends string>(options: Array<StyleOption<T>>, id: T) {
|
||||
const option = options.find((item) => item.id === id);
|
||||
if (!option) throw new Error(`未知项目画风选项:${id}`);
|
||||
return option;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SettingsSection = "channels" | "models" | "preferences" | "storage";
|
||||
export type SettingsSection = "channels" | "models" | "preferences" | "prompts" | "storage";
|
||||
|
||||
export function settingsPath(section: SettingsSection = "channels", continueCreation = false) {
|
||||
const params = new URLSearchParams({ section });
|
||||
|
||||
@@ -25,7 +25,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
items: [
|
||||
{ path: "/admin/users", label: "用户管理", description: "账号、角色与状态", icon: <UsersRound className="size-4" /> },
|
||||
{ path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: <RadioTower className="size-4" /> },
|
||||
{ path: "/admin/storyboard-prompts", label: "分镜提示词", description: "Agent 提示词版本", icon: <MessageSquareText className="size-4" /> },
|
||||
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,45 +1,68 @@
|
||||
import { App, Button, Drawer, Form, Input, Select, Table, Tag } from "antd";
|
||||
import { Alert, App, Button, Drawer, Form, Input, Popconfirm, Select, Table, Tabs, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Copy, Info, Pencil, Plus, Power, Search, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Braces, Copy, FileJson, FileText, Info, Plus, Power, Search, ShieldCheck, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { createAdminStoryboardPromptTemplate, deleteAdminStoryboardPromptTemplate, listAdminStoryboardPromptTemplates, updateAdminStoryboardPromptTemplate, type StoryboardPromptTemplate, type StoryboardPromptVariable } from "@/services/api/auth";
|
||||
import { PromptCodeEditor, type PromptCodeEditorHandle } from "@/components/prompt/prompt-code-editor";
|
||||
import {
|
||||
createAdminPromptTemplate,
|
||||
deleteAdminPromptTemplate,
|
||||
listAdminPromptTemplates,
|
||||
updateAdminPromptTemplate,
|
||||
type PromptOperationDefinition,
|
||||
type PromptTemplate,
|
||||
} from "@/services/api/auth";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton } from "../components/admin-ui";
|
||||
|
||||
type PromptFormValues = { name: string; content: string; enabled?: boolean };
|
||||
type PromptFormValues = { name: string; enabled?: boolean };
|
||||
type DraftBaseline = { operation: string; name: string; enabled: boolean; content: string };
|
||||
|
||||
export default function StoryboardPromptsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const { message } = App.useApp();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const keyword = searchParams.get("filter") || "";
|
||||
const operationFilter = searchParams.get("operation") || "all";
|
||||
const status = searchParams.get("status") === "enabled" || searchParams.get("status") === "disabled" ? searchParams.get("status") as "enabled" | "disabled" : "all";
|
||||
const [templates, setTemplates] = useState<StoryboardPromptTemplate[]>([]);
|
||||
const [variables, setVariables] = useState<StoryboardPromptVariable[]>([]);
|
||||
const [templates, setTemplates] = useState<PromptTemplate[]>([]);
|
||||
const [definitions, setDefinitions] = useState<PromptOperationDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<StoryboardPromptTemplate | null>(null);
|
||||
const [baseTemplate, setBaseTemplate] = useState<PromptTemplate | null>(null);
|
||||
const [draftOperation, setDraftOperation] = useState("");
|
||||
const [pendingOperation, setPendingOperation] = useState("");
|
||||
const [draftBaseline, setDraftBaseline] = useState<DraftBaseline | null>(null);
|
||||
const [editorContent, setEditorContent] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm<PromptFormValues>();
|
||||
const hasFilters = Boolean(keyword || status !== "all");
|
||||
const editorRef = useRef<PromptCodeEditorHandle>(null);
|
||||
const draftName = Form.useWatch("name", form) || "";
|
||||
const draftEnabled = Form.useWatch("enabled", form) === true;
|
||||
const selectedDefinition = definitions.find((item) => item.operation === draftOperation);
|
||||
const hasFilters = Boolean(keyword || operationFilter !== "all" || status !== "all");
|
||||
const dirty = Boolean(draftBaseline) && (
|
||||
draftOperation !== draftBaseline?.operation
|
||||
|| draftName !== draftBaseline?.name
|
||||
|| draftEnabled !== draftBaseline?.enabled
|
||||
|| editorContent !== draftBaseline?.content
|
||||
);
|
||||
|
||||
const updateUrl = (patch: { filter?: string; status?: string }) => {
|
||||
const updateUrl = (patch: { filter?: string; operation?: string; status?: string }) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (patch.filter !== undefined) patch.filter ? next.set("filter", patch.filter) : next.delete("filter");
|
||||
if (patch.status !== undefined) patch.status !== "all" ? next.set("status", patch.status) : next.delete("status");
|
||||
Object.entries(patch).forEach(([key, value]) => value && value !== "all" ? next.set(key, value) : next.delete(key));
|
||||
setSearchParams(next);
|
||||
};
|
||||
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listAdminStoryboardPromptTemplates();
|
||||
const result = await listAdminPromptTemplates();
|
||||
setTemplates(result.templates);
|
||||
setVariables(result.variables);
|
||||
setDefinitions(result.definitions);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取分镜提示词失败");
|
||||
message.error(error instanceof Error ? error.message : "读取提示词模板失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -47,91 +70,189 @@ export default function StoryboardPromptsPage() {
|
||||
|
||||
useEffect(() => { void reload(); }, []);
|
||||
|
||||
const definitionByOperation = useMemo(() => new Map(definitions.map((item) => [item.operation, item])), [definitions]);
|
||||
const filtered = useMemo(() => templates.filter((template) => {
|
||||
const definition = definitionByOperation.get(template.operation);
|
||||
const normalizedKeyword = keyword.trim().toLowerCase();
|
||||
if (normalizedKeyword && !`${template.name} ${template.content}`.toLowerCase().includes(normalizedKeyword)) return false;
|
||||
if (normalizedKeyword && !`${template.name} ${template.content} ${definition?.label || ""}`.toLowerCase().includes(normalizedKeyword)) return false;
|
||||
if (operationFilter !== "all" && template.operation !== operationFilter) return false;
|
||||
if (status === "enabled" && !template.enabled) return false;
|
||||
if (status === "disabled" && template.enabled) return false;
|
||||
return true;
|
||||
}), [keyword, status, templates]);
|
||||
}), [definitionByOperation, keyword, operationFilter, status, templates]);
|
||||
|
||||
const openDrawer = (template?: StoryboardPromptTemplate) => {
|
||||
setEditing(template || null);
|
||||
const activeTemplateFor = (operation: string) => templates.find((template) => template.operation === operation && template.enabled);
|
||||
|
||||
const loadDraftBaseline = (operation: string, template?: PromptTemplate) => {
|
||||
const source = template || activeTemplateFor(operation);
|
||||
const baseline = {
|
||||
operation,
|
||||
name: source ? `${source.name} · 新版本` : "",
|
||||
enabled: false,
|
||||
content: source?.content || "",
|
||||
};
|
||||
setDraftOperation(operation);
|
||||
setPendingOperation("");
|
||||
setEditorContent(baseline.content);
|
||||
setDraftBaseline(baseline);
|
||||
form.setFieldsValue({ name: baseline.name, enabled: baseline.enabled });
|
||||
};
|
||||
|
||||
const openDrawer = (template?: PromptTemplate) => {
|
||||
const operation = template?.operation || definitions[0]?.operation || "";
|
||||
setBaseTemplate(template || null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(template ? { name: template.name, content: template.content, enabled: template.enabled } : { name: "", content: "", enabled: false });
|
||||
loadDraftBaseline(operation, template);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const switchOperation = (operation: string) => {
|
||||
if (!dirty) {
|
||||
loadDraftBaseline(operation);
|
||||
return;
|
||||
}
|
||||
setPendingOperation(operation);
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
if (saving) return;
|
||||
if (!form.isFieldsTouched()) return setDrawerOpen(false);
|
||||
modal.confirm({ title: "放弃提示词修改?", content: "尚未保存的版本内容将丢失。", okText: "放弃修改", cancelText: "继续编辑", okButtonProps: { danger: true }, onOk: () => setDrawerOpen(false) });
|
||||
setDrawerOpen(false);
|
||||
setPendingOperation("");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
if (!editorContent.trim()) {
|
||||
message.warning("请填写提示词模板内容");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { name: values.name.trim(), content: values.content, enabled: values.enabled === true };
|
||||
await (editing ? updateAdminStoryboardPromptTemplate(editing.id, payload) : createAdminStoryboardPromptTemplate(payload));
|
||||
await createAdminPromptTemplate({ operation: draftOperation, name: values.name.trim(), content: editorContent, enabled: values.enabled === true });
|
||||
setDraftBaseline(null);
|
||||
setDrawerOpen(false);
|
||||
form.resetFields();
|
||||
await reload();
|
||||
message.success(editing ? "分镜提示词已更新" : "分镜提示词已创建");
|
||||
message.success("提示词新版本已创建");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存分镜提示词失败");
|
||||
message.error(error instanceof Error ? error.message : "保存提示词模板失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activate = async (template: StoryboardPromptTemplate) => {
|
||||
const activate = async (template: PromptTemplate) => {
|
||||
try {
|
||||
await updateAdminStoryboardPromptTemplate(template.id, { name: template.name, content: template.content, enabled: true });
|
||||
await updateAdminPromptTemplate(template.id, { operation: template.operation, name: template.name, content: template.content, enabled: true });
|
||||
await reload();
|
||||
message.success("分镜提示词已启用");
|
||||
message.success("提示词版本已启用");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "启用分镜提示词失败");
|
||||
message.error(error instanceof Error ? error.message : "启用提示词版本失败");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (template: StoryboardPromptTemplate) => {
|
||||
const remove = async (template: PromptTemplate) => {
|
||||
try {
|
||||
await deleteAdminStoryboardPromptTemplate(template.id);
|
||||
await deleteAdminPromptTemplate(template.id);
|
||||
await reload();
|
||||
message.success("分镜提示词已删除");
|
||||
message.success("提示词版本已删除");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "删除分镜提示词失败");
|
||||
message.error(error instanceof Error ? error.message : "删除提示词版本失败");
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<StoryboardPromptTemplate> = [
|
||||
{ title: "模板", dataIndex: "name", render: (_, template) => <div><div className="font-medium">{template.name}</div><div className="text-xs text-foreground/45">{template.content.length} 字符</div></div> },
|
||||
{ title: "内容摘要", dataIndex: "content", width: 420, render: (content: string) => <div className="line-clamp-2 whitespace-pre-wrap text-xs leading-5 text-foreground/65">{content}</div> },
|
||||
{ title: "状态", dataIndex: "enabled", width: 110, render: (enabled) => <Tag variant="filled" color={enabled ? "success" : "default"}>{enabled ? "启用中" : "未启用"}</Tag> },
|
||||
const columns: ColumnsType<PromptTemplate> = [
|
||||
{ title: "模板类型", dataIndex: "operation", width: 180, render: (operation: string) => { const definition = definitionByOperation.get(operation); return <div><div className="font-medium">{definition?.label || operation}</div><div className="mt-1 text-xs text-foreground/45">{definition?.category || "--"}</div></div>; } },
|
||||
{ title: "版本", dataIndex: "name", render: (_, template) => <div><div className="font-medium">{template.name}</div><div className="mt-1 text-xs text-foreground/45">v{template.version} · {template.content.length} 字符</div></div> },
|
||||
{ title: "输出", dataIndex: "outputType", width: 120, render: (outputType: string, template) => <span className="inline-flex items-center gap-1.5 text-xs text-foreground/65">{outputType === "json" ? <FileJson className="size-3.5" /> : <FileText className="size-3.5" />}{outputType === "json" ? definitionByOperation.get(template.operation)?.schemaKey || "JSON" : "文本"}</span> },
|
||||
{ title: "状态", dataIndex: "enabled", width: 100, render: (enabled) => <Tag variant="filled" color={enabled ? "success" : "default"}>{enabled ? "启用中" : "历史版"}</Tag> },
|
||||
{ title: "更新时间", dataIndex: "updatedAt", width: 180, render: formatTime },
|
||||
{ title: "操作", width: 140, fixed: "right", align: "right", render: (_, template) => <AdminRowActions primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openDrawer(template) }} actions={[{ key: "activate", label: "启用版本", icon: <Power className="size-3.5" />, disabled: template.enabled, confirm: { title: "启用这个提示词版本?", description: "启用后会立即替换当前 Agent 分镜生成所使用的版本。", okText: "确认启用" }, onClick: () => activate(template) }, { key: "delete", label: "删除版本", icon: <Trash2 className="size-3.5" />, danger: true, disabled: template.enabled, confirm: { title: "删除这个提示词版本?", description: "删除后不可恢复;启用中的版本不能删除。", okText: "确认删除" }, onClick: () => remove(template) }]} /> },
|
||||
{ title: "操作", width: 170, fixed: "right", align: "right", render: (_, template) => <AdminRowActions primary={{ label: "基于此版本新建", icon: <Copy className="size-3.5" />, onClick: () => openDrawer(template) }} actions={[{ key: "activate", label: "启用版本", icon: <Power className="size-3.5" />, disabled: template.enabled, confirm: { title: "启用这个提示词版本?", description: "只会替换同类型的当前版本,其他模板类型不受影响。", okText: "确认启用" }, onClick: () => activate(template) }, { key: "delete", label: "删除版本", icon: <Trash2 className="size-3.5" />, danger: true, disabled: template.enabled, confirm: { title: "删除这个历史版本?", description: "删除后不可恢复,启用中的版本不能删除。", okText: "确认删除" }, onClick: () => remove(template) }]} /> },
|
||||
];
|
||||
|
||||
const insertVariable = (placeholder: string) => {
|
||||
const current = form.getFieldValue("content") || "";
|
||||
form.setFieldValue("content", `${current}${current && !current.endsWith("\n") ? "\n" : ""}${placeholder}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="分镜提示词" description="Agent 提示词版本" actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增版本</Button>}>
|
||||
<div className="border-b border-border pb-4 text-xs leading-5 text-foreground/60"><div className="flex items-center gap-1.5 font-medium text-foreground/80"><Info className="size-3.5" />真实用于 Agent 分镜生成</div><p>当前启用版本会与镜头时长、数量和电影化质量约束一起发送给文本模型。展开行可查看完整提示词。</p></div>
|
||||
<ListToolbar active={hasFilters} onReset={() => updateUrl({ filter: "", status: "all" })}>
|
||||
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索版本名称或提示词" onChange={(event) => updateUrl({ filter: event.target.value })} />
|
||||
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value })} options={[{ label: "全部状态", value: "all" }, { label: "启用中", value: "enabled" }, { label: "未启用", value: "disabled" }]} />
|
||||
<AdminPageFrame title="提示词模板" description="平台创作策略与版本管理" actions={<Button type="primary" icon={<Plus className="size-4" />} disabled={!definitions.length} onClick={() => openDrawer()}>新建版本</Button>}>
|
||||
<div className="border-b border-border pb-4 text-xs leading-5 text-foreground/60"><div className="flex items-center gap-1.5 font-medium text-foreground/80"><ShieldCheck className="size-3.5" />创作策略与执行契约分离</div><p>可以调整导演方法、镜头语言和生成策略。JSON Schema、动态项目数据和服务端校验属于受保护契约,只读展示且不会被模板覆盖。</p></div>
|
||||
<ListToolbar active={hasFilters} onReset={() => updateUrl({ filter: "", operation: "all", status: "all" })}>
|
||||
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索模板或内容" onChange={(event) => updateUrl({ filter: event.target.value })} />
|
||||
<Select className="w-40" value={operationFilter} onChange={(value) => updateUrl({ operation: value })} options={[{ label: "全部类型", value: "all" }, ...definitions.map((item) => ({ label: item.label, value: item.operation }))]} />
|
||||
<Select className="w-32" value={status} onChange={(value) => updateUrl({ status: value })} options={[{ label: "全部状态", value: "all" }, { label: "启用中", value: "enabled" }, { label: "历史版本", value: "disabled" }]} />
|
||||
</ListToolbar>
|
||||
<TableSurface>{loading && templates.length === 0 ? <AdminTableSkeleton rows={8} columns={5} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={filtered} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} pagination={{ pageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100] }} scroll={{ x: 1080 }} expandable={{ expandedRowRender: (template) => <pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs leading-5 text-foreground/75">{template.content}</pre> }} />}</TableSurface>
|
||||
<Drawer title={editing ? "编辑分镜提示词" : "新增分镜提示词"} open={drawerOpen} size="min(760px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="name" label="版本名称" rules={[{ required: true, message: "请填写版本名称" }]}><Input placeholder="例如:写实电影分镜 v2" /></Form.Item>
|
||||
<Form.Item label="变量"><div className="flex flex-wrap gap-2">{variables.map((variable) => <Button key={variable.placeholder} size="small" icon={<Copy className="size-3.5" />} onClick={() => insertVariable(variable.placeholder)}>{variable.label}</Button>)}</div></Form.Item>
|
||||
<Form.Item name="content" label="提示词模板" rules={[{ required: true, message: "请填写提示词模板" }]}><Input.TextArea rows={22} placeholder="使用变量占位符,例如 {{剧情}}、{{用户要求}}、{{画布资产}}" /></Form.Item>
|
||||
<Form.Item name="enabled" label="保存后状态"><Select disabled={editing?.enabled} options={[{ label: "保留为未启用版本", value: false }, { label: "保存并设为当前启用版本", value: true }]} /></Form.Item>
|
||||
<TableSurface>{loading && templates.length === 0 ? <AdminTableSkeleton rows={8} columns={6} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={filtered} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} /> }} pagination={{ pageSize: 20, showSizeChanger: true, pageSizeOptions: [20, 50, 100] }} scroll={{ x: 1120 }} expandable={{ expandedRowRender: (template) => <pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted/50 p-3 text-xs leading-5 text-foreground/75">{template.content}</pre> }} />}</TableSurface>
|
||||
|
||||
<Drawer
|
||||
title={baseTemplate ? `基于 v${baseTemplate.version} 新建版本` : "新建提示词版本"}
|
||||
open={drawerOpen}
|
||||
size="min(1180px, 100vw)"
|
||||
onClose={closeDrawer}
|
||||
closable={false}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: 0 } }}
|
||||
extra={<div className="flex gap-2"><Popconfirm disabled={!dirty} title="放弃模板修改?" description="尚未保存的新版本内容将丢失。" okText="放弃修改" cancelText="继续编辑" okButtonProps={{ danger: true }} onConfirm={closeDrawer}><Button disabled={saving} onClick={() => { if (!dirty) closeDrawer(); }}>关闭</Button></Popconfirm><Button type="primary" loading={saving} disabled={!draftOperation || !draftName.trim() || !editorContent.trim()} onClick={() => void save()}>保存版本</Button></div>}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} className="flex min-h-full flex-col">
|
||||
<div className="grid shrink-0 gap-4 border-b border-border p-4 md:grid-cols-3">
|
||||
<Form.Item label="模板类型" className="mb-0">
|
||||
<Select value={draftOperation} disabled={Boolean(baseTemplate)} options={definitions.map((item) => ({ label: `${item.category} · ${item.label}`, value: item.operation }))} onChange={switchOperation} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="版本名称" className="mb-0" rules={[{ required: true, whitespace: true, message: "请填写版本名称" }]}>
|
||||
<Input placeholder="例如:轻喜剧分镜策略 v2" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="保存后状态" className="mb-0">
|
||||
<Select options={[{ label: "保存为历史版本", value: false }, { label: "保存并设为当前启用版本", value: true }]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{pendingOperation ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="当前版本有未保存修改"
|
||||
description={`切换到“${definitionByOperation.get(pendingOperation)?.label || pendingOperation}”会丢弃当前草稿。`}
|
||||
action={<div className="flex gap-2"><Button size="small" onClick={() => setPendingOperation("")}>继续编辑</Button><Button size="small" danger onClick={() => loadDraftBaseline(pendingOperation)}>放弃并切换</Button></div>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-3">
|
||||
<section className="flex min-h-0 flex-col border-b border-border p-4 lg:col-span-2 lg:border-b-0 lg:border-r">
|
||||
<div className="mb-3 flex shrink-0 flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2"><h3 className="text-sm font-semibold">模板内容</h3>{dirty ? <Tag variant="filled" color="warning">未保存</Tag> : null}</div>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/50">这里只编辑创作策略。变量会插入到当前光标位置。</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{selectedDefinition?.variables.map((variable) => (
|
||||
<Button key={variable.placeholder} size="small" icon={<Braces className="size-3.5" />} onClick={() => editorRef.current?.insertText(variable.placeholder)}>
|
||||
插入{variable.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-96 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<PromptCodeEditor ref={editorRef} value={editorContent} ariaLabel="提示词模板内容" onChange={setEditorContent} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="min-h-0 p-4">
|
||||
<Tabs
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "contract",
|
||||
label: "输出契约",
|
||||
children: <div><div className="mb-3 flex items-center gap-2 text-xs font-medium"><ShieldCheck className="size-4" />服务端只读</div><pre className="thin-scrollbar max-h-96 overflow-auto whitespace-pre-wrap text-xs leading-6 text-foreground/65">{selectedDefinition?.outputContract || "请选择模板类型"}</pre></div>,
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "最终结构",
|
||||
children: <div className="space-y-4 text-xs leading-6"><section><div className="mb-2 font-medium text-foreground/80">可编辑创作策略</div><pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap text-foreground/65">{editorContent || "尚未填写"}</pre></section><section className="border-t border-border pt-4"><div className="mb-2 font-medium text-foreground/80">运行时强制追加</div><p className="text-foreground/55">动态项目上下文、用户个性化要求和受保护输出契约。</p></section></div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="mt-4 flex items-start gap-2 border-t border-border pt-4 text-xs leading-5 text-foreground/55"><Info className="mt-0.5 size-3.5 shrink-0" />每次调整都会创建新版本,启用中的版本不会被原地覆盖。</div>
|
||||
</aside>
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</AdminPageFrame>
|
||||
|
||||
@@ -145,7 +145,7 @@ export async function executeImageGeneration({
|
||||
config: { ...generationConfig, count: "1" },
|
||||
referenceImages,
|
||||
signal: controller.signal,
|
||||
metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions },
|
||||
metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation, promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables },
|
||||
onTaskCreated: (task) => bindGenerationTask(targetId, task),
|
||||
});
|
||||
const image = result.images?.[0];
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function executeVideoGeneration({
|
||||
|
||||
startGenerationRequest(videoId, nodeId, nodeId, controller);
|
||||
try {
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoices: generationContext.resolvedCharacterVoices, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: videoId, mode: "video", prompt: effectivePrompt, config: generationConfig, referenceImages: generationContext.referenceImages, referenceVideos: generationContext.referenceVideos, referenceAudios: generationContext.referenceAudios, signal: controller.signal, metadata: { sourceNodeId: nodeId, resolvedCharacterVersions: generationContext.resolvedCharacterVersions, resolvedCharacterVoices: generationContext.resolvedCharacterVoices, promptTemplateOperation: sourceNode?.metadata?.promptTemplateOperation, promptTemplateVariables: sourceNode?.metadata?.promptTemplateVariables, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(videoId, task) });
|
||||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||
const videoSize = fitNodeSize(video.width || spec.width, video.height || spec.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
|
||||
|
||||
@@ -26,7 +26,7 @@ import { CanvasProjectAssetModal } from "@/components/canvas/canvas-project-asse
|
||||
import { CanvasCharacterReferenceNodeContent } from "@/components/canvas/canvas-character-reference-node";
|
||||
import { CanvasCharacterReferenceModal } from "@/components/canvas/canvas-character-reference-modal";
|
||||
import { 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 { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "@/components/canvas/canvas-node-hover-toolbar";
|
||||
import { CanvasNodeAnglePanel } from "@/components/canvas/canvas-node-angle-dialog";
|
||||
import { CanvasTextEditorModal } from "@/components/canvas/canvas-text-editor-modal";
|
||||
@@ -434,7 +434,7 @@ function InfiniteCanvasPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const preset = canvasStylePresets.find((item) => item.id === linkedProjectQuery.data?.project.stylePresetId);
|
||||
const preset = resolveCanvasStylePreset(linkedProjectQuery.data?.project.stylePresetId);
|
||||
if (!projectLoaded || !preset) return;
|
||||
const current = nodesRef.current.find((node) => node.type === CanvasNodeType.Text && node.metadata?.workflowKind === "styleboard");
|
||||
const nextMetadata = {
|
||||
@@ -1259,7 +1259,8 @@ function InfiniteCanvasPage() {
|
||||
onCreateImageNodes={() => createScriptImageNodes(contentNode.id)}
|
||||
onCreateVideoNodes={() => createScriptVideoNodes(contentNode.id)}
|
||||
onGenerateImages={() => void generateScriptImages(contentNode.id, rowIds)}
|
||||
onGenerateVideos={() => workspaceMode === "simple" ? void createAndGenerateScriptVideos(contentNode.id) : void generateScriptVideos(contentNode.id, rowIds)}
|
||||
onGenerateVideos={() => contentNode.metadata?.storyboardVideoInputMode === "keyframe" ? void generateScriptVideos(contentNode.id, rowIds) : void createAndGenerateScriptVideos(contentNode.id, rowIds)}
|
||||
onVideoInputModeChange={(storyboardVideoInputMode) => handleConfigNodeChange(contentNode.id, { storyboardVideoInputMode })}
|
||||
onMergeVideos={() => void mergeVideosByIds(pipeline.successfulVideoNodeIds)}
|
||||
onCreateActionBoards={() => void createScriptActionBoards(contentNode.id)}
|
||||
onRetryBatch={(batchId) => retryFailedBatchItems(contentNode.id, batchId)}
|
||||
@@ -1690,7 +1691,12 @@ function InfiniteCanvasPage() {
|
||||
setNodes((prev) => prev.map((node) => node.id === activeScriptNode.id ? { ...node, metadata: { ...node.metadata, storyboard: { rows: node.metadata?.storyboard?.rows || [], visibleColumns, referenceNodeIds: node.metadata?.storyboard?.referenceNodeIds || [] } } } : node));
|
||||
}}
|
||||
onGenerateImages={(rowIds) => activeScriptNode && void generateScriptImages(activeScriptNode.id, rowIds)}
|
||||
onGenerateVideos={(rowIds) => activeScriptNode && void generateScriptVideos(activeScriptNode.id, rowIds)}
|
||||
onGenerateVideos={(rowIds) => {
|
||||
if (!activeScriptNode) return;
|
||||
if (activeScriptNode.metadata?.storyboardVideoInputMode === "keyframe") void generateScriptVideos(activeScriptNode.id, rowIds);
|
||||
else void createAndGenerateScriptVideos(activeScriptNode.id, rowIds);
|
||||
}}
|
||||
onVideoInputModeChange={(storyboardVideoInputMode) => activeScriptNode && handleConfigNodeChange(activeScriptNode.id, { storyboardVideoInputMode })}
|
||||
/>
|
||||
|
||||
{directorNodeId && activeDirectorScene ? (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { nanoid } from "nanoid";
|
||||
|
||||
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
|
||||
import { getNodeSpec } from "@/constant/canvas";
|
||||
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
|
||||
import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardPromptTemplateMetadata, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain";
|
||||
import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { isDrawingEngineAvailable, type CanvasDrawingEngine } from "@/lib/canvas/canvas-drawing-engine";
|
||||
import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
|
||||
@@ -129,7 +129,7 @@ export function useCanvasConnectionController({
|
||||
: type === CanvasNodeType.Script && scriptPrompt
|
||||
? { prompt: scriptPrompt, composerContent: scriptPrompt }
|
||||
: type === CanvasNodeType.Video && storyboardRow
|
||||
? { prompt: videoPrompt, composerContent: videoPrompt, generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
|
||||
? { prompt: videoPrompt, composerContent: videoPrompt, ...storyboardPromptTemplateMetadata(storyboardRow, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE }
|
||||
: undefined;
|
||||
const sourceNodeForQuickCreate = pending.quick ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined;
|
||||
const spec = getNodeSpec(type);
|
||||
|
||||
@@ -148,7 +148,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
|
||||
}
|
||||
if (node.type === CanvasNodeType.Video) {
|
||||
const videoGenerationMetadata = buildVideoGenerationMetadata(node, videoContext);
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoices: context?.resolvedCharacterVoices || [], ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "video", prompt, config: generationConfig, referenceImages: videoContext?.referenceImages || [], referenceVideos: videoContext?.referenceVideos || [], referenceAudios: videoContext?.referenceAudios || [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], resolvedCharacterVoices: context?.resolvedCharacterVoices || [], promptTemplateOperation: node.metadata?.promptTemplateOperation, promptTemplateVariables: node.metadata?.promptTemplateVariables, ...videoGenerationMetadata }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
|
||||
if (!result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||
const video = await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||
const videoSize = fitNodeSize(video.width || node.width, video.height || node.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
|
||||
@@ -187,7 +187,7 @@ export function useCanvasGenerationRetry({ projectId, domainProjectId, addedSkil
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt, config: generationConfig, referenceImages: useReferenceImages ? retryImages : [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [] }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: node.id, mode: "image", prompt, config: generationConfig, referenceImages: useReferenceImages ? retryImages : [], signal: controller.signal, metadata: { retry: true, sourceNodeId: sourceNode.id, resolvedCharacterVersions: context?.resolvedCharacterVersions || [], promptTemplateOperation: node.metadata?.promptTemplateOperation, promptTemplateVariables: node.metadata?.promptTemplateVariables }, onTaskCreated: (task) => bindGenerationTask(node.id, task) });
|
||||
const image = result.images?.[0];
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploadedImage = await uploadImage(image.dataUrl);
|
||||
|
||||
@@ -132,7 +132,10 @@ export function useCanvasNodeEditor({
|
||||
const metadata = moderationFailure && prompt !== previousPrompt
|
||||
? resetGenerationTaskMetadata(node.metadata, node.metadata?.content ? "success" : "idle")
|
||||
: node.metadata;
|
||||
return { ...node, metadata: hasExistingContent ? { ...metadata, composerContent: prompt } : { ...metadata, prompt, composerContent: prompt } };
|
||||
const promptTemplateMetadata = prompt !== previousPrompt && metadata.promptTemplateOperation
|
||||
? { promptTemplateOperation: undefined, promptTemplateVariables: undefined }
|
||||
: {};
|
||||
return { ...node, metadata: hasExistingContent ? { ...metadata, ...promptTemplateMetadata, composerContent: prompt } : { ...metadata, ...promptTemplateMetadata, prompt, composerContent: prompt } };
|
||||
}));
|
||||
}, [setNodes]);
|
||||
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
createStoryboardRow,
|
||||
expandStoryboardTextMentions,
|
||||
storyboardRowsFromTask,
|
||||
storyboardPromptTemplateMetadata,
|
||||
} from "@/lib/canvas/canvas-project-domain";
|
||||
import { buildNodeMentionReferences } from "@/lib/canvas/canvas-resource-references";
|
||||
import { resolveStoryboardGenerationContext } from "@/lib/canvas/canvas-storyboard-context";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { createGenerationTask, waitForGenerationTask } from "@/services/api/task-center";
|
||||
@@ -86,18 +88,20 @@ export function useCanvasStoryboard({
|
||||
|
||||
const replaceScriptRows = useCallback((nodeId: string, rows: StoryboardRow[]) => {
|
||||
const rowIds = new Set(rows.map((row) => `row:${row.id}`));
|
||||
const previousRows = new Map((nodesRef.current.find((node) => node.id === nodeId)?.metadata?.storyboard?.rows || []).map((row) => [row.id, row]));
|
||||
const nextRows = rows.map((row) => invalidateEditedPromptVariables(previousRows.get(row.id), row));
|
||||
setConnections((current) => current
|
||||
.filter((connection) => connection.fromNodeId !== nodeId || !connection.fromHandleId || rowIds.has(connection.fromHandleId))
|
||||
.filter((connection) => connection.toNodeId !== nodeId || !connection.toHandleId || rowIds.has(connection.toHandleId)));
|
||||
updateScriptRows(nodeId, () => rows);
|
||||
}, [setConnections, updateScriptRows]);
|
||||
updateScriptRows(nodeId, () => nextRows);
|
||||
}, [nodesRef, setConnections, updateScriptRows]);
|
||||
|
||||
const addScriptRow = useCallback((nodeId: string) => {
|
||||
updateScriptRows(nodeId, (rows) => [...rows, createStoryboardRow(rows.length + 1)]);
|
||||
}, [updateScriptRows]);
|
||||
|
||||
const updateScriptRow = useCallback((nodeId: string, rowId: string, patch: Partial<StoryboardRow>) => {
|
||||
updateScriptRows(nodeId, (rows) => rows.map((row) => row.id === rowId ? { ...row, ...patch } : row));
|
||||
updateScriptRows(nodeId, (rows) => rows.map((row) => row.id === rowId ? invalidateEditedPromptVariables(row, { ...row, ...patch }) : row));
|
||||
}, [updateScriptRows]);
|
||||
|
||||
const removeScriptRow = useCallback((nodeId: string, rowId: string) => {
|
||||
@@ -109,6 +113,13 @@ export function useCanvasStoryboard({
|
||||
const generateScriptRows = useCallback(async (nodeId: string, prompt: string) => {
|
||||
const scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
|
||||
if (!scriptNode || !prompt.trim()) return;
|
||||
let storyboardContext: ReturnType<typeof resolveStoryboardGenerationContext>;
|
||||
try {
|
||||
storyboardContext = resolveStoryboardGenerationContext(nodesRef.current);
|
||||
} catch (error) {
|
||||
message.warning(error instanceof Error ? error.message : "分镜上下文不完整");
|
||||
return;
|
||||
}
|
||||
const shotDuration = scriptNode.metadata?.storyboardShotDuration || "auto";
|
||||
const shotDurationSeconds = shotDuration === "auto" ? 0 : Number(shotDuration);
|
||||
const shotCount = scriptNode.metadata?.storyboardShotCount || "auto";
|
||||
@@ -130,6 +141,8 @@ export function useCanvasStoryboard({
|
||||
input: {
|
||||
canvasSnapshot: { nodes: nodesRef.current, connections: connectionsRef.current },
|
||||
requirements: "输出可直接编辑并用于批量生成图片和视频的分镜表。",
|
||||
projectStyle: storyboardContext.projectStyle,
|
||||
characters: storyboardContext.characters,
|
||||
shotDurationSeconds,
|
||||
shotCount: requestedShotCount,
|
||||
config: backendProviderConfig(generationConfig),
|
||||
@@ -181,8 +194,8 @@ export function useCanvasStoryboard({
|
||||
const existing = row.imageNodeId ? nextNodes.find((node) => node.id === row.imageNodeId && node.type === CanvasNodeType.Image) : undefined;
|
||||
const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata);
|
||||
const imageNode = existing
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber } }
|
||||
: createCanvasNode(CanvasNodeType.Image, { x: startX + imageSpec.width / 2, y: scriptNode.position.y + index * (imageSpec.height + 36) + imageSpec.height / 2 }, { prompt, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber, status: NODE_STATUS_IDLE });
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber } }
|
||||
: createCanvasNode(CanvasNodeType.Image, { x: startX + imageSpec.width / 2, y: scriptNode.position.y + index * (imageSpec.height + 36) + imageSpec.height / 2 }, { prompt, ...storyboardPromptTemplateMetadata(row, "image"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 分镜图`, shotIndex: row.shotNumber, status: NODE_STATUS_IDLE });
|
||||
if (!existing) {
|
||||
imageNode.title = `镜头 ${row.shotNumber} · 分镜图`;
|
||||
nextNodes.push(imageNode);
|
||||
@@ -194,6 +207,7 @@ export function useCanvasStoryboard({
|
||||
const referenceIds = new Set([
|
||||
...(scriptNode.metadata?.storyboard?.referenceNodeIds || []),
|
||||
...(row.referenceNodeIds || []),
|
||||
...characterReferenceNodeIds(row, nextNodes),
|
||||
...nextConnections.filter((connection) => connection.toNodeId === scriptNode.id && connection.toHandleId === `row:${row.id}`).map((connection) => connection.fromNodeId),
|
||||
]);
|
||||
referenceIds.forEach((referenceId) => {
|
||||
@@ -272,14 +286,20 @@ export function useCanvasStoryboard({
|
||||
if (existingIndex >= 0) {
|
||||
const existing = nextNodes[existingIndex];
|
||||
const existingMetadata = existing.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing.metadata);
|
||||
nextNodes[existingIndex] = { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, seconds: String(row.durationSeconds), shotIndex: row.shotNumber, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, generationMode: "video", videoEditOperation: existing.metadata?.videoEditOperation || "text_to_video" } };
|
||||
nextNodes[existingIndex] = { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), seconds: String(row.durationSeconds), shotIndex: row.shotNumber, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, generationMode: "video", videoEditOperation: existing.metadata?.videoEditOperation || "text_to_video" } };
|
||||
storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false).forEach((referenceId) => {
|
||||
if (!nextConnections.some((connection) => connection.fromNodeId === referenceId && connection.toNodeId === existing.id)) nextConnections.push({ id: nanoid(), fromNodeId: referenceId, toNodeId: existing.id });
|
||||
});
|
||||
videoNodeByRowId.set(row.id, existing.id);
|
||||
return;
|
||||
}
|
||||
const videoNode = createCanvasNode(CanvasNodeType.Video, { x: startLeft + videoSpec.width / 2, y: scriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, composerContent: prompt, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "text_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
const videoNode = createCanvasNode(CanvasNodeType.Video, { x: startLeft + videoSpec.width / 2, y: scriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "text_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
videoNode.title = `镜头 ${row.shotNumber} · 视频`;
|
||||
nextNodes.push(videoNode);
|
||||
nextConnections.push({ id: nanoid(), fromNodeId: scriptNode.id, toNodeId: videoNode.id, fromHandleId: `row:${row.id}` });
|
||||
storyboardRowReferenceNodeIds(scriptNode, row, nextNodes, nextConnections, false).forEach((referenceId) => {
|
||||
if (!nextConnections.some((connection) => connection.fromNodeId === referenceId && connection.toNodeId === videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: referenceId, toNodeId: videoNode.id });
|
||||
});
|
||||
videoNodeByRowId.set(row.id, videoNode.id);
|
||||
createdCount += 1;
|
||||
});
|
||||
@@ -302,14 +322,15 @@ export function useCanvasStoryboard({
|
||||
if (!silent) message.success(createdCount ? `已创建 ${createdCount} 个视频节点` : "已同步现有视频节点的提示词");
|
||||
}, [connectionsRef, message, nodesRef, setConnections, setNodes]);
|
||||
|
||||
const createAndGenerateScriptVideos = useCallback(async (nodeId: string) => {
|
||||
const createAndGenerateScriptVideos = useCallback(async (nodeId: string, rowIds?: string[]) => {
|
||||
const videoModel = effectiveConfig.videoModel || effectiveConfig.model;
|
||||
if (!isAiConfigReady(effectiveConfig, videoModel)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
let scriptNode = nodesRef.current.find((node) => node.id === nodeId && node.type === CanvasNodeType.Script);
|
||||
const rows = scriptNode?.metadata?.storyboard?.rows || [];
|
||||
const allRows = scriptNode?.metadata?.storyboard?.rows || [];
|
||||
const rows = rowIds?.length ? allRows.filter((row) => rowIds.includes(row.id)) : allRows;
|
||||
const describedRows = rows.filter((row) => Boolean((row.videoMotionPrompt || row.plotDescription).trim()));
|
||||
const activeNodeIds = scriptNode ? activeGenerationBatchNodeIds(scriptNode, "storyboard_video") : new Set<string>();
|
||||
const targetRows = describedRows.filter((row) => {
|
||||
@@ -332,19 +353,17 @@ export function useCanvasStoryboard({
|
||||
if (!videoNode || videoNode.metadata?.content) return [];
|
||||
const prompt = (currentRow.videoMotionPrompt || currentRow.plotDescription).trim();
|
||||
if (!prompt) return [];
|
||||
const imageNode = currentRow.imageNodeId ? nodesRef.current.find((node) => node.id === currentRow.imageNodeId && node.type === CanvasNodeType.Image && node.metadata?.content) : undefined;
|
||||
return [{ row: currentRow, videoNode, imageNode, prompt }];
|
||||
return [{ row: currentRow, videoNode, prompt }];
|
||||
});
|
||||
const targetById = new Map(targets.map((target) => [target.videoNode.id, target]));
|
||||
const nextNodes = nodesRef.current.map((node) => {
|
||||
const target = targetById.get(node.id);
|
||||
return target ? { ...node, metadata: { ...node.metadata, prompt: target.prompt, composerContent: target.prompt, generationMode: "video" as const, videoEditOperation: target.imageNode ? "image_to_video" as const : "text_to_video" as const } } : node;
|
||||
});
|
||||
const nextConnections = [...connectionsRef.current];
|
||||
targets.forEach((target) => {
|
||||
const imageNode = target.imageNode;
|
||||
if (imageNode && !nextConnections.some((connection) => connection.fromNodeId === imageNode.id && connection.toNodeId === target.videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: imageNode.id, toNodeId: target.videoNode.id });
|
||||
return target ? { ...node, metadata: { ...node.metadata, prompt: target.prompt, composerContent: target.prompt, ...storyboardPromptTemplateMetadata(target.row, "video"), generationMode: "video" as const, videoEditOperation: "text_to_video" as const, videoStartFrameNodeId: undefined } } : node;
|
||||
});
|
||||
const dedicatedFirstFrameConnections = new Set(targets
|
||||
.filter((target) => Boolean(target.row.imageNodeId))
|
||||
.map((target) => `${target.row.imageNodeId}:${target.videoNode.id}`));
|
||||
const nextConnections = connectionsRef.current.filter((connection) => !dedicatedFirstFrameConnections.has(`${connection.fromNodeId}:${connection.toNodeId}`));
|
||||
nodesRef.current = nextNodes;
|
||||
connectionsRef.current = nextConnections;
|
||||
setNodes(nextNodes);
|
||||
@@ -405,8 +424,8 @@ export function useCanvasStoryboard({
|
||||
const rows = (scriptNode?.metadata?.storyboard?.rows || []).filter((row) => rowIds.includes(row.id));
|
||||
if (!scriptNode || !rows.length) return;
|
||||
const readyRows = rows.filter((row) => row.imageNodeId && nodesRef.current.some((node) => node.id === row.imageNodeId && node.type === CanvasNodeType.Image && node.metadata?.content));
|
||||
if (!readyRows.length) return message.warning("请先生成选中镜头的分镜图");
|
||||
if (readyRows.length !== rows.length) message.warning(`${rows.length - readyRows.length} 个镜头没有可用分镜图,已跳过`);
|
||||
if (!readyRows.length) return message.warning("请先生成并检查选中镜头的首帧");
|
||||
if (readyRows.length !== rows.length) return message.warning(`${rows.length - readyRows.length} 个选中镜头还没有可用首帧,请全部生成并检查后再确认`);
|
||||
const videoModel = effectiveConfig.videoModel || effectiveConfig.model;
|
||||
if (!isAiConfigReady(effectiveConfig, videoModel)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
@@ -434,8 +453,8 @@ export function useCanvasStoryboard({
|
||||
const existing = row.videoNodeId ? nextNodes.find((node) => node.id === row.videoNodeId && node.type === CanvasNodeType.Video) : undefined;
|
||||
const existingMetadata = existing?.metadata?.content ? existing.metadata : resetGenerationTaskMetadata(existing?.metadata);
|
||||
const videoNode = existing
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, seconds: String(row.durationSeconds) } }
|
||||
: createCanvasNode(CanvasNodeType.Video, { x: startX, y: currentScriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "image_to_video", status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
? { ...existing, metadata: { ...existingMetadata, prompt, composerContent: prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot" as const, workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video" as const, videoEditOperation: "image_to_video" as const, videoStartFrameNodeId: row.imageNodeId, seconds: String(row.durationSeconds) } }
|
||||
: createCanvasNode(CanvasNodeType.Video, { x: startX, y: currentScriptNode.position.y + index * (videoSpec.height + 36) + videoSpec.height / 2 }, { prompt, ...storyboardPromptTemplateMetadata(row, "video"), workflowKind: "shot", workflowTitle: `镜头 ${row.shotNumber} 视频`, shotIndex: row.shotNumber, generationMode: "video", videoEditOperation: "image_to_video", videoStartFrameNodeId: row.imageNodeId, status: NODE_STATUS_IDLE, seconds: String(row.durationSeconds) });
|
||||
if (!existing) {
|
||||
videoNode.title = `镜头 ${row.shotNumber} · 视频`;
|
||||
nextNodes.push(videoNode);
|
||||
@@ -444,7 +463,9 @@ export function useCanvasStoryboard({
|
||||
const existingIndex = nextNodes.findIndex((node) => node.id === existing.id);
|
||||
nextNodes[existingIndex] = videoNode;
|
||||
}
|
||||
if (!nextConnections.some((connection) => connection.fromNodeId === row.imageNodeId && connection.toNodeId === videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: row.imageNodeId!, toNodeId: videoNode.id });
|
||||
storyboardRowReferenceNodeIds(currentScriptNode, row, nextNodes, nextConnections, true).forEach((referenceId) => {
|
||||
if (!nextConnections.some((connection) => connection.fromNodeId === referenceId && connection.toNodeId === videoNode.id)) nextConnections.push({ id: nanoid(), fromNodeId: referenceId, toNodeId: videoNode.id });
|
||||
});
|
||||
targets.push({ row, node: videoNode, prompt });
|
||||
});
|
||||
nodesRef.current = nextNodes;
|
||||
@@ -470,6 +491,35 @@ export function useCanvasStoryboard({
|
||||
};
|
||||
}
|
||||
|
||||
function characterReferenceNodeIds(row: StoryboardRow, nodes: CanvasNodeData[]) {
|
||||
const assetIds = new Set(row.characters.map((character) => character.characterAssetId).filter((assetId): assetId is string => Boolean(assetId)));
|
||||
return nodes
|
||||
.filter((node) => node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId) && assetIds.has(node.metadata.characterAssetId!))
|
||||
.map((node) => node.id);
|
||||
}
|
||||
|
||||
function invalidateEditedPromptVariables(previous: StoryboardRow | undefined, next: StoryboardRow) {
|
||||
if (!previous) return next;
|
||||
return {
|
||||
...next,
|
||||
imagePromptTemplateVariables: next.imageGenerationPrompt === previous.imageGenerationPrompt ? next.imagePromptTemplateVariables : undefined,
|
||||
videoPromptTemplateVariables: next.videoMotionPrompt === previous.videoMotionPrompt ? next.videoPromptTemplateVariables : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function storyboardRowReferenceNodeIds(scriptNode: CanvasNodeData, row: StoryboardRow, nodes: CanvasNodeData[], connections: CanvasConnection[], includeFirstFrame: boolean) {
|
||||
const referenceIds = new Set([
|
||||
...(scriptNode.metadata?.storyboard?.referenceNodeIds || []),
|
||||
...(row.referenceNodeIds || []),
|
||||
...characterReferenceNodeIds(row, nodes),
|
||||
...connections.filter((connection) => connection.toNodeId === scriptNode.id && connection.toHandleId === `row:${row.id}`).map((connection) => connection.fromNodeId),
|
||||
...(includeFirstFrame && row.imageNodeId ? [row.imageNodeId] : []),
|
||||
]);
|
||||
if (!includeFirstFrame && row.imageNodeId) referenceIds.delete(row.imageNodeId);
|
||||
referenceIds.delete(scriptNode.id);
|
||||
return Array.from(referenceIds);
|
||||
}
|
||||
|
||||
function activeGenerationBatchNodeIds(node: CanvasNodeData, mode: CanvasGenerationBatchMode) {
|
||||
return new Set((node.metadata?.generationBatches || [])
|
||||
.filter((batch) => batch.mode === mode)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { App, Button, Form, Input, InputNumber, Popconfirm, Select, Tag, Tooltip } from "antd";
|
||||
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, Info, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, Info, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
@@ -26,13 +26,15 @@ import {
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ChannelModelSettings } from "./channel-video-pricing";
|
||||
import { ModelDefaultGrid } from "./model-default-grid";
|
||||
import { PromptPreferencesPane } from "./prompt-preferences-pane";
|
||||
|
||||
type ConfigSectionKey = "channels" | "models" | "preferences" | "storage";
|
||||
type ConfigSectionKey = "channels" | "models" | "preferences" | "prompts" | "storage";
|
||||
|
||||
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
|
||||
{ key: "channels", label: "自定义渠道", description: "连接你自己的模型服务", icon: <RadioTower className="size-4" /> },
|
||||
{ key: "models", label: "模型选择", description: "按领域选择默认模型", icon: <Boxes className="size-4" /> },
|
||||
{ key: "preferences", label: "生成偏好", description: "画布、音频与系统提示词", icon: <SlidersHorizontal className="size-4" /> },
|
||||
{ key: "preferences", label: "生成偏好", description: "画布、视频与音频默认值", icon: <SlidersHorizontal className="size-4" /> },
|
||||
{ key: "prompts", label: "提示词偏好", description: "按任务定制平台模板", icon: <MessageSquareText className="size-4" /> },
|
||||
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
];
|
||||
|
||||
@@ -279,7 +281,7 @@ export default function SettingsPage() {
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 md:px-6 md:py-5">
|
||||
<div className="mx-auto w-full max-w-[1180px]">
|
||||
<div className={activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-[1180px]"}>
|
||||
{([
|
||||
{
|
||||
key: "channels",
|
||||
@@ -489,20 +491,22 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
|
||||
<section className="pt-6">
|
||||
<div className="mb-4"><h3 className="text-sm font-semibold">默认指令</h3><p className="mt-1 text-xs text-foreground/55">在未单独填写时附加到对应生成请求。</p></div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Form.Item label="音频指令" className="mb-0">
|
||||
<div className="mb-4"><h3 className="text-sm font-semibold">音频指令</h3><p className="mt-1 text-xs text-foreground/55">在音频节点没有单独填写时使用。</p></div>
|
||||
<div className="max-w-2xl">
|
||||
<Form.Item label="默认音频指令" className="mb-0">
|
||||
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="系统提示词" className="mb-0">
|
||||
<Input.TextArea rows={5} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
</Form>
|
||||
</SettingsPane>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prompts",
|
||||
label: "提示词偏好",
|
||||
children: <SettingsPane fill><PromptPreferencesPane /></SettingsPane>,
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
label: (
|
||||
@@ -526,8 +530,8 @@ export default function SettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPane({ children }: { children: ReactNode }) {
|
||||
return <div>{children}</div>;
|
||||
function SettingsPane({ children, fill = false }: { children: ReactNode; fill?: boolean }) {
|
||||
return <div className={fill ? "h-full" : undefined}>{children}</div>;
|
||||
}
|
||||
|
||||
function ChannelStatus({ channel }: { channel: ModelChannel }) {
|
||||
|
||||
226
web/src/pages/settings/prompt-preferences-pane.tsx
Normal file
226
web/src/pages/settings/prompt-preferences-pane.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Alert, App, Button, Input, Segmented, Select, Skeleton, Tabs, Tag } from "antd";
|
||||
import { RotateCcw, Save, ShieldCheck, Undo2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { PromptCodeEditor } from "@/components/prompt/prompt-code-editor";
|
||||
import {
|
||||
listUserPromptPreferences,
|
||||
resetUserPromptCustomization,
|
||||
updateUserPromptCustomization,
|
||||
type UserPromptCustomization,
|
||||
type UserPromptPreference,
|
||||
} from "@/services/api/auth";
|
||||
|
||||
type CustomizationMode = UserPromptCustomization["mode"];
|
||||
|
||||
const modeOptions = [
|
||||
{ label: "跟随平台", value: "inherit" },
|
||||
{ label: "追加要求", value: "append" },
|
||||
{ label: "高级改写", value: "rewrite" },
|
||||
];
|
||||
|
||||
export function PromptPreferencesPane() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [preferences, setPreferences] = useState<UserPromptPreference[]>([]);
|
||||
const [selectedOperation, setSelectedOperation] = useState("");
|
||||
const [mode, setMode] = useState<CustomizationMode>("inherit");
|
||||
const [appendContent, setAppendContent] = useState("");
|
||||
const [rewriteContent, setRewriteContent] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const reload = async (preferredOperation?: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await listUserPromptPreferences();
|
||||
setPreferences(result.preferences);
|
||||
setSelectedOperation((current) => preferredOperation || current || result.preferences[0]?.definition.operation || "");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取提示词偏好失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { void reload(); }, []);
|
||||
|
||||
const selected = useMemo(() => preferences.find((item) => item.definition.operation === selectedOperation), [preferences, selectedOperation]);
|
||||
const savedMode = selected?.customization?.mode || "inherit";
|
||||
const savedAppendContent = savedMode === "append" ? selected?.customization?.content || "" : "";
|
||||
const savedRewriteContent = savedMode === "rewrite" ? selected?.customization?.content || "" : selected?.template?.content || "";
|
||||
const activeContent = mode === "append" ? appendContent : mode === "rewrite" ? rewriteContent : "";
|
||||
const savedActiveContent = savedMode === "append" ? savedAppendContent : savedMode === "rewrite" ? savedRewriteContent : "";
|
||||
const dirty = mode !== savedMode || activeContent !== savedActiveContent;
|
||||
|
||||
const restoreDraft = (preference = selected) => {
|
||||
const customization = preference?.customization;
|
||||
setMode(customization?.mode || "inherit");
|
||||
setAppendContent(customization?.mode === "append" ? customization.content : "");
|
||||
setRewriteContent(customization?.mode === "rewrite" ? customization.content : preference?.template?.content || "");
|
||||
};
|
||||
|
||||
useEffect(() => { restoreDraft(selected); }, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return undefined;
|
||||
const preventUnload = (event: BeforeUnloadEvent) => event.preventDefault();
|
||||
window.addEventListener("beforeunload", preventUnload);
|
||||
return () => window.removeEventListener("beforeunload", preventUnload);
|
||||
}, [dirty]);
|
||||
|
||||
const selectOperation = (operation: string) => {
|
||||
if (operation === selectedOperation) return;
|
||||
if (!dirty) {
|
||||
setSelectedOperation(operation);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "切换模板并放弃修改?",
|
||||
content: "当前模板还有未保存内容。切换后这些修改将丢失。",
|
||||
okText: "放弃并切换",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => setSelectedOperation(operation),
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!selected) return;
|
||||
const content = mode === "append" ? appendContent : mode === "rewrite" ? rewriteContent : "";
|
||||
if (mode !== "inherit" && !content.trim()) {
|
||||
message.warning("请填写个人提示词内容");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateUserPromptCustomization(selected.definition.operation, { mode, content });
|
||||
await reload(selected.definition.operation);
|
||||
message.success("提示词偏好已保存");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存提示词偏好失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
if (!selected) return;
|
||||
modal.confirm({
|
||||
title: "恢复平台模板?",
|
||||
content: `将删除“${selected.definition.label}”的个人定制,后续自动跟随平台版本。`,
|
||||
okText: "恢复平台模板",
|
||||
cancelText: "取消",
|
||||
onOk: async () => {
|
||||
try {
|
||||
await resetUserPromptCustomization(selected.definition.operation);
|
||||
await reload(selected.definition.operation);
|
||||
message.success("已恢复平台模板");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "恢复平台模板失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (loading && preferences.length === 0) return <Skeleton active paragraph={{ rows: 10 }} />;
|
||||
if (!selected) return <div className="py-16 text-center text-sm text-foreground/50">暂无可配置的提示词模板</div>;
|
||||
|
||||
const templateContent = selected.template?.content || "当前没有启用的平台模板";
|
||||
const previewCreative = mode === "inherit" ? templateContent : mode === "append" ? `${templateContent}\n\n【用户个性化创作要求】\n${appendContent}` : rewriteContent;
|
||||
const outputLabel = selected.definition.outputType === "json" ? selected.definition.schemaKey || "JSON" : "文本";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="shrink-0 border-b border-border pb-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-foreground/55" htmlFor="prompt-template-select">提示词模板</label>
|
||||
<Select
|
||||
id="prompt-template-select"
|
||||
className="w-full max-w-md"
|
||||
value={selectedOperation}
|
||||
onChange={selectOperation}
|
||||
options={preferences.map((item) => ({
|
||||
value: item.definition.operation,
|
||||
label: `${item.definition.category} · ${item.definition.label}${item.customization && item.customization.mode !== "inherit" ? " · 已定制" : ""}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button icon={<Undo2 className="size-4" />} disabled={!dirty || saving} onClick={() => restoreDraft()}>撤销修改</Button>
|
||||
<Button icon={<RotateCcw className="size-4" />} disabled={!selected.customization || saving} onClick={reset}>恢复平台</Button>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty} onClick={() => void save()}>保存更改</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold">{selected.definition.label}</h2>
|
||||
<Tag variant="filled">平台 v{selected.template?.version || "--"}</Tag>
|
||||
<Tag variant="filled">{outputLabel}</Tag>
|
||||
{dirty ? <Tag variant="filled" color="warning">未保存</Tag> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/55">{selected.definition.description}</p>
|
||||
</div>
|
||||
<Segmented value={mode} options={modeOptions} onChange={(value) => setMode(value as CustomizationMode)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{selected.outdated ? <Alert className="mt-4" type="warning" showIcon title="平台模板已更新" description="当前高级改写基于旧版本。可以保留现有改写,或恢复平台后再基于新版本调整。" /> : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 pt-4 lg:grid-cols-3">
|
||||
<section className="flex min-h-0 flex-col lg:col-span-2">
|
||||
<div className="mb-3 shrink-0">
|
||||
<h3 className="text-sm font-semibold">{mode === "inherit" ? "当前平台模板" : mode === "append" ? "追加个人要求" : "改写创作策略"}</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-foreground/50">
|
||||
{mode === "inherit" ? "平台升级后自动使用新版本。" : mode === "append" ? "内容追加在平台策略之后,仍会自动继承平台升级。" : "只替换创作策略;动态项目数据和输出契约仍由服务端强制注入。"}
|
||||
</p>
|
||||
</div>
|
||||
{mode === "append" ? (
|
||||
<Input.TextArea
|
||||
className="min-h-96 resize-none"
|
||||
value={appendContent}
|
||||
maxLength={12000}
|
||||
showCount
|
||||
placeholder="例如:仙侠项目采用明亮、宏大、高清的休闲剧质感;避免阴森恐怖色调,人物表演自然、轻松。"
|
||||
onChange={(event) => setAppendContent(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-96 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<PromptCodeEditor
|
||||
value={mode === "inherit" ? templateContent : rewriteContent}
|
||||
readOnly={mode === "inherit"}
|
||||
ariaLabel={mode === "inherit" ? "平台提示词模板" : "个人提示词改写"}
|
||||
onChange={mode === "rewrite" ? setRewriteContent : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="min-h-0 border-t border-border pt-4 lg:border-l lg:border-t-0 lg:pl-4 lg:pt-0">
|
||||
<Tabs
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: "baseline",
|
||||
label: "平台基线",
|
||||
children: <pre className="thin-scrollbar max-h-96 overflow-auto whitespace-pre-wrap text-xs leading-6 text-foreground/65">{templateContent}</pre>,
|
||||
},
|
||||
{
|
||||
key: "contract",
|
||||
label: "输出契约",
|
||||
children: <div><div className="mb-3 flex items-center gap-2 text-xs font-medium"><ShieldCheck className="size-4" />服务端只读</div><pre className="thin-scrollbar max-h-96 overflow-auto whitespace-pre-wrap text-xs leading-6 text-foreground/65">{selected.definition.outputContract}</pre></div>,
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "最终结构",
|
||||
children: <div className="space-y-4 text-xs leading-6"><section><div className="mb-2 font-medium text-foreground/80">创作策略</div><pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap text-foreground/65">{previewCreative || "尚未填写"}</pre></section><section className="border-t border-border pt-4"><div className="mb-2 font-medium text-foreground/80">运行时强制追加</div><p className="text-foreground/55">当前剧情、项目画风、当前角色版本、画布资产与受保护输出契约。</p></section></div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,8 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <AnalyticsPage /> },
|
||||
{ path: "users", element: <UsersPage /> },
|
||||
{ path: "channels", element: <ChannelsPage /> },
|
||||
{ path: "storyboard-prompts", element: <StoryboardPromptsPage /> },
|
||||
{ path: "prompt-templates", element: <StoryboardPromptsPage /> },
|
||||
{ path: "storyboard-prompts", element: <Navigate to="/admin/prompt-templates" replace /> },
|
||||
{ path: "announcements", element: <AnnouncementsPage /> },
|
||||
{ path: "credit-operations", element: <CreditOperationsPage /> },
|
||||
{ path: "redemption-codes", element: <RedemptionCodesPage /> },
|
||||
|
||||
@@ -201,21 +201,51 @@ export type ModelPricing = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type StoryboardPromptTemplate = {
|
||||
export type PromptTemplate = {
|
||||
id: string;
|
||||
operation: string;
|
||||
name: string;
|
||||
version: number;
|
||||
content: string;
|
||||
outputType: "json" | "text";
|
||||
enabled: boolean;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type StoryboardPromptVariable = {
|
||||
export type PromptTemplateVariable = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
};
|
||||
|
||||
export type PromptOperationDefinition = {
|
||||
operation: string;
|
||||
label: string;
|
||||
category: string;
|
||||
description: string;
|
||||
outputType: "json" | "text";
|
||||
schemaKey?: string;
|
||||
variables: PromptTemplateVariable[];
|
||||
outputContract: string;
|
||||
};
|
||||
|
||||
export type UserPromptCustomization = {
|
||||
id: string;
|
||||
operation: string;
|
||||
mode: "inherit" | "append" | "rewrite";
|
||||
content: string;
|
||||
baseTemplateId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type UserPromptPreference = {
|
||||
definition: PromptOperationDefinition;
|
||||
template: PromptTemplate | null;
|
||||
customization?: UserPromptCustomization;
|
||||
outdated: boolean;
|
||||
};
|
||||
|
||||
export type AdminOSSSetting = {
|
||||
enabled: boolean;
|
||||
provider: "aliyun";
|
||||
@@ -397,20 +427,32 @@ export function deleteAdminChannel(id: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/channels/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function listAdminStoryboardPromptTemplates() {
|
||||
return request<{ templates: StoryboardPromptTemplate[]; variables: StoryboardPromptVariable[] }>(api.get("/admin/storyboard-prompts"));
|
||||
export function listAdminPromptTemplates() {
|
||||
return request<{ templates: PromptTemplate[]; definitions: PromptOperationDefinition[] }>(api.get("/admin/prompt-templates"));
|
||||
}
|
||||
|
||||
export function createAdminStoryboardPromptTemplate(input: Partial<Pick<StoryboardPromptTemplate, "name" | "content" | "enabled">>) {
|
||||
return request<{ template: StoryboardPromptTemplate }>(api.post("/admin/storyboard-prompts", input));
|
||||
export function createAdminPromptTemplate(input: Pick<PromptTemplate, "operation" | "name" | "content"> & { enabled?: boolean }) {
|
||||
return request<{ template: PromptTemplate }>(api.post("/admin/prompt-templates", input));
|
||||
}
|
||||
|
||||
export function updateAdminStoryboardPromptTemplate(id: string, input: Partial<Pick<StoryboardPromptTemplate, "name" | "content" | "enabled">>) {
|
||||
return request<{ template: StoryboardPromptTemplate }>(api.patch(`/admin/storyboard-prompts/${encodeURIComponent(id)}`, input));
|
||||
export function updateAdminPromptTemplate(id: string, input: Pick<PromptTemplate, "operation" | "name" | "content"> & { enabled?: boolean }) {
|
||||
return request<{ template: PromptTemplate }>(api.patch(`/admin/prompt-templates/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
export function deleteAdminStoryboardPromptTemplate(id: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/storyboard-prompts/${encodeURIComponent(id)}`));
|
||||
export function deleteAdminPromptTemplate(id: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/admin/prompt-templates/${encodeURIComponent(id)}`));
|
||||
}
|
||||
|
||||
export function listUserPromptPreferences() {
|
||||
return request<{ preferences: UserPromptPreference[] }>(api.get("/settings/prompt-templates"));
|
||||
}
|
||||
|
||||
export function updateUserPromptCustomization(operation: string, input: Pick<UserPromptCustomization, "mode" | "content">) {
|
||||
return request<{ customization: UserPromptCustomization }>(api.patch(`/settings/prompt-templates/${encodeURIComponent(operation)}`, input));
|
||||
}
|
||||
|
||||
export function resetUserPromptCustomization(operation: string) {
|
||||
return request<{ ok: boolean }>(api.delete(`/settings/prompt-templates/${encodeURIComponent(operation)}`));
|
||||
}
|
||||
|
||||
export function getAdminOSSSetting() {
|
||||
|
||||
@@ -183,7 +183,7 @@ export function backendProviderConfig(config: AiConfig) {
|
||||
audioFormat: config.audioFormat,
|
||||
audioSpeed: config.audioSpeed,
|
||||
audioInstructions: config.audioInstructions,
|
||||
systemPrompt: config.systemPrompt,
|
||||
systemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,8 @@ export type CreateSessionInput = {
|
||||
prompt: string;
|
||||
canvasSnapshot?: Record<string, unknown>;
|
||||
references?: string[];
|
||||
projectStyle?: { presetId: string; title: string; prompt: string };
|
||||
characters?: Array<{ assetId: string; versionId: string; name: string; definition: Record<string, unknown> }>;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -303,7 +303,9 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot) {
|
||||
audioVoice: config.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: config.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: config.audioSpeed || defaultConfig.audioSpeed,
|
||||
audioInstructions: config.audioInstructions || "",
|
||||
audioInstructions: config.audioInstructions || "",
|
||||
// 旧版全局 systemPrompt 会跨任务污染请求;提示词定制现已按 operation 由服务端编译。
|
||||
systemPrompt: "",
|
||||
videoSeconds: normalizeVideoDuration(config.videoSeconds),
|
||||
vquality: normalizeVideoResolution(config.vquality),
|
||||
videoGenerateAudio: config.videoGenerateAudio || "true",
|
||||
|
||||
@@ -554,6 +554,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* CodeMirror 使用运行时注入样式,需在全局覆盖中绑定项目主题 token。 */
|
||||
.prompt-code-editor,
|
||||
.prompt-code-editor .cm-editor {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-editor {
|
||||
background: var(--workspace-surface-strong) !important;
|
||||
color: var(--foreground);
|
||||
font-size: var(--fs-caption);
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-editor.cm-focused {
|
||||
outline: 2px solid color-mix(in srgb, var(--workspace-accent) 42%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-scroller {
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-content {
|
||||
padding-block: var(--space-3);
|
||||
color: var(--foreground) !important;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-line {
|
||||
padding-inline: var(--space-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-gutters {
|
||||
border-right: 1px solid var(--workspace-border) !important;
|
||||
background: color-mix(in srgb, var(--workspace-surface-strong) 92%, var(--foreground) 8%) !important;
|
||||
color: color-mix(in srgb, var(--foreground) 42%, transparent) !important;
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-activeLine,
|
||||
.prompt-code-editor .cm-activeLineGutter {
|
||||
background: color-mix(in srgb, var(--workspace-accent) 7%, transparent) !important;
|
||||
}
|
||||
|
||||
.prompt-code-editor .cm-selectionBackground,
|
||||
.prompt-code-editor .cm-content ::selection {
|
||||
background: color-mix(in srgb, var(--workspace-accent) 24%, transparent) !important;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.app-spatial-workspace {
|
||||
--workspace-canvas: #f5f5f5;
|
||||
@@ -3275,6 +3328,30 @@
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
/* 画风选择器自身就是完整工作区表面,移除 AntD 外壳避免形成双层厚边。 */
|
||||
.canvas-style-picker-modal .ant-modal-container,
|
||||
.canvas-style-detail-modal .ant-modal-container {
|
||||
overflow: hidden;
|
||||
border: var(--stroke-1) solid var(--border) !important;
|
||||
border-radius: var(--r-2xl) !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.canvas-style-picker-modal .ant-modal-body,
|
||||
.canvas-style-detail-modal .ant-modal-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.canvas-style-picker-modal .ant-modal-close,
|
||||
.canvas-style-detail-modal .ant-modal-close {
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
color: var(--cn-text);
|
||||
background: color-mix(in srgb, var(--cn-spatial-elevated) 84%, transparent);
|
||||
backdrop-filter: blur(var(--space-2));
|
||||
}
|
||||
|
||||
:where(.ant-select-dropdown, .ant-dropdown-menu, .ant-popover-inner) {
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 20px 56px rgba(15, 23, 42, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
|
||||
|
||||
@@ -29,6 +29,7 @@ export type CanvasWorkspaceMode = "simple" | "professional";
|
||||
export type CanvasToolMode = "move" | "box-select";
|
||||
export type StoryboardShotDuration = "auto" | "5" | "10" | "15" | "30";
|
||||
export type StoryboardShotCount = "auto" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "10";
|
||||
export type StoryboardVideoInputMode = "direct" | "keyframe";
|
||||
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
|
||||
export type CanvasGenerationBatchMode = "storyboard_image" | "storyboard_video" | "action_board";
|
||||
export type CanvasGenerationBatchStatus = "queued" | "running" | "partial_failed" | "completed" | "cancelled";
|
||||
@@ -38,10 +39,12 @@ export type CanvasWorkflowKind = "free" | "script" | "story_input" | "character"
|
||||
export type CanvasVideoEditOperation = "text_to_video" | "image_to_video" | "extend" | "inpaint" | "replace_element" | "camera_motion" | "style_transfer" | "audio_to_video" | "compare_versions" | "concat";
|
||||
export type CanvasSkillCategory = "writing" | "storyboard" | "image" | "video" | "utility";
|
||||
export type CanvasSkillOutputMode = "text" | "json" | "image_prompt" | "workflow";
|
||||
export type StoryboardColumn = "shotNumber" | "durationSeconds" | "plotDescription" | "dialogue" | "shotSize" | "emotion" | "lightingAndAtmosphere" | "audioEffects" | "camera" | "motion" | "timeBeats" | "imageGenerationPrompt" | "videoMotionPrompt" | "negativePrompt";
|
||||
export type StoryboardColumn = "shotNumber" | "durationSeconds" | "plotDescription" | "dialogue" | "narrativeIntent" | "viewerPOV" | "performanceBlocking" | "shotSize" | "emotion" | "lightingAndAtmosphere" | "audioEffects" | "camera" | "motion" | "timeBeats" | "imageGenerationPrompt" | "videoMotionPrompt" | "continuityOut" | "negativePrompt";
|
||||
|
||||
export type StoryboardCharacterReference = {
|
||||
characterName: string;
|
||||
characterAssetId?: string;
|
||||
characterVersionId?: string;
|
||||
characterDescription?: string;
|
||||
characterImageNodeId?: string;
|
||||
};
|
||||
@@ -53,6 +56,9 @@ export type StoryboardRow = {
|
||||
plotDescription: string;
|
||||
dialogue: string;
|
||||
characters: StoryboardCharacterReference[];
|
||||
narrativeIntent: string;
|
||||
viewerPOV: string;
|
||||
performanceBlocking: string;
|
||||
shotSize: string;
|
||||
emotion: string;
|
||||
lightingAndAtmosphere: string;
|
||||
@@ -62,6 +68,11 @@ export type StoryboardRow = {
|
||||
timeBeats: string;
|
||||
imageGenerationPrompt: string;
|
||||
videoMotionPrompt: string;
|
||||
imagePromptTemplateVariables?: Record<string, string>;
|
||||
videoPromptTemplateVariables?: Record<string, string>;
|
||||
mustHave: string[];
|
||||
optionalDetails: string[];
|
||||
continuityOut: string;
|
||||
negativePrompt: string;
|
||||
referenceNodeIds: string[];
|
||||
imageNodeId?: string;
|
||||
@@ -115,6 +126,8 @@ export type CanvasNodeMetadata = {
|
||||
richText?: Record<string, unknown>;
|
||||
composerContent?: string;
|
||||
prompt?: string;
|
||||
promptTemplateOperation?: string;
|
||||
promptTemplateVariables?: Record<string, string>;
|
||||
status?: CanvasNodeStatus;
|
||||
locked?: boolean;
|
||||
errorDetails?: string;
|
||||
@@ -217,6 +230,7 @@ export type CanvasNodeMetadata = {
|
||||
storyboard?: StoryboardData;
|
||||
storyboardShotDuration?: StoryboardShotDuration;
|
||||
storyboardShotCount?: StoryboardShotCount;
|
||||
storyboardVideoInputMode?: StoryboardVideoInputMode;
|
||||
storyboardComposerHeight?: number;
|
||||
generationBatches?: CanvasGenerationBatch[];
|
||||
frame?: {
|
||||
|
||||
Reference in New Issue
Block a user