feat(*): 分镜与资产 - 完善五类资产、候选去重、搜索、中文镜头编号与分类整理

This commit is contained in:
ddCat
2026-08-31 13:15:38 +08:00
parent ef30645cf0
commit 8984c567ac
53 changed files with 816 additions and 188 deletions

View File

@@ -2,6 +2,7 @@ import { useMemo } from "react";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { ASSET_CATEGORY_LABELS, normalizeAssetCategory } from "@/lib/asset-category";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
@@ -21,7 +22,7 @@ type Props = {
onClose: () => void;
};
const categoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) {
const assets = useAssetStore((state) => state.assets);
@@ -31,7 +32,7 @@ export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: P
...insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
category: normalizeAssetCategory(asset.category),
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: (asset.tags || []).join(" "),

View File

@@ -3,6 +3,7 @@ import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { ArrowLeft, Check, ChevronRight, Clipboard, CloudUpload, Copy, FolderOpen, FolderPlus, Image as ImageIcon, Layers3, Link2, Maximize2, PanelTop, Pencil, Plus, Redo2, Tags, Trash2, Undo2, Upload, UserRound } from "lucide-react";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -15,15 +16,7 @@ import { CanvasNodeType, type CanvasNodeData, type CanvasNodeTypeId, type Canvas
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [
{ value: "character", label: "角色" },
{ value: "environment", label: "场景" },
{ value: "wardrobe", label: "服饰" },
{ value: "prop", label: "道具" },
{ value: "weapon", label: "武器" },
{ value: "style", label: "画风" },
{ value: "other", label: "其他" },
];
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = ASSET_CATEGORY_OPTIONS;
type CanvasNodeContextMenuProps = {
menu: ContextMenuState;

View File

@@ -4,6 +4,7 @@ import type { MenuProps } from "antd";
import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
import { canvasThemes } from "@/lib/canvas-theme";
import { resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
@@ -58,15 +59,7 @@ type CanvasNodeToolbarProps = {
type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["assetCategory"]>;
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = [
{ value: "character", label: "角色" },
{ value: "environment", label: "场景" },
{ value: "wardrobe", label: "服饰" },
{ value: "prop", label: "道具" },
{ value: "weapon", label: "武器" },
{ value: "style", label: "画风" },
{ value: "other", label: "其他" },
];
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = ASSET_CATEGORY_OPTIONS;
type ToolbarTool = {
id: string;

View File

@@ -4,12 +4,13 @@ import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/componen
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { externalAssetToInsertPayload, type InsertAssetPayload } from "@/components/canvas/asset-picker-modal";
import { compileCharacterReferencePrompt } from "@/lib/canvas/canvas-character-reference";
import { ASSET_CATEGORY_LABELS, normalizeAssetCategory } from "@/lib/asset-category";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import type { ProjectAsset, ProjectDetail } from "@/services/api/projects";
import { getRemoteAsset } from "@/services/api/user-data";
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
const categoryLabels: Record<string, string> = { all: "全部资产", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
const categoryLabels: Record<string, string> = { all: "全部资产", ...ASSET_CATEGORY_LABELS };
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
@@ -20,13 +21,13 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }];
const media = mediaById.get(asset.id);
return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: asset.category || media?.category || "other", folderId: asset.folderId, project: asset, media }];
return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: normalizeAssetCategory(asset.category || media?.category), folderId: asset.folderId, project: asset, media }];
});
if (detail) return projectItems;
// 自由画布未关联项目时回退到个人素材库。
return mediaAssets
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media): ProjectPickerItem => ({ id: media.id, category: media.category || "other", media }));
.map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
}, [detail?.assets, mediaAssets]);
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const character = item.character;

View File

@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
import { ArrowLeft, ChevronRight, FileText, Folder, Image as ImageIcon, Music2, Pencil, Search, UserRound, Video, Workflow } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { useThemeStore } from "@/stores/use-theme-store";
import { buildAssetMentionReferences, canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { useAssetStore, type AssetCategory } from "@/stores/use-asset-store";
@@ -504,16 +505,6 @@ function createInlinePreview(reference: CanvasResourceReference) {
return fallback;
}
const ASSET_CATEGORY_LABELS: Record<AssetCategory, string> = {
character: "角色",
environment: "场景",
wardrobe: "服饰",
prop: "道具",
weapon: "武器",
style: "画风",
other: "其他",
};
function MentionMenu({ anchor, connectedReferences, assetReferences, filteredReferences, query, cursorOffset, activeReferenceId, preferredWidth, onQueryChange, onClose, onSelect }: {
anchor: HTMLElement;
connectedReferences: CanvasResourceReference[];

View File

@@ -0,0 +1,40 @@
export const ASSET_CATEGORIES = ["character", "environment", "prop", "material", "other"] as const;
export type AssetCategory = (typeof ASSET_CATEGORIES)[number];
export const ASSET_CATEGORY_LABELS: Record<AssetCategory, string> = {
character: "角色",
environment: "场景",
prop: "道具",
material: "素材",
other: "其他",
};
export const ASSET_CATEGORY_OPTIONS = ASSET_CATEGORIES.map((value) => ({
value,
label: ASSET_CATEGORY_LABELS[value],
}));
const LEGACY_ASSET_CATEGORY_MAP: Record<string, AssetCategory> = {
wardrobe: "prop",
weapon: "prop",
accessory: "prop",
style: "material",
};
export function normalizeAssetCategory(value: unknown, fallback: AssetCategory = "other"): AssetCategory {
if (typeof value !== "string") return fallback;
const normalized = value.trim().toLowerCase();
if ((ASSET_CATEGORIES as readonly string[]).includes(normalized)) return normalized as AssetCategory;
return LEGACY_ASSET_CATEGORY_MAP[normalized] || fallback;
}
export function defaultAssetCategoryForKind(kind: string): AssetCategory {
if (kind === "entity") return "character";
if (kind === "image" || kind === "video" || kind === "audio" || kind === "model") return "material";
return "other";
}
export function assetCategoryLabel(value: unknown) {
return ASSET_CATEGORY_LABELS[normalizeAssetCategory(value)];
}

View File

@@ -1,4 +1,5 @@
import type { Asset } from "@/stores/use-asset-store";
import { defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
export type AssetStorageDocument = {
state: { assets: Asset[] };
@@ -8,10 +9,12 @@ export type AssetStorageDocument = {
};
export function normalizeAssetRecord(asset: Asset): Asset {
if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string")) return asset;
const category = normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind));
if (Array.isArray(asset.tags) && asset.tags.every((tag) => typeof tag === "string") && asset.category === category) return asset;
return {
...asset,
tags: Array.isArray(asset.tags) ? asset.tags.filter((tag): tag is string => typeof tag === "string") : [],
category,
};
}

View File

@@ -1,7 +1,14 @@
import type { CanvasConnection, CanvasNodeData, Position } from "@/types/canvas";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type Position } from "@/types/canvas";
export type CanvasLayoutMode = "row" | "column" | "grid";
export type CanvasAlignmentMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom" | "distributeX" | "distributeY";
export type CanvasLayoutLane = "text" | "image" | "video" | "audio";
const FLOW_COLUMN_GAP = 120;
const LANE_GAP = 96;
const LANE_NODE_GAP = 36;
const GRID_NODE_GAP = 32;
const LANE_ORDER: CanvasLayoutLane[] = ["text", "image", "video", "audio"];
export function layoutCanvasNodes(nodes: CanvasNodeData[], mode: CanvasLayoutMode) {
const sorted = [...nodes].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
@@ -36,6 +43,7 @@ export function layoutCanvasNodes(nodes: CanvasNodeData[], mode: CanvasLayoutMod
}
export function layoutCanvasFlow(nodes: CanvasNodeData[], connections: CanvasConnection[]) {
if (!nodes.length) return new Map<string, Position>();
const selectedIds = new Set(nodes.map((node) => node.id));
const inbound = new Map(nodes.map((node) => [node.id, 0]));
const outbound = new Map(nodes.map((node) => [node.id, [] as string[]]));
@@ -63,29 +71,83 @@ export function layoutCanvasFlow(nodes: CanvasNodeData[], connections: CanvasCon
if (!layerById.has(node.id)) layerById.set(node.id, 0);
});
const groups = new Map<number, CanvasNodeData[]>();
nodes.forEach((node) => {
const layer = layerById.get(node.id) || 0;
groups.set(layer, [...(groups.get(layer) || []), node]);
});
const left = Math.min(...nodes.map((node) => node.position.x));
const top = Math.min(...nodes.map((node) => node.position.y));
const result = new Map<string, Position>();
const layers = [...new Set(layerById.values())].sort((a, b) => a - b);
const layerX = new Map<number, number>();
let x = left;
[...groups.keys()].sort((a, b) => a - b).forEach((layer) => {
const column = groups.get(layer)!.sort((a, b) => a.position.y - b.position.y);
let y = top;
const width = Math.max(...column.map((node) => node.width));
column.forEach((node) => {
result.set(node.id, { x, y });
y += node.height + 48;
layers.forEach((layer) => {
const layerNodes = nodes.filter((node) => layerById.get(node.id) === layer);
layerX.set(layer, x);
x += Math.max(...layerNodes.map((node) => node.width)) + FLOW_COLUMN_GAP;
});
let laneTop = top;
LANE_ORDER.forEach((lane) => {
const laneNodes = nodes.filter((node) => canvasLayoutLane(node) === lane);
if (!laneNodes.length) return;
const stacks = new Map<number, CanvasNodeData[]>();
laneNodes.forEach((node) => {
const layer = layerById.get(node.id) || 0;
stacks.set(layer, [...(stacks.get(layer) || []), node]);
});
x += width + 120;
let laneHeight = 0;
stacks.forEach((stack) => {
const height = stack.reduce((sum, node) => sum + node.height, 0) + Math.max(0, stack.length - 1) * LANE_NODE_GAP;
laneHeight = Math.max(laneHeight, height);
});
stacks.forEach((stack, layer) => {
let y = laneTop;
stack.sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x).forEach((node) => {
result.set(node.id, { x: layerX.get(layer) || left, y });
y += node.height + LANE_NODE_GAP;
});
});
laneTop += laneHeight + LANE_GAP;
});
return result;
}
export function layoutCanvasNodesByMediaType(nodes: CanvasNodeData[]) {
if (!nodes.length) return new Map<string, Position>();
const left = Math.min(...nodes.map((node) => node.position.x));
const top = Math.min(...nodes.map((node) => node.position.y));
const result = new Map<string, Position>();
let laneTop = top;
LANE_ORDER.forEach((lane) => {
const laneNodes = nodes.filter((node) => canvasLayoutLane(node) === lane).sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
if (!laneNodes.length) return;
const columns = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(laneNodes.length))));
const cellWidth = Math.max(...laneNodes.map((node) => node.width)) + GRID_NODE_GAP;
const cellHeight = Math.max(...laneNodes.map((node) => node.height)) + GRID_NODE_GAP;
laneNodes.forEach((node, index) => {
result.set(node.id, {
x: left + (index % columns) * cellWidth,
y: laneTop + Math.floor(index / columns) * cellHeight,
});
});
laneTop += Math.ceil(laneNodes.length / columns) * cellHeight - GRID_NODE_GAP + LANE_GAP;
});
return result;
}
export function layoutCanvasAuto(nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const candidates = nodes.filter((node) => !node.metadata?.locked && node.type !== CanvasNodeType.Frame);
if (candidates.length < 2) return new Map<string, Position>();
const candidateIds = new Set(candidates.map((node) => node.id));
const hasConnections = connections.some((connection) => candidateIds.has(connection.fromNodeId) && candidateIds.has(connection.toNodeId));
return hasConnections ? layoutCanvasFlow(candidates, connections) : layoutCanvasNodesByMediaType(candidates);
}
export function canvasLayoutLane(node: CanvasNodeData): CanvasLayoutLane {
if (node.type === CanvasNodeType.Video) return "video";
if (node.type === CanvasNodeType.Audio) return "audio";
if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Drawing || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.Compare || node.type === CanvasNodeType.ColorGrade) return "image";
return "text";
}
export function alignCanvasNodes(nodes: CanvasNodeData[], mode: CanvasAlignmentMode) {
const result = new Map<string, Position>();
if (nodes.length < 2) return result;

View File

@@ -116,13 +116,14 @@ export function declaredCanvasNodeAssetCategory(node: CanvasNodeData): AssetCate
if (node.metadata?.assetCategory) return node.metadata.assetCategory;
if (node.metadata?.workflowKind === "character") return "character";
if (node.metadata?.workflowKind === "scene") return "environment";
if (node.metadata?.workflowKind === "styleboard") return "style";
if (node.metadata?.workflowKind === "styleboard") return "material";
return undefined;
}
export function canvasNodeAssetCategory(node: CanvasNodeData): AssetCategory {
const declared = declaredCanvasNodeAssetCategory(node);
if (declared) return declared;
if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) return "material";
return "other";
}

View File

@@ -1,5 +1,6 @@
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import type { CanvasAssistantSession, CanvasConnection, CanvasNodeData } from "@/types/canvas";
import { normalizeAssetCategory } from "@/lib/asset-category";
export type CanvasStorageTombstones = {
projects: Record<string, number>;
@@ -61,7 +62,7 @@ function normalizeTombstones(value: unknown): CanvasStorageTombstones {
export function parseCanvasStorageDocument(value: string | null, fallback: CanvasProject[] = []): CanvasStorageDocument {
if (!value) {
return {
state: { projects: fallback },
state: { projects: normalizeProjectAssetCategories(fallback) },
version: 0,
storageRevision: 0,
tombstones: emptyTombstones(),
@@ -75,13 +76,28 @@ export function parseCanvasStorageDocument(value: string | null, fallback: Canva
};
if (!Array.isArray(parsed.state?.projects)) throw new Error("画布持久状态无效");
return {
state: { projects: parsed.state.projects as CanvasProject[] },
state: { projects: normalizeProjectAssetCategories(parsed.state.projects as CanvasProject[]) },
version: typeof parsed.version === "number" ? parsed.version : 0,
storageRevision: typeof parsed.storageRevision === "number" && Number.isFinite(parsed.storageRevision) ? parsed.storageRevision : 0,
tombstones: normalizeTombstones(parsed.tombstones),
};
}
function normalizeProjectAssetCategories(projects: CanvasProject[]) {
return projects.map((project) => {
let changed = false;
const nodes = project.nodes.map((node) => {
const category = node.metadata?.assetCategory;
if (!category) return node;
const normalized = normalizeAssetCategory(category);
if (normalized === category) return node;
changed = true;
return { ...node, metadata: { ...node.metadata, assetCategory: normalized } };
});
return changed ? { ...project, nodes } : project;
});
}
export function serializeCanvasStorageDocument(document: CanvasStorageDocument) {
return JSON.stringify(document);
}

View File

@@ -38,7 +38,7 @@ export function storyboardAssetRoleForNode(node: CanvasNodeData): StoryboardAsse
if (node.type === CanvasNodeType.Audio) return "audio";
if (node.type === CanvasNodeType.Video) return "motion";
const category = node.metadata?.assetCategory;
if (category === "environment" || category === "wardrobe" || category === "prop" || category === "weapon" || category === "style") return category;
if (category === "environment" || category === "prop") return category;
if (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Drawing) return "style";
return null;
}

15
web/src/lib/shot-label.ts Normal file
View File

@@ -0,0 +1,15 @@
const LEGACY_DEFAULT_SHOT_TITLE = /^SC\.\d+$/i;
export function formatShotOrdinal(index: number) {
return `镜头${String(Math.max(0, index) + 1).padStart(2, "0")}`;
}
export function normalizeDefaultShotTitle(title: string | undefined, index: number) {
const value = (title || "").trim();
return LEGACY_DEFAULT_SHOT_TITLE.test(value) ? formatShotOrdinal(index) : value;
}
export function customShotTitle(title: string | undefined, index: number) {
const value = normalizeDefaultShotTitle(title, index);
return value === formatShotOrdinal(index) ? "" : value;
}

View File

@@ -12,6 +12,7 @@ import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/ass
import { saveAs } from "file-saver";
import { useCopyText } from "@/hooks/use-copy-text";
import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
@@ -48,13 +49,7 @@ const kindOptions = [
const categoryOptions = [
{ label: "全部分类", value: "all" },
{ label: "角色", value: "character" },
{ label: "场景", value: "environment" },
{ label: "服饰", value: "wardrobe" },
{ label: "道具", value: "prop" },
{ label: "武器", value: "weapon" },
{ label: "画风", value: "style" },
{ label: "其他", value: "other" },
...ASSET_CATEGORY_OPTIONS,
];
const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
@@ -781,10 +776,6 @@ function assetSearchText(asset: LibraryAsset) {
return [asset.title, asset.source || "", asset.note || "", assetCategoryLabel(asset.category), (asset.tags || []).join(" "), asset.kind === "text" ? asset.data.content : asset.data.mimeType].join(" ").toLowerCase();
}
function assetCategoryLabel(category?: AssetCategory) {
return categoryOptions.find((item) => item.value === (category || "other"))?.label || "其他";
}
function assetProjectLabel(asset: LibraryAsset) {
const projectName = asset.metadata?.projectName;
if (typeof projectName === "string" && projectName.trim()) return projectName;

View File

@@ -5,7 +5,7 @@ import { nanoid } from "nanoid";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_HEADER_HEIGHT, getFrameChildIds, getFrameChildren, isFrameNode } from "@/lib/canvas/canvas-frame";
import { alignCanvasNodes, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { alignCanvasNodes, layoutCanvasAuto, layoutCanvasFlow, layoutCanvasNodes, nextCanvasVersionLabel, type CanvasAlignmentMode } from "@/lib/canvas/canvas-layout";
import { createCanvasNode, isHiddenBatchChild, removeCanvasNodes } from "@/lib/canvas/canvas-project-domain";
import { isolateCopiedNodeMetadata, nextCopiedNodeTitle } from "@/lib/canvas/canvas-node-copy";
import { CanvasNodeType, type CanvasConnection, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type ContextMenuState, type Position } from "@/types/canvas";
@@ -200,12 +200,9 @@ export function useCanvasNodeOperations({
return;
}
const candidateIds = new Set(candidates.map((node) => node.id));
const hasConnections = connectionsRef.current.some((connection) => candidateIds.has(connection.fromNodeId) && candidateIds.has(connection.toNodeId));
const mode = hasConnections ? "flow" : "grid";
const positions = mode === "flow" ? layoutCanvasFlow(candidates, connectionsRef.current) : layoutCanvasNodes(candidates, "grid");
const positions = layoutCanvasAuto(candidates, connectionsRef.current);
commitNodes(currentNodes.map((node) => positions.has(node.id) ? { ...node, position: positions.get(node.id)! } : node));
message.success(mode === "flow" ? (hasSelection ? "已按连线整理选中节点" : "已按连线整理画布") : (hasSelection ? "已网格整理选中节点" : "已网格整理画布"));
message.success(hasSelection ? "已按媒体分类整理选中节点" : "已按媒体分类整理画布");
}, [commitNodes, connectionsRef, message, nodesRef, selectedNodeIdsRef]);
const alignSelectedNodes = useCallback((mode: CanvasAlignmentMode) => {

View File

@@ -14,8 +14,10 @@ import { VoiceRecordingButton } from "@/components/conversation/voice-recording-
import { ModelPicker } from "@/components/model-picker";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { creationCanvasHandoffPath, creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { createGenerationBatchRetryContexts, createGenerationRetryContext, runGenerationOperationOnce, type GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
import { createClientId } from "@/lib/client-id";
import { formatShotOrdinal } from "@/lib/shot-label";
import { generationErrorCode, generationErrorMessage } from "@/lib/generation-error";
import { useCopyText } from "@/hooks/use-copy-text";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
@@ -934,7 +936,7 @@ export default function CreatePage() {
setCount,
promptOptimizerProvider,
composerFocusRef,
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `SC.${String(nextShotNumber).padStart(2, "0")} · 写下这一镜的镜头、画面或故事` : undefined,
placeholderOverride: viewMode === "storyboard" && composingNextShot ? `${formatShotOrdinal(nextShotNumber - 1)} · 写下这一镜的镜头、画面或故事` : undefined,
onSubmit: () => void submit(),
};
@@ -1018,7 +1020,7 @@ export default function CreatePage() {
</>;
}
const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect, onDelete }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void }) {
const [keyword, setKeyword] = useState("");
@@ -1572,9 +1574,9 @@ function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapability
}
const creationEmptyBannerFrames = [
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "SC.01 · 雨夜霓虹" },
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "SC.02 · 暗巷追逐" },
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "SC.03 · 天台重逢" },
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "镜头01 · 雨夜霓虹" },
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "镜头02 · 暗巷追逐" },
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "镜头03 · 天台重逢" },
];
function CreationEmptyBanner() {
@@ -1630,7 +1632,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
<div className="storyboard-workbench-rail">
<Tooltip title="镜头时间线"><button type="button" className={`storyboard-workbench-rail-button${railOpen ? " is-open" : ""}${composing ? " is-draft" : ""}`} aria-expanded={railOpen} aria-label="镜头时间线" onClick={() => setRailOpen((value) => !value)}><Film /><span className="storyboard-workbench-rail-badge">{composing ? nextShotNumber : shots.length}</span></button></Tooltip>
{railOpen ? <div className="storyboard-workbench-rail-pop" role="listbox" aria-label="镜头列表">
<div className="storyboard-workbench-rail-pop-head"><span className="storyboard-workbench-rail-pop-title"><Clapperboard />镜头时间线<small>{composing ? `下一镜 SC.${String(nextShotNumber).padStart(2, "0")}` : `${shots.length} 个镜头`}</small></span><button type="button" className="storyboard-workbench-rail-pop-close" aria-label="关闭镜头列表" onClick={closeRail}><X /></button></div>
<div className="storyboard-workbench-rail-pop-head"><span className="storyboard-workbench-rail-pop-title"><Clapperboard />镜头时间线<small>{composing ? `下一镜 ${formatShotOrdinal(nextShotNumber - 1)}` : `${shots.length} 个镜头`}</small></span><button type="button" className="storyboard-workbench-rail-pop-close" aria-label="关闭镜头列表" onClick={closeRail}><X /></button></div>
<ul className="creation-scrollbar">
{shots.map((shot, index) => {
const status = statusOf(shot);
@@ -1639,15 +1641,15 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
const thumbIsVideo = shot.result?.mode === "video";
return <li key={shot.user?.id || shot.result?.id || index}>
<button type="button" className={`storyboard-workbench-rail-row${index === activeIndex && !composing ? " is-active" : ""}`} onClick={() => { onSelect(index); closeRail(); }}>
<span className="storyboard-workbench-rail-thumb">{thumbUrl ? (thumbIsVideo ? <video muted preload="metadata" src={thumbUrl} /> : <img src={thumbUrl} alt="" />) : <span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(index + 1).padStart(2, "0")}</em></span>}</span>
<span className="storyboard-workbench-rail-thumb">{thumbUrl ? (thumbIsVideo ? <video muted preload="metadata" src={thumbUrl} /> : <img src={thumbUrl} alt="" />) : <span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>{formatShotOrdinal(index)}</em></span>}</span>
<span className="storyboard-workbench-rail-info">
<span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(index + 1).padStart(2, "0")}</span><span className={`storyboard-workbench-rail-row-state is-${status}`}>{status === "pending" ? "生成中" : status === "error" ? "失败" : status === "done" ? "完成" : "待生成"}</span>{shot.result?.createdAt ? <time dateTime={shot.result.createdAt}>{formatMessageTime(shot.result.createdAt)}</time> : null}</span>
<span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">{formatShotOrdinal(index)}</span><span className={`storyboard-workbench-rail-row-state is-${status}`}>{status === "pending" ? "生成中" : status === "error" ? "失败" : status === "done" ? "完成" : "待生成"}</span>{shot.result?.createdAt ? <time dateTime={shot.result.createdAt}>{formatMessageTime(shot.result.createdAt)}</time> : null}</span>
<span className="storyboard-workbench-rail-row-title">{title}</span>
</span>
</button>
</li>;
})}
{composing ? <li><button type="button" className="storyboard-workbench-rail-row is-draft" onClick={() => { onCancelCompose(); closeRail(); }}><span className="storyboard-workbench-rail-thumb"><span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>SC.{String(nextShotNumber).padStart(2, "0")}</em></span></span><span className="storyboard-workbench-rail-info"><span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">SC.{String(nextShotNumber).padStart(2, "0")}</span><span className="storyboard-workbench-rail-row-state">待撰写</span></span><span className="storyboard-workbench-rail-row-title">等待你的脚本</span></span></button></li> : null}
{composing ? <li><button type="button" className="storyboard-workbench-rail-row is-draft" onClick={() => { onCancelCompose(); closeRail(); }}><span className="storyboard-workbench-rail-thumb"><span className="storyboard-workbench-rail-thumb-ph"><Clapperboard /><em>{formatShotOrdinal(nextShotNumber - 1)}</em></span></span><span className="storyboard-workbench-rail-info"><span className="storyboard-workbench-rail-head"><span className="storyboard-workbench-rail-row-shot">{formatShotOrdinal(nextShotNumber - 1)}</span><span className="storyboard-workbench-rail-row-state">待撰写</span></span><span className="storyboard-workbench-rail-row-title">等待你的脚本</span></span></button></li> : null}
</ul>
<button type="button" className="storyboard-workbench-rail-pop-add" onClick={() => { closeRail(); onBeginCompose(); }}><Plus />新增镜头</button>
</div> : null}
@@ -1677,7 +1679,7 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
return <article className={`storyboard-workbench-card is-${status}`}>
<header className="storyboard-workbench-card-head">
<div className="storyboard-workbench-card-heading">
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>镜头 {shotNumber}</span>
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">{formatShotOrdinal(shotNumber - 1)}</span></span>
<span className="storyboard-workbench-card-mode">{mode === "video" ? <Film /> : mode === "image" ? <ImageIcon /> : <MessageSquareText />}{modeLabels[mode]}</span>
{modelName ? <span className="storyboard-workbench-card-model">{modelName}</span> : null}
{status === "pending" ? <span className="storyboard-workbench-card-state is-pending"><LoaderCircle className="animate-spin" />生成中</span> : status === "error" ? <span className="storyboard-workbench-card-state is-error">生成失败</span> : status === "done" ? <span className="storyboard-workbench-card-state is-done"><Check />已完成</span> : <span className="storyboard-workbench-card-state">待生成</span>}
@@ -1720,7 +1722,7 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number;
return <article className="storyboard-workbench-card is-next">
<header className="storyboard-workbench-card-head">
<div className="storyboard-workbench-card-heading">
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">SC.{String(shotNumber).padStart(2, "0")}</span>下一镜 {shotNumber}</span>
<span className="storyboard-workbench-card-shot"><span className="storyboard-workbench-card-shot-index">{formatShotOrdinal(shotNumber - 1)}</span>下一镜</span>
<span className="storyboard-workbench-card-state is-draft"><Clapperboard />待撰写</span>
</div>
<div className="storyboard-workbench-card-actions">
@@ -1731,8 +1733,8 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number;
<div className="storyboard-workbench-next-panel">
<span className="storyboard-workbench-next-panel-icon"><Clapperboard /></span>
<div className="storyboard-workbench-next-panel-copy">
<strong>SC.{String(shotNumber).padStart(2, "0")} 等待你的脚本</strong>
<span>在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 SC.{String(shotNumber).padStart(2, "0")} 自动加入镜头轨道。</span>
<strong>{formatShotOrdinal(shotNumber - 1)} 等待你的脚本</strong>
<span>在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 {formatShotOrdinal(shotNumber - 1)} 自动加入镜头轨道。</span>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { useMutation, useMutationState, useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Tabs, type FormInstance } from "antd";
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react";
import { Box, Check, ChevronDown, Download, FileText, FolderOpen, FolderPlus, Image as ImageIcon, Link2, MoreHorizontal, MoveRight, Music2, Pencil, Plus, RefreshCw, Search, Sparkles, Trash2, Upload, UserRound, Video, VolumeX } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { PaginationBar } from "@/components/layout/workspace-page";
@@ -10,10 +10,12 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { useExternalAssetSources } from "@/hooks/use-external-asset-sources";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/canvas/canvas-folder-theme";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { CHARACTER_VOICE_FORMAT_LABEL, CHARACTER_VOICE_UPLOAD_ACCEPT, characterVoiceFormatName, characterVoiceTitleFromFileName, isSupportedCharacterVoiceFile } from "@/lib/character-voice-formats";
import { ASSET_CATEGORIES, defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { uploadMediaFile } from "@/services/file-storage";
import {
@@ -49,7 +51,7 @@ import { linkSelectedProjectAssets } from "./project-asset-linking";
import { generateCharacterTurnaround } from "./project-character-media";
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
const categories = ["all", ...ASSET_CATEGORIES];
const ALL_FOLDERS = "__all_folders__";
const pickerCategoryLabels = { all: "全部素材", ...categoryLabels };
const characterFields = [
@@ -73,6 +75,8 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(40);
const [candidatePage, setCandidatePage] = useState(1);
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebouncedValue(keyword.trim(), 250);
const candidatePageSize = 24;
const [folderEditor, setFolderEditor] = useState<{ folder?: ProjectAssetFolder; parentId: string } | null>(null);
const [folderName, setFolderName] = useState("");
@@ -88,19 +92,20 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const [form] = Form.useForm<CharacterForm>();
const assetsQuery = useQuery({
queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId],
queryKey: ["project", detail.project.id, "assets", page, pageSize, category, folderId, debouncedKeyword],
queryFn: () => listProjectAssetsPage(detail.project.id, {
page,
pageSize,
category: category === "all" ? undefined : category,
folderId: folderId === ALL_FOLDERS ? undefined : folderId,
query: debouncedKeyword || undefined,
}),
});
const foldersQuery = useQuery({ queryKey: ["project", detail.project.id, "asset-folders"], queryFn: () => listProjectAssetFolders(detail.project.id) });
const showPendingCandidates = folderId === ALL_FOLDERS && (category === "all" || category === "character");
const candidatesQuery = useQuery({
queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, "character", "pending_confirmation"],
queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: "character", status: "pending_confirmation" }),
queryKey: ["project", detail.project.id, "asset-candidates", candidatePage, candidatePageSize, "character", "pending_confirmation", debouncedKeyword],
queryFn: () => listProjectAssetCandidates(detail.project.id, { page: candidatePage, pageSize: candidatePageSize, category: "character", status: "pending_confirmation", query: debouncedKeyword || undefined }),
});
const assets = assetsQuery.data?.assets || [];
const assetFolders = foldersQuery.data?.folders || [];
@@ -121,6 +126,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const lastPage = Math.max(1, Math.ceil(candidatesQuery.data.total / candidatePageSize));
if (candidatePage > lastPage) setCandidatePage(lastPage);
}, [candidatePage, candidatesQuery.data]);
useEffect(() => {
setPage(1);
setCandidatePage(1);
}, [debouncedKeyword]);
const selectFolder = (nextFolderId: string) => {
setFolderId(nextFolderId);
@@ -140,7 +149,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
...availableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.kind === "entity" ? "character" : asset.category || "other",
category: normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)),
kindLabel: mediaLabel(asset.kind),
asset,
description: asset.note,
@@ -152,7 +161,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
...imageAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.category || "other",
category: normalizeAssetCategory(asset.category, defaultAssetCategoryForKind(asset.kind)),
kindLabel: "图片",
asset,
searchText: (asset.tags || []).join(" "),
@@ -210,7 +219,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const assetId = addAsset(imported);
return linkProjectAsset(detail.project.id, {
assetId,
category: imported.kind === "entity" ? "character" : imported.category || "other",
category: normalizeAssetCategory(imported.category, defaultAssetCategoryForKind(imported.kind)),
folderId: nextFolderId,
});
}
@@ -218,7 +227,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
if (!selected) throw new Error("所选素材已不存在,请重新选择");
return linkProjectAsset(detail.project.id, {
assetId: selected.id,
category: selected.kind === "entity" ? "character" : selected.category || "other",
category: normalizeAssetCategory(selected.category, defaultAssetCategoryForKind(selected.kind)),
folderId: nextFolderId,
});
});
@@ -238,7 +247,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
});
const versionMutation = useMutation({ mutationFn: (id: string) => createProjectAssetVersion(detail.project.id, id, {}), onSuccess: () => done("已创建新版本"), onError: failed("版本创建失败") });
const unlinkMutation = useMutation({ mutationFn: (id: string) => unlinkProjectAsset(detail.project.id, id), onSuccess: () => done("资产已移出项目"), onError: failed("资产移除失败") });
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: string }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category as AssetCategory }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
const categoryMutation = useMutation({ mutationFn: ({ id, next }: { id: string; next: AssetCategory }) => updateProjectAssetCategory(detail.project.id, id, next), onSuccess: ({ asset }) => { updatePersonalAsset(asset.id, { category: asset.category }); done("资产分类已更新"); }, onError: failed("资产分类更新失败") });
const moveMutation = useMutation({ mutationFn: ({ id, nextFolderId }: { id: string; nextFolderId: string }) => moveProjectAsset(detail.project.id, id, nextFolderId), onSuccess: () => done("资产已移动"), onError: failed("资产移动失败") });
const createFolderMutation = useMutation({
mutationFn: ({ name, parentId }: { name: string; parentId: string }) => createProjectAssetFolder(detail.project.id, { name, parentId: parentId || undefined }),
@@ -392,7 +401,10 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
<button type="button" className="truncate rounded px-1.5 py-1 hover:bg-surface-hover" onClick={() => selectFolder("")}>素材库</button>
{folderId === ALL_FOLDERS ? <><span>/</span><span className="font-medium text-foreground">全部资产</span></> : folderPath.map((folder) => <span key={folder.id} className="contents"><span>/</span><button type="button" className="truncate rounded px-1.5 py-1 font-medium text-foreground hover:bg-surface-hover" onClick={() => selectFolder(folder.id)}>{folder.name}</button></span>)}
</div>
{folderId !== ALL_FOLDERS ? <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor(undefined, folderId)}>新建子文件夹</Button> : null}
<div className="flex items-center gap-2">
<Input allowClear value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<Search className="size-3.5 text-foreground/35" />} placeholder="搜索资产名称" aria-label="搜索项目资产" className="w-52" />
{folderId !== ALL_FOLDERS ? <Button type="text" size="small" icon={<FolderPlus className="size-3.5" />} onClick={() => openFolderEditor(undefined, folderId)}>新建子文件夹</Button> : null}
</div>
</div>
{childFolders.length ? <div className="project-asset-folder-grid mb-5">{childFolders.map((folder) => <ProjectAssetFolderCard key={folder.id} folder={folder} folders={assetFolders} assets={assets} folderCounts={folderCountMap} personalAssets={personalAssets} onOpen={() => selectFolder(folder.id)} onRename={() => openFolderEditor(folder)} onMove={(parentId) => moveFolderMutation.mutate({ id: folder.id, parentId })} onStyle={(style) => styleFolderMutation.mutate({ id: folder.id, style })} onTheme={(theme) => themeFolderMutation.mutate({ id: folder.id, theme })} onDelete={() => modal.confirm({ title: `删除文件夹“${folder.name}”?`, content: "仅空文件夹可以删除,素材和子文件夹不会被级联删除。", okText: "删除", okButtonProps: { danger: true }, cancelText: "取消", onOk: () => deleteFolderMutation.mutateAsync(folder.id) })} deleting={(deleteFolderMutation.isPending && deleteFolderMutation.variables === folder.id) || (moveFolderMutation.isPending && moveFolderMutation.variables?.id === folder.id) || (styleFolderMutation.isPending && styleFolderMutation.variables?.id === folder.id) || (themeFolderMutation.isPending && themeFolderMutation.variables?.id === folder.id)} />)}</div> : null}
{folderId === ALL_FOLDERS && (category === "all" || category === "character") && pendingCandidates.length ? (
@@ -423,7 +435,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
</section>
) : null}
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{currentFolder?.name || (folderId === ALL_FOLDERS ? (category === "all" ? "全部资产" : categoryLabel(category)) : "根目录内容")}</span><span>{assetsQuery.data?.total || 0} 项已确认</span></div>
{assetsQuery.isLoading ? <WorkspaceState icon="assets" compact title="正在读取资产" description="按当前目录和分类加载这一页。" /> : visibleAssets.length ? <><div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} folderItems={folderMoveItems} generating={generatingAssetIds.has(asset.id)} removing={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} folderItems={folderMoveItems} onOpen={() => setPreviewAsset(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div><PaginationBar current={page} pageSize={pageSize} total={assetsQuery.data?.total || 0} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /></> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title="这个文件夹还没有内容" description="可以新建子文件夹、引用个人素材,或把画布产物归档到这里。" />}
{assetsQuery.isLoading ? <WorkspaceState icon="assets" compact title="正在读取资产" description="按当前目录、分类和关键词加载这一页。" /> : visibleAssets.length ? <><div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} folderItems={folderMoveItems} generating={generatingAssetIds.has(asset.id)} removing={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} folderItems={folderMoveItems} onOpen={() => setPreviewAsset(asset)} onMove={(nextFolderId) => moveMutation.mutate({ id: asset.id, nextFolderId })} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(moveMutation.isPending && moveMutation.variables?.id === asset.id) || (categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div><PaginationBar current={page} pageSize={pageSize} total={assetsQuery.data?.total || 0} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} /></> : childFolders.length || (showPendingCandidates && pendingCandidates.length) ? null : <WorkspaceState icon="assets" compact title={debouncedKeyword ? "没有找到匹配资产" : "这个文件夹还没有内容"} description={debouncedKeyword ? "换一个关键词,或调整左侧分类与目录筛选。" : "可以新建子文件夹、引用个人素材,或把画布产物归档到这里。"} />}
</div>
</div>
@@ -648,8 +660,8 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
type CharacterFormKey = (typeof characterFields)[number][0];
function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
function MediaAssetCard({ asset, personalAsset, folderItems, onOpen, onMove, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; folderItems: Array<{ key: string; label: string }>; onOpen: () => void; onMove: (folderId: string) => void; onCategoryChange: (category: AssetCategory) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><ProjectAssetMedia asset={asset} personalAsset={personalAsset} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(normalizeAssetCategory(key)) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.folderId || ""], items: folderItems, onClick: ({ key }) => onMove(key) }}><Button type="text" size="small" icon={<MoveRight className="size-3.5" />} loading={loading} aria-label={`移动 ${asset.title}`} /></Dropdown><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} aria-label={`为 ${asset.title} 创建版本`} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} aria-label={`移出 ${asset.title}`} /></Popconfirm></div></div></div></AssetLibraryCard>;
}
function ProjectAssetMedia({ asset, personalAsset }: { asset: ProjectAsset; personalAsset?: Asset }) {

View File

@@ -1,4 +1,5 @@
import type { StoryboardRow } from "@/types/canvas";
import { formatShotOrdinal } from "@/lib/shot-label";
import type { ProjectDetail, ShotRevisionInput } from "@/services/api/projects";
import { ensureShotAssetMentionPrompt } from "./workflow-shot-references";
@@ -90,7 +91,7 @@ export function storyboardRowsToProjectShots(rows: StoryboardRow[], detail?: Pro
return reference ? [reference] : [];
});
return {
title: `SC.${String(shotNumber).padStart(2, "0")}`,
title: formatShotOrdinal(index),
description,
durationMs,
assetVersionIds: storyboardRowAssetVersionIds(row, versionByAssetId),

View File

@@ -347,17 +347,17 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true }));
};
const storeExtractedCharacters = async (unitId: string, characters: Awaited<ReturnType<typeof extractChapterCharacters>>) => {
// 已确认角色允许再次提取并作为候选归并,只有尚待处理的同名候选需要去重。
const knownNames = new Set(detail.assetCandidates
.filter((candidate) => candidate.category === "character" && candidate.status === "pending_confirmation")
.map((candidate) => normalizeCharacterName(candidate.name)));
const knownNames = new Set([
...detail.assetCandidates.filter((candidate) => candidate.category === "character").map((candidate) => normalizeCharacterName(candidate.name)),
...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)),
]);
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
if (fresh.length) {
await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })));
}
const created = fresh.length
? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract")
: { candidates: [] };
markChapterOperationCompleted(unitId, "characters");
refreshProject();
return fresh.length;
return created.candidates.length;
};
const storeGeneratedStoryboard = async (unitId: string, rows: ReturnType<typeof chapterStoryboardFromGenerationTask>["rows"]) => {
const shots = storyboardRowsToProjectShots(rows, detail);

View File

@@ -2,6 +2,7 @@ import { Tag } from "antd";
import type { ReactNode } from "react";
import type { ProjectDetail, ProjectUnit } from "@/services/api/projects";
import { ASSET_CATEGORY_LABELS, assetCategoryLabel as sharedAssetCategoryLabel } from "@/lib/asset-category";
export type ProjectDetailViewProps = {
detail: ProjectDetail;
@@ -9,15 +10,7 @@ export type ProjectDetailViewProps = {
onCreateCanvas: () => void;
};
export const categoryLabels: Record<string, string> = {
character: "角色",
environment: "场景",
wardrobe: "服饰",
prop: "道具",
weapon: "武器",
style: "画风",
other: "其他",
};
export const categoryLabels: Record<string, string> = ASSET_CATEGORY_LABELS;
export const mediaLabels: Record<string, string> = {
image: "图片",
@@ -64,7 +57,7 @@ const sourceTypeLabels: Record<string, string> = {
};
export function categoryLabel(value: string) {
return categoryLabels[value] || "其他";
return sharedAssetCategoryLabel(value);
}
export function statusLabel(value: string) {

View File

@@ -1,14 +1,16 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { App, Button, Empty, Form, Image, Input, InputNumber, Segmented, Select, Tag } from "antd";
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
import { Box, ChevronDown, ChevronLeft, ChevronRight, Download, Film, Image as ImageIcon, Layers3, List, Play, Plus, RefreshCcw, Save, Search, SlidersHorizontal, Trash2, UsersRound, WandSparkles, X } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { ModelPicker } from "@/components/model-picker";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationOptions } from "@/lib/model-capabilities";
import { modelQuoteRequest } from "@/lib/model-pricing";
import { customShotTitle, formatShotOrdinal, normalizeDefaultShotTitle } from "@/lib/shot-label";
import { modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
import { formatVideoResolutionLabel } from "@/lib/video-generation-options";
import { submitBackendGenerationTask } from "@/services/api/generation-task";
@@ -80,7 +82,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
const [form] = Form.useForm<ShotEditorValues>();
const watchedDuration = Form.useWatch("durationSeconds", form);
const watchedTitle = Form.useWatch("title", form);
const [leftTab, setLeftTab] = useState<"assets" | "episodes" | "shots">("assets");
const [leftTab, setLeftTab] = useState<"assets" | "episodes" | "shots">("episodes");
const [previewTab, setPreviewTab] = useState<"latest" | "history">("latest");
const [previewArtifactId, setPreviewArtifactId] = useState("");
const [editorDirty, setEditorDirty] = useState(false);
@@ -219,7 +221,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
: shotDurationSeconds;
const videoPrompt = ensureShotAssetMentionPrompt(revision?.videoPrompt || "", shotAssetReferenceContext.mentionReferences);
form.setFieldsValue({
title: selectedShot?.title || "",
title: normalizeDefaultShotTitle(selectedShot?.title, Math.max(0, shotIndex)),
plotDescription: revision?.plotDescription || selectedShot?.description || "",
action: revision?.action || "",
dialogue: revision?.dialogue || "",
@@ -438,7 +440,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
size="small"
value={leftTab}
onChange={(value) => setLeftTab(value as typeof leftTab)}
options={[{ value: "assets", label: "资产" }, { value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }]}
options={[{ value: "episodes", label: "章节" }, { value: "shots", label: "镜头" }, { value: "assets", label: "资产" }]}
/>
<div className="workflow-library-scroll thin-scrollbar">
{leftTab === "assets" ? <AssetLibrary detail={detail} referenceByVersionId={referenceByVersionId} changing={changeAssetBinding.isPending} onToggle={(asset, reference) => changeAssetBinding.mutate({ asset, reference })} /> : null}
@@ -450,8 +452,8 @@ export default function WorkflowProductionWorkbench(props: Props) {
<section className="workflow-shot-editor">
<header className="workflow-panel-header">
<div className="workflow-shot-heading">
<span className="workflow-shot-number">SC.{String(shotIndex + 1).padStart(2, "0")}</span>
<h2>{watchedTitle || selectedShot.title || "未命名镜头"}</h2>
<span className="workflow-shot-number">{formatShotOrdinal(shotIndex)}</span>
{customShotTitle(watchedTitle || selectedShot.title, shotIndex) ? <h2>{customShotTitle(watchedTitle || selectedShot.title, shotIndex)}</h2> : null}
<Tag className="!m-0" color={saveShot.isPending ? "blue" : editorDirty ? "orange" : revision ? "green" : undefined}>{saveShot.isPending ? "保存中" : editorDirty ? "有未保存修改" : revision ? "已保存" : "草稿"}</Tag>
</div>
<div className="flex items-center gap-1"><span className="mr-1 text-[var(--fs-micro)] text-foreground/45">{shotIndex + 1} / {shots.length}</span><Button type="text" size="small" icon={<ChevronLeft className="size-4" />} disabled={shotIndex <= 0} onClick={() => selectRelativeShot(-1)} aria-label="上一个镜头" /><Button type="text" size="small" icon={<ChevronRight className="size-4" />} disabled={shotIndex >= shots.length - 1} onClick={() => selectRelativeShot(1)} aria-label="下一个镜头" /></div>
@@ -590,12 +592,15 @@ function WorkflowDisclosure({ icon, title, description, summary, className = "",
function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { detail: ProjectDetail; referenceByVersionId: Map<string, ShotAssetReference>; changing: boolean; onToggle: (asset: ProjectAsset, reference?: ShotAssetReference) => void }) {
const [category, setCategory] = useState("all");
const [keyword, setKeyword] = useState("");
const debouncedKeyword = useDebouncedValue(keyword.trim(), 250);
const [page, setPage] = useState(1);
const pageSize = 30;
const assetsQuery = useQuery({
queryKey: ["project", detail.project.id, "assets", "workflow-library", category, page, pageSize],
queryFn: () => listProjectAssetsPage(detail.project.id, { page, pageSize, category: category === "all" ? undefined : category }),
queryKey: ["project", detail.project.id, "assets", "workflow-library", category, debouncedKeyword, page, pageSize],
queryFn: () => listProjectAssetsPage(detail.project.id, { page, pageSize, category: category === "all" ? undefined : category, query: debouncedKeyword || undefined }),
});
useEffect(() => setPage(1), [debouncedKeyword]);
const assetsPage = assetsQuery.data?.assets || [];
const groups = useMemo(() => {
const map = new Map<string, ProjectAsset[]>();
@@ -604,7 +609,7 @@ function AssetLibrary({ detail, referenceByVersionId, changing, onToggle }: { de
}, [assetsPage]);
const total = assetsQuery.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / pageSize));
return <div className="workflow-asset-groups"><Select size="small" className="mb-2 w-full" value={category} options={[{ value: "all", label: `全部资产(${Object.values(assetsQuery.data?.categoryCounts || {}).reduce((sum, count) => sum + count, 0)})` }, ...Object.entries(assetsQuery.data?.categoryCounts || {}).filter(([, count]) => count > 0).map(([value, count]) => ({ value, label: `${assetCategoryLabel(value)}(${count})` }))]} onChange={(value) => { setCategory(value); setPage(1); }} />{assetsQuery.isLoading ? <div className="py-6 text-center text-xs text-foreground/45">正在读取资产…</div> : groups.length ? groups.map(([groupCategory, assets]) => <section key={groupCategory}><h3>{assetCategoryLabel(groupCategory)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={changing || !asset.primaryVersionId} aria-pressed={active} onClick={() => onToggle(asset, reference)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定 · 点击取消" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="项目还没有资产" />}{total > pageSize ? <div className="mt-3 flex items-center justify-between border-t border-border/60 pt-2 text-[var(--fs-micro)] text-foreground/45"><span>{page}/{pages} · 共 {total} 项</span><span className="flex gap-1"><Button type="text" size="small" icon={<ChevronLeft className="size-3.5" />} disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))} /><Button type="text" size="small" icon={<ChevronRight className="size-3.5" />} disabled={page >= pages} onClick={() => setPage((value) => Math.min(pages, value + 1))} /></span></div> : null}</div>;
return <div className="workflow-asset-groups"><Input allowClear size="small" className="mb-2 w-full" value={keyword} onChange={(event) => setKeyword(event.target.value)} prefix={<Search className="size-3.5 text-foreground/35" />} placeholder="搜索资产名称" aria-label="搜索镜头资产" /><Select size="small" className="mb-2 w-full" value={category} options={[{ value: "all", label: `全部资产(${Object.values(assetsQuery.data?.categoryCounts || {}).reduce((sum, count) => sum + count, 0)})` }, ...Object.entries(assetsQuery.data?.categoryCounts || {}).filter(([, count]) => count > 0).map(([value, count]) => ({ value, label: `${assetCategoryLabel(value)}(${count})` }))]} onChange={(value) => { setCategory(value); setPage(1); }} />{assetsQuery.isLoading ? <div className="py-6 text-center text-xs text-foreground/45">正在读取资产…</div> : groups.length ? groups.map(([groupCategory, assets]) => <section key={groupCategory}><h3>{assetCategoryLabel(groupCategory)} <span>({assets.length})</span></h3><div className="workflow-asset-list">{assets.map((asset) => { const reference = asset.primaryVersionId ? referenceByVersionId.get(asset.primaryVersionId) : undefined; const active = Boolean(reference); const previewUrl = assetPreviewUrl(asset); return <button key={asset.id} type="button" className={`workflow-asset-row ${active ? "is-active" : ""}`} disabled={changing || !asset.primaryVersionId} aria-pressed={active} onClick={() => onToggle(asset, reference)}><span className="workflow-asset-thumb">{previewUrl ? <img src={previewUrl} alt="" loading="lazy" /> : asset.category === "character" ? <UsersRound /> : asset.mediaType === "image" ? <ImageIcon /> : <Box />}</span><span className="min-w-0 flex-1"><strong>{asset.title}</strong><small>{active ? "已绑定 · 点击取消" : `${assetCategoryLabel(asset.category)} · v${Math.max(1, asset.versionCount)}`}</small></span>{active ? <span className="workflow-bound-dot" /> : null}</button>; })}</div></section>) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={debouncedKeyword ? "没有找到匹配资产" : "项目还没有资产"} />}{total > pageSize ? <div className="mt-3 flex items-center justify-between border-t border-border/60 pt-2 text-[var(--fs-micro)] text-foreground/45"><span>{page}/{pages} · 共 {total} 项</span><span className="flex gap-1"><Button type="text" size="small" icon={<ChevronLeft className="size-3.5" />} disabled={page <= 1} onClick={() => setPage((value) => Math.max(1, value - 1))} /><Button type="text" size="small" icon={<ChevronRight className="size-3.5" />} disabled={page >= pages} onClick={() => setPage((value) => Math.min(pages, value + 1))} /></span></div> : null}</div>;
}
function ShotAssetMentionTextarea({ value = "", onChange = () => undefined, references, variant = "motion" }: { value?: string; onChange?: (value: string) => void; references: ReturnType<typeof buildShotAssetReferenceContext>["mentionReferences"]; variant?: "scene" | "motion" }) {
@@ -630,7 +635,7 @@ function EpisodeLibrary({ detail, activeUnitId, projectId, activeStage }: { deta
}
function ShotLibrary({ detail, shots, selectedShotId, onSelectShot }: { detail: ProjectDetail; shots: ProjectShot[]; selectedShotId: string; onSelectShot: (id: string) => void }) {
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{String(index + 1).padStart(2, "0")}</span><span className="min-w-0 flex-1"><strong>{shot.title}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
return <div className="workflow-simple-list">{shots.map((shot, index) => { const video = currentArtifact(detail, shot.id, "video"); return <button key={shot.id} type="button" className={shot.id === selectedShotId ? "is-active" : ""} onClick={() => onSelectShot(shot.id)}><span>{formatShotOrdinal(index)}</span><span className="min-w-0 flex-1"><strong>{customShotTitle(shot.title, index) || "未命名"}</strong><small>{formatDuration(shot.durationMs)}</small></span><ArtifactStatus artifact={video} compact /></button>; })}</div>;
}
function BoundAssets({ detail, shotId, changing, onUnlink }: { detail: ProjectDetail; shotId: string; changing: boolean; onUnlink: (reference: ShotAssetReference) => void }) {
@@ -693,7 +698,7 @@ function TimelineShot({ artifactType, detail, shot, task, submitting, index, sel
const revision = currentRevision(detail, shot);
const stageLabel = artifactType === "video" ? "镜头视频" : artifactType === "action_board" ? "动作预演" : "分镜画面";
const cameraMeta = [revision?.shotSize, revision?.cameraMovement].filter(Boolean).join(" · ") || "等待补充镜头参数";
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>SC.{String(index + 1).padStart(2, "0")}</strong><b>{formatDuration(shot.durationMs)}</b></span><em className="workflow-timeline-title">{shot.title}</em><small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={submitting ? "queued" : task?.status} compact /></span></span></button>;
return <button type="button" className={`workflow-timeline-shot ${selected ? "is-active" : ""}`} onClick={onSelect}><span className="workflow-timeline-media">{preview?.resourceId ? preview.type === "video" ? <video src={resourceFileUrl(preview.resourceId)} muted preload="metadata" /> : <img src={resourceFileUrl(preview.resourceId)} alt="" loading="lazy" /> : <Film />}</span><span className="workflow-timeline-copy"><span className="workflow-timeline-heading"><strong>{formatShotOrdinal(index)}</strong><b>{formatDuration(shot.durationMs)}</b></span>{customShotTitle(shot.title, index) ? <em className="workflow-timeline-title">{customShotTitle(shot.title, index)}</em> : null}<small className="workflow-timeline-meta">{cameraMeta}</small><span className="workflow-timeline-status"><span>{stageLabel}{stateArtifact ? ` · v${stateArtifact.version}` : ""}</span><ArtifactStatus artifact={stateArtifact} taskStatus={submitting ? "queued" : task?.status} compact /></span></span></button>;
}
function revisionInput(values: ShotEditorValues): ShotRevisionInput {

View File

@@ -3,6 +3,7 @@ import { Tag } from "antd";
import { Check, CircleAlert } from "lucide-react";
import { Link } from "react-router";
import { assetCategoryLabel as sharedAssetCategoryLabel } from "@/lib/asset-category";
import type { ProjectDetail, ProjectShot, ShotArtifact, ShotRevision, WorkflowStep } from "@/services/api/projects";
import type { TaskStatus } from "@/services/api/task-center";
@@ -71,7 +72,7 @@ export function artifactTypeForStage(stage: ShortDramaWorkflowStage) {
}
export function assetCategoryLabel(category: string) {
return ({ character: "角色", environment: "场景", wardrobe: "服饰", accessory: "配饰", prop: "道具", weapon: "武器", style: "画风" } as Record<string, string>)[category] || category || "其他";
return sharedAssetCategoryLabel(category);
}
export function formatDuration(durationMs: number) {

View File

@@ -1,4 +1,5 @@
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { assetCategoryLabel, normalizeAssetCategory } from "@/lib/asset-category";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources";
import type { CharacterRepresentation, ProjectAsset, ProjectDetail, ShotAssetReference } from "@/services/api/projects";
import type { AssetCategory } from "@/stores/use-asset-store";
@@ -23,8 +24,6 @@ export type ShotPromptAssetReference = {
dialogue?: string;
};
const assetCategories = new Set<AssetCategory>(["character", "environment", "wardrobe", "prop", "weapon", "style", "other"]);
export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: string): ShotAssetReferenceContext {
const assetByVersionId = new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset]));
const seenAssetIds = new Set<string>();
@@ -194,11 +193,11 @@ function dialogueForCharacter(dialogue: string, characterName: string) {
}
function projectAssetCategory(value: string): AssetCategory {
return assetCategories.has(value as AssetCategory) ? value as AssetCategory : "other";
return normalizeAssetCategory(value);
}
function projectAssetCategoryLabel(value: string) {
return ({ environment: "场景", wardrobe: "服装", prop: "道具", weapon: "武器", style: "风格", other: "资产" } as Record<string, string>)[value] || "资产";
return assetCategoryLabel(value);
}
function characterVoiceDescription(asset: ProjectAsset) {

View File

@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { Clock3, Film, Layers3, PackageCheck } from "lucide-react";
import { Link } from "react-router";
import { ASSET_CATEGORIES } from "@/lib/asset-category";
import { listProjectAssetsPage, type ProjectDetail } from "@/services/api/projects";
import { assetCategoryLabel, formatDuration, MetricCard, StageHeading } from "./workflow-shared";
@@ -16,8 +17,7 @@ export function AssetsStage({ detail, projectId, unitId }: { detail: ProjectDeta
const candidates = detail.assetCandidates.filter((item) => !item.unitId || item.unitId === unitId);
const assetCountsQuery = useQuery({ queryKey: ["project", projectId, "assets", "workflow-counts"], queryFn: () => listProjectAssetsPage(projectId, { page: 1, pageSize: 1 }) });
const confirmedCounts = assetCountsQuery.data?.categoryCounts || {};
const categories = ["character", "environment", "wardrobe", "prop", "weapon"];
return <section className="mx-auto max-w-6xl"><StageHeading eyebrow="02 / 资产拆分" title="确认镜头真正会使用的资产" description="角色、场景、服饰、配饰与武器先建立稳定版本,镜头再绑定具体版本。" /><div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{categories.map((category) => { const confirmed = confirmedCounts[category] || 0; const pending = candidates.filter((item) => item.category === category && item.status === "pending_confirmation").length; return <div key={category} className="border-t border-border/70 py-4"><div className="text-xs font-medium text-foreground/55">{assetCategoryLabel(category)}</div><div className="mt-3 text-2xl font-semibold">{assetCountsQuery.isLoading ? "—" : confirmed}</div><div className="mt-1 text-[var(--fs-micro)] text-foreground/42">已确认 · {pending} 待处理</div></div>; })}</div><div className="mt-5 flex items-center justify-between border-y border-border/70 py-5"><div><h3 className="text-sm font-semibold">资产库承担版本确认与设定维护</h3><p className="mt-1 text-xs text-foreground/48">确认后可直接在分镜工作台左栏绑定到镜头。</p></div><Link to={`/projects/${projectId}/assets`}><Button type="primary">打开资产库</Button></Link></div></section>;
return <section className="mx-auto max-w-6xl"><StageHeading eyebrow="02 / 资产拆分" title="确认镜头真正会使用的资产" description="角色、场景、道具、素材与其他资产先建立稳定版本,镜头再绑定具体版本。" /><div className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-5">{ASSET_CATEGORIES.map((category) => { const confirmed = confirmedCounts[category] || 0; const pending = candidates.filter((item) => item.category === category && item.status === "pending_confirmation").length; return <div key={category} className="border-t border-border/70 py-4"><div className="text-xs font-medium text-foreground/55">{assetCategoryLabel(category)}</div><div className="mt-3 text-2xl font-semibold">{assetCountsQuery.isLoading ? "—" : confirmed}</div><div className="mt-1 text-[var(--fs-micro)] text-foreground/42">已确认 · {pending} 待处理</div></div>; })}</div><div className="mt-5 flex items-center justify-between border-y border-border/70 py-5"><div><h3 className="text-sm font-semibold">资产库承担版本确认与设定维护</h3><p className="mt-1 text-xs text-foreground/48">确认后可直接在分镜工作台左栏绑定到镜头。</p></div><Link to={`/projects/${projectId}/assets`}><Button type="primary">打开资产库</Button></Link></div></section>;
}
export function DeliveryStage({ detail, unitId }: { detail: ProjectDetail; unitId: string }) {

View File

@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { App, Button, Empty } from "antd";
import { Link } from "react-router";
import { formatShotOrdinal } from "@/lib/shot-label";
import { saveProjectShot, type ProjectDetail } from "@/services/api/projects";
import { AssetsStage, DeliveryStage, StoryStage } from "./workflow-stage-views";
@@ -49,7 +50,7 @@ export default function ProjectWorkflowView({ detail, projectId, unitId, stage }
if (!unit) throw new Error("请先添加章节");
return saveProjectShot(projectId, {
unitId: unit.id,
title: `SC.${String(shots.length + 1).padStart(2, "0")}`,
title: formatShotOrdinal(shots.length),
description: "待补充分镜画面",
position: shots.length,
durationMs: 3000,

View File

@@ -11,6 +11,7 @@ import {
type ProjectDetail,
type ShotAssetReference,
} from "./projects";
import { normalizeAssetCategory, type AssetCategory } from "@/lib/asset-category";
export const projectAgentToolNames = [
"project_get_context",
@@ -47,7 +48,7 @@ export async function runProjectAgentTool(name: ProjectAgentToolName, rawInput:
}
if (name === "project_extract_asset_candidates") {
const candidates = Array.isArray(rawInput.candidates) ? rawInput.candidates : [];
return createProjectAssetCandidates(projectId, candidates.filter(isCandidateInput));
return createProjectAssetCandidates(projectId, candidates.map(normalizeAgentCandidateInput).filter((candidate): candidate is NonNullable<typeof candidate> => Boolean(candidate)), "agent");
}
if (name === "project_confirm_asset_candidate") {
return confirmProjectAssetCandidate(projectId, String(rawInput.candidateId || ""), String(rawInput.assetId || "") || undefined);
@@ -68,7 +69,7 @@ export async function runProjectAgentTool(name: ProjectAgentToolName, rawInput:
return updateWorkflowStep(projectId, String(rawInput.stepId || ""), { status: "running" });
}
if (name === "project_link_asset") {
return linkProjectAsset(projectId, { assetId: String(rawInput.assetId || ""), category: String(rawInput.category || "other") });
return linkProjectAsset(projectId, { assetId: String(rawInput.assetId || ""), category: normalizeAssetCategory(rawInput.category) });
}
if (name === "project_upsert_asset_version") {
return createProjectAssetVersion(projectId, String(rawInput.assetId || ""), { prompt: String(rawInput.prompt || ""), definitionJson: typeof rawInput.definitionJson === "string" ? rawInput.definitionJson : undefined, note: String(rawInput.note || "") });
@@ -79,10 +80,21 @@ export async function runProjectAgentTool(name: ProjectAgentToolName, rawInput:
throw new Error(`未知项目工具:${name}`);
}
function isCandidateInput(value: unknown): value is { unitId?: string; shotId?: string; name: string; category: string; details?: Record<string, unknown> } {
if (!value || typeof value !== "object") return false;
const agentCandidateCategories = new Set<AssetCategory>(["environment", "prop", "material", "other"]);
function normalizeAgentCandidateInput(value: unknown): { unitId?: string; shotId?: string; name: string; category: AssetCategory; details?: Record<string, unknown> } | null {
if (!value || typeof value !== "object") return null;
const item = value as Record<string, unknown>;
return typeof item.name === "string" && typeof item.category === "string";
if (typeof item.name !== "string" || typeof item.category !== "string") return null;
const category = normalizeAssetCategory(item.category);
if (!agentCandidateCategories.has(category)) return null;
return {
name: item.name,
category,
...(typeof item.unitId === "string" ? { unitId: item.unitId } : {}),
...(typeof item.shotId === "string" ? { shotId: item.shotId } : {}),
...(item.details && typeof item.details === "object" && !Array.isArray(item.details) ? { details: item.details as Record<string, unknown> } : {}),
};
}
function isShotInput(value: unknown): value is { id?: string; unitId?: string; title: string; description?: string; position?: number; durationMs?: number; status?: string } {

View File

@@ -1,4 +1,5 @@
import { apiClient, request } from "@/services/api/request";
import { normalizeAssetCategory, type AssetCategory } from "@/lib/asset-category";
import type { GenerationTask } from "@/services/api/task-center";
const api = apiClient;
@@ -56,7 +57,7 @@ export type ProjectAsset = {
id: string;
title: string;
mediaType: string;
category: string;
category: AssetCategory;
status: string;
primaryVersionId?: string;
versionCount: number;
@@ -121,7 +122,7 @@ export type ProjectAssetCandidate = {
unitId?: string;
shotId?: string;
name: string;
category: string;
category: AssetCategory;
status: "pending_confirmation" | "confirmed" | "ignored" | string;
detailsJson: string;
resolvedAssetId?: string;
@@ -365,21 +366,30 @@ export function listProjectAssetsPage(projectId: string, options: { page?: numbe
status: options.status || undefined,
folder_id: options.folderId,
q: options.query || undefined,
} }));
} })).then((page) => ({ ...page, assets: page.assets.map(normalizeProjectAsset) }));
}
export function listProjectAssets(projectId: string) {
return request<{ assets: ProjectAsset[] }>(api.get(`/projects/${encodeURIComponent(projectId)}/assets`));
return request<{ assets: ProjectAsset[] }>(api.get(`/projects/${encodeURIComponent(projectId)}/assets`)).then((result) => ({ assets: result.assets.map(normalizeProjectAsset) }));
}
export function listProjectAssetCandidates(projectId: string, options: { page?: number; pageSize?: number; unitId?: string; status?: string; category?: string } = {}) {
export function listProjectAssetCandidates(projectId: string, options: { page?: number; pageSize?: number; unitId?: string; status?: string; category?: string; query?: string } = {}) {
return request<ProjectAssetCandidatePage>(api.get(`/projects/${encodeURIComponent(projectId)}/asset-candidates`, { params: {
page: options.page || 1,
page_size: options.pageSize || 100,
unit_id: options.unitId || undefined,
status: options.status || undefined,
category: options.category || undefined,
} }));
q: options.query || undefined,
} })).then((page) => ({ ...page, candidates: page.candidates.map(normalizeProjectAssetCandidate) }));
}
function normalizeProjectAsset(asset: ProjectAsset): ProjectAsset {
return { ...asset, category: normalizeAssetCategory(asset.category), usages: Array.isArray(asset.usages) ? asset.usages : [] };
}
function normalizeProjectAssetCandidate(candidate: ProjectAssetCandidate): ProjectAssetCandidate {
return { ...candidate, category: normalizeAssetCategory(candidate.category) };
}
function normalizeProjectDetail(detail: ProjectDetail): ProjectDetail {
@@ -391,8 +401,7 @@ function normalizeProjectDetail(detail: ProjectDetail): ProjectDetail {
: [];
const assets = Array.isArray(detail.assets)
? detail.assets.map((asset) => ({
...asset,
usages: Array.isArray(asset.usages) ? asset.usages : [],
...normalizeProjectAsset(asset),
...(asset.character ? {
character: {
...asset.character,
@@ -415,7 +424,7 @@ function normalizeProjectDetail(detail: ProjectDetail): ProjectDetail {
shotRevisions: Array.isArray(detail.shotRevisions) ? detail.shotRevisions : [],
shotArtifacts: Array.isArray(detail.shotArtifacts) ? detail.shotArtifacts : [],
shotReferences: Array.isArray(detail.shotReferences) ? detail.shotReferences : [],
assetCandidates: Array.isArray(detail.assetCandidates) ? detail.assetCandidates : [],
assetCandidates: Array.isArray(detail.assetCandidates) ? detail.assetCandidates.map(normalizeProjectAssetCandidate) : [],
tasks: Array.isArray(detail.tasks) ? detail.tasks : [],
};
}
@@ -468,7 +477,7 @@ export function unlinkCanvasProject(projectId: string, canvasId: string) {
return request<{ canvasId: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/canvases/${encodeURIComponent(canvasId)}`));
}
export function linkProjectAsset(projectId: string, input: { assetId: string; category: string; folderId?: string }, signal?: AbortSignal) {
export function linkProjectAsset(projectId: string, input: { assetId: string; category: AssetCategory; folderId?: string }, signal?: AbortSignal) {
return request<{ asset: ProjectAsset }>(api.post(`/projects/${encodeURIComponent(projectId)}/assets`, input, { signal }));
}
@@ -476,7 +485,7 @@ export function unlinkProjectAsset(projectId: string, assetId: string) {
return request<{ id: string }>(api.delete(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`));
}
export function updateProjectAssetCategory(projectId: string, assetId: string, category: string, signal?: AbortSignal) {
export function updateProjectAssetCategory(projectId: string, assetId: string, category: AssetCategory, signal?: AbortSignal) {
return request<{ asset: ProjectAsset }>(api.patch(`/projects/${encodeURIComponent(projectId)}/assets/${encodeURIComponent(assetId)}`, { category }, { signal }));
}
@@ -560,8 +569,8 @@ export function createShotRevision(projectId: string, shotId: string, input: Sho
return request<{ shot: ProjectShot; revision: ShotRevision }>(api.post(`/projects/${encodeURIComponent(projectId)}/shots/${encodeURIComponent(shotId)}/revisions`, input));
}
export function createProjectAssetCandidates(projectId: string, candidates: Array<{ unitId?: string; shotId?: string; name: string; category: string; details?: Record<string, unknown> }>) {
return request<{ candidates: ProjectAssetCandidate[] }>(api.post(`/projects/${encodeURIComponent(projectId)}/asset-candidates`, { candidates }));
export function createProjectAssetCandidates(projectId: string, candidates: Array<{ unitId?: string; shotId?: string; name: string; category: AssetCategory; details?: Record<string, unknown> }>, source?: "chapter_character_extract" | "agent") {
return request<{ candidates: ProjectAssetCandidate[] }>(api.post(`/projects/${encodeURIComponent(projectId)}/asset-candidates`, { candidates, source }));
}
export function confirmProjectAssetCandidate(projectId: string, candidateId: string, assetId?: string) {

View File

@@ -14,6 +14,7 @@ import { createLocalDreaminaTaskEffectStore } from "@/services/local-dreamina-ge
import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects";
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { getActiveUserScope } from "@/lib/user-scope";
import { normalizeAssetCategory } from "@/lib/asset-category";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { useAssetStore, type AssetCategory, type AssetStatus, type NewAsset } from "@/stores/use-asset-store";
import type { CanvasNodeData } from "@/types/canvas";
@@ -92,7 +93,7 @@ async function syncAssetToProject(assetId: string, domainProjectId: string, cate
domainProjectId,
{
assetId: asset.id,
category: category || asset.category || "other",
category: normalizeAssetCategory(category || asset.category),
folderId,
},
signal,
@@ -102,7 +103,7 @@ async function syncAssetToProject(assetId: string, domainProjectId: string, cate
if (folderId !== undefined && (linked.folderId || "") !== folderId) linked = (await moveProjectAsset(domainProjectId, asset.id, folderId, signal)).asset;
throwIfAborted(signal);
useAssetStore.getState().updateAsset(asset.id, {
category: linked.category as AssetCategory,
category: normalizeAssetCategory(linked.category),
status: linked.status as AssetStatus,
primaryVersionId: linked.primaryVersionId,
metadata: { ...asset.metadata, projectIds: [...new Set([...linkedProjectIds, domainProjectId])] },

View File

@@ -2,6 +2,7 @@ import { create } from "zustand";
import { persist, type PersistStorage, type StorageValue } from "zustand/middleware";
import { nanoid } from "nanoid";
import type { AssetCategory } from "@/lib/asset-category";
import { normalizeAssetRecord, parseAssetStorageDocument, rebaseAssetSnapshot, serializeAssetStorageDocument, type AssetStorageDocument } from "@/lib/asset-storage-revision";
import { parseCanvasStorageDocument } from "@/lib/canvas/canvas-storage-revision";
import { localForageStorageForScope } from "@/lib/localforage-storage";
@@ -13,7 +14,7 @@ import { flushGenerationAssetStorageLocks, insertOrReturnGenerationAsset, withGe
import { CANVAS_STORE_KEY, commitPendingCanvasStorePersistenceLocked, pendingCanvasStorePersistence, withCanvasStorePersistenceLock } from "@/stores/canvas/use-canvas-store";
export type AssetKind = "text" | "image" | "video" | "audio" | "model" | "entity";
export type AssetCategory = "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "other";
export type { AssetCategory } from "@/lib/asset-category";
export type AssetStatus = "draft" | "review" | "confirmed" | "archived";
export type TextAsset = AssetBase<"text"> & { data: { content: string } };
export type ImageAsset = AssetBase<"image"> & { data: { dataUrl: string; storageKey?: string; width: number; height: number; bytes: number; mimeType: string } };

View File

@@ -1,4 +1,5 @@
import type { CanvasColorGrade } from "@/lib/canvas/canvas-color-grade";
import type { AssetCategory } from "@/lib/asset-category";
import type { PortraitTextureSettings } from "@/lib/canvas/canvas-portrait-texture";
import type { StyleExecutionPlan } from "@/lib/canvas/style-profile";
import type { PortraitClearanceNodeState } from "@/lib/portrait-clearance/contracts";
@@ -256,7 +257,7 @@ export type CanvasNodeMetadata = {
hasAudio?: boolean;
assetId?: string;
assetTags?: string[];
assetCategory?: "character" | "environment" | "wardrobe" | "prop" | "weapon" | "style" | "other";
assetCategory?: AssetCategory;
workflowKind?: CanvasWorkflowKind;
workflowTitle?: string;
workflowDescription?: string;