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

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

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;
}