112 lines
6.3 KiB
TypeScript
112 lines
6.3 KiB
TypeScript
import { Modal } from "antd";
|
|
import { Tooltip } from "@/components/ui/base/tooltip";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import { Image as ImageIcon, Music2, Play, UserRound } from "lucide-react";
|
|
|
|
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
|
|
import { isStoryboardPreviewAsset } from "@/lib/canvas/canvas-storyboard-materializer";
|
|
import { resolveMediaUrl } from "@/services/file-storage";
|
|
import { CanvasNodeType, type CanvasNodeData, type StoryboardAssetBinding } from "@/types/canvas";
|
|
|
|
const ROLE_LABELS: Record<StoryboardAssetBinding["role"], string> = {
|
|
character: "角色",
|
|
environment: "场景",
|
|
wardrobe: "服装",
|
|
prop: "道具",
|
|
weapon: "武器",
|
|
style: "风格",
|
|
motion: "动态",
|
|
audio: "音频",
|
|
};
|
|
|
|
export function StoryboardAssetsCell({ bindings, nodes, limit = 4 }: { bindings: StoryboardAssetBinding[]; nodes: CanvasNodeData[]; limit?: number }) {
|
|
const [previewNode, setPreviewNode] = useState<CanvasNodeData | null>(null);
|
|
const nodeById = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
|
|
const assets = bindings.map((binding) => ({ binding, node: nodeById.get(binding.nodeId) })).filter((item) => !item.node || isStoryboardPreviewAsset(item.node));
|
|
const visible = assets.slice(0, limit);
|
|
const hiddenCount = Math.max(0, assets.length - visible.length);
|
|
|
|
if (!assets.length) return <span className="text-[var(--fs-caption)] text-foreground/35">未关联</span>;
|
|
return (
|
|
<>
|
|
<div className="flex min-w-0 items-center gap-1.5" aria-label={`已关联 ${assets.length} 个资产`}>
|
|
{visible.map(({ binding, node }) => (
|
|
<Tooltip key={binding.nodeId} title={`${node?.title || "资产已失效"} · ${ROLE_LABELS[binding.role]}`}>
|
|
<button
|
|
type="button"
|
|
data-icon-only
|
|
disabled={!node}
|
|
className="relative grid size-9 shrink-0 place-items-center overflow-hidden rounded-md border border-foreground/10 bg-foreground/[0.035] text-foreground/45 outline-none transition enabled:hover:border-foreground/30 enabled:hover:text-foreground/70 focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] disabled:cursor-not-allowed"
|
|
aria-label={`预览${node?.title || "失效资产"}`}
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (node) setPreviewNode(node);
|
|
}}
|
|
>
|
|
{node ? <AssetThumbnail node={node} /> : <ImageIcon className="size-4" />}
|
|
<span className="absolute bottom-0.5 right-0.5 rounded bg-black/65 px-1 text-[8px] leading-3 text-white">{ROLE_LABELS[binding.role].slice(0, 1)}</span>
|
|
</button>
|
|
</Tooltip>
|
|
))}
|
|
{hiddenCount ? <span className="shrink-0 text-[var(--fs-caption)] font-medium text-foreground/45">+{hiddenCount}</span> : null}
|
|
</div>
|
|
<AssetPreviewModal node={previewNode} onClose={() => setPreviewNode(null)} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function AssetThumbnail({ node }: { node: CanvasNodeData }) {
|
|
const source = useNodeMediaSource(node.type === CanvasNodeType.Video ? null : node);
|
|
if (node.type === CanvasNodeType.Audio) return <Music2 className="size-4" />;
|
|
if (node.metadata?.workflowKind === "character" && !source) return <UserRound className="size-4" />;
|
|
if (node.type === CanvasNodeType.Video) {
|
|
return (
|
|
<>
|
|
<CanvasVideoPreviewImage node={node} alt="" loading="lazy" decoding="async" draggable={false} className="size-full object-cover" fallback={<Play className="size-4" />} />
|
|
<span className="absolute inset-0 grid place-items-center bg-black/15"><Play className="size-3.5 fill-white text-white" /></span>
|
|
</>
|
|
);
|
|
}
|
|
return source ? <img src={source} alt="" loading="lazy" decoding="async" draggable={false} className="size-full object-cover" /> : <ImageIcon className="size-4" />;
|
|
}
|
|
|
|
function AssetPreviewModal({ node, onClose }: { node: CanvasNodeData | null; onClose: () => void }) {
|
|
const source = useNodeMediaSource(node);
|
|
return (
|
|
<Modal title={node?.title || "资产预览"} open={Boolean(node)} onCancel={onClose} footer={null} width={880} centered destroyOnHidden>
|
|
{node ? (
|
|
<div className="grid min-h-56 place-items-center overflow-hidden rounded-lg bg-black/[0.035] p-3 dark:bg-white/[0.035]" data-canvas-no-zoom>
|
|
{node.type === CanvasNodeType.Video && source ? <video src={source} controls autoPlay playsInline className="max-h-[68vh] max-w-full rounded-md" />
|
|
: node.type === CanvasNodeType.Audio && source ? <audio src={source} controls autoPlay className="w-full max-w-xl" />
|
|
: source ? <img src={source} alt={node.title || "资产预览"} className="max-h-[68vh] max-w-full object-contain" />
|
|
: <span className="text-sm text-foreground/45">当前资产没有可预览的媒体内容</span>}
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function useNodeMediaSource(node: CanvasNodeData | null) {
|
|
const fallback = node ? node.metadata?.workflowKind === "character"
|
|
? node.metadata.characterCoverUrl || ""
|
|
: node.type === CanvasNodeType.Drawing
|
|
? node.metadata?.drawingPreviewUrl || node.metadata?.content || ""
|
|
: node.metadata?.content || "" : "";
|
|
const storageKey = node?.metadata?.storageKey;
|
|
const [source, setSource] = useState(fallback);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setSource(fallback);
|
|
if (storageKey) void resolveMediaUrl(storageKey, fallback).then((url) => {
|
|
if (!cancelled) setSource(url || fallback);
|
|
}).catch(() => {
|
|
if (!cancelled) setSource(fallback);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [fallback, storageKey]);
|
|
return source;
|
|
}
|