feat(*): 分镜与资产 - 完善五类资产、候选去重、搜索、中文镜头编号与分类整理
This commit is contained in:
40
web/src/lib/asset-category.ts
Normal file
40
web/src/lib/asset-category.ts
Normal 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)];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
15
web/src/lib/shot-label.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user