feat(canvas): 画布能力 - 完善 LibTV 导入与资源预览
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
type AssetMediaPreviewProps = {
|
||||
@@ -32,7 +33,8 @@ export function AssetMediaPreview({ asset, alt, className = "", fallback = null
|
||||
);
|
||||
}
|
||||
|
||||
const storageKey = asset.kind === "image" ? asset.data.storageKey : undefined;
|
||||
const imageUrl = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
|
||||
if (!imageUrl) return fallback;
|
||||
return <img src={imageUrl} alt={alt} loading="lazy" decoding="async" className={className} />;
|
||||
if (!imageUrl && !storageKey) return fallback;
|
||||
return <CachedResourceImage storageKey={storageKey} src={imageUrl} alt={alt} loading="lazy" decoding="async" className={className} fallback={fallback} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2,
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import { AssetMediaPreview } from "@/components/asset-media-preview";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
|
||||
@@ -13,6 +14,7 @@ export type AssetLibraryPickerItem = {
|
||||
kindLabel: string;
|
||||
asset?: Asset;
|
||||
imageUrl?: string;
|
||||
imageStorageKey?: string;
|
||||
imageFit?: "cover" | "contain";
|
||||
description?: string;
|
||||
searchText?: string;
|
||||
@@ -210,7 +212,7 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
|
||||
return (
|
||||
<button type="button" className={cn("asset-picker-card", selected && "is-selected", disabled && "is-disabled")} onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
|
||||
<div className="asset-picker-card-media">
|
||||
{item.imageUrl ? <img src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
|
||||
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
|
||||
<span className="asset-picker-card-check"><Check /></span>
|
||||
<span className="asset-picker-card-kind">{item.kindLabel}</span>
|
||||
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
|
||||
|
||||
75
web/src/components/cached-resource-image.tsx
Normal file
75
web/src/components/cached-resource-image.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
|
||||
|
||||
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
|
||||
storageKey?: string;
|
||||
src?: string;
|
||||
fallback?: ReactNode;
|
||||
eager?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。
|
||||
* 普通外链、data URL 和本地 Blob URL 不经过资源缓存,保持原有行为。
|
||||
*/
|
||||
export function CachedResourceImage({ storageKey, src = "", fallback = null, eager = false, onError, ...props }: CachedResourceImageProps) {
|
||||
const remoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const targetRef = useRef<HTMLSpanElement>(null);
|
||||
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
|
||||
const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src);
|
||||
const [cacheFailed, setCacheFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteResource || eager) {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
const image = targetRef.current;
|
||||
if (!image || typeof IntersectionObserver === "undefined") {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setNearViewport(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
}, { rootMargin: "240px" });
|
||||
observer.observe(image);
|
||||
return () => observer.disconnect();
|
||||
}, [eager, remoteResource]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setCacheFailed(false);
|
||||
if (!remoteResource || !storageKey) {
|
||||
setCachedSrc(src);
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
if (!nearViewport) {
|
||||
setCachedSrc("");
|
||||
return () => { cancelled = true; };
|
||||
}
|
||||
|
||||
setCachedSrc("");
|
||||
const resolve = cacheResourceObjectUrl(storageKey);
|
||||
void resolve.then((url) => {
|
||||
if (!cancelled) setCachedSrc(url || src);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCacheFailed(true);
|
||||
setCachedSrc(src);
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [nearViewport, remoteResource, src, storageKey]);
|
||||
|
||||
if (!remoteResource) return <img {...props} src={cachedSrc} onError={onError} />;
|
||||
return (
|
||||
<span ref={targetRef} className="cached-resource-image-shell">
|
||||
{cachedSrc && !(cacheFailed && !src) ? <img {...props} src={cachedSrc} onError={onError} /> : fallback}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
|
||||
import { Crosshair, FolderOpen, ImageIcon, Images, PanelLeftClose, Plus, Search, X } from "lucide-react";
|
||||
|
||||
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -229,7 +230,7 @@ function AssetTrayRow({ title, imageUrl, storageKey, icon, active = false, dragg
|
||||
onDragStartCapture={onDragStart}
|
||||
>
|
||||
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--dock-item-radius)] border" style={{ background: theme.node.fill, borderColor: theme.toolbar.border }}>
|
||||
{imageUrl ? <img src={imageUrl} alt="" width={36} height={36} className="size-full object-cover" draggable={false} /> : <ImageIcon className="size-3.5 opacity-55" />}
|
||||
{imageUrl || storageKey ? <CachedResourceImage storageKey={storageKey} src={imageUrl} alt="" width={36} height={36} className="size-full object-cover" draggable={false} fallback={<ImageIcon className="size-3.5 opacity-55" />} /> : <ImageIcon className="size-3.5 opacity-55" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[var(--fs-tiny)] font-semibold">{title}</span>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
|
||||
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
|
||||
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
|
||||
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import { buildLibTVImagePreviewUrl } from "@/lib/canvas/libtv-import";
|
||||
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
@@ -417,10 +418,11 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null);
|
||||
const nearViewport = useNearViewport(imageContainerRef);
|
||||
const { url, loading } = useNodeResourceUrl(node, nearViewport);
|
||||
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
|
||||
return (
|
||||
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
|
||||
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
|
||||
{url ? <img src={url} alt={node.title} loading="lazy" decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
|
||||
</div>
|
||||
</BatchFrame>
|
||||
);
|
||||
@@ -432,7 +434,9 @@ function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode
|
||||
|
||||
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
|
||||
const storageKey = node.metadata?.storageKey || "";
|
||||
const fallback = node.metadata?.content || "";
|
||||
const content = node.metadata?.content || "";
|
||||
const fallback = node.metadata?.previewContent
|
||||
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
|
||||
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
|
||||
const [loading, setLoading] = useState(isRemoteResource && eager);
|
||||
|
||||
@@ -36,6 +36,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
|
||||
kindLabel: character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片",
|
||||
asset: media,
|
||||
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : undefined,
|
||||
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
|
||||
imageFit: character ? "contain" : "cover",
|
||||
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : undefined,
|
||||
searchText: media?.tags.join(" "),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveBackendApiUrl } from "@/stores/use-config-store";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
|
||||
@@ -109,16 +110,16 @@ function ProjectPreview({ project }: { project: CanvasProject }) {
|
||||
.flatMap((node) => {
|
||||
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
|
||||
const url = getNodeMediaUrl(node);
|
||||
return isPreviewUrl(url) ? [{ node, url }] : [];
|
||||
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
|
||||
});
|
||||
const media = mediaNodes.find(({ node }) => node.type === CanvasNodeType.Image) || mediaNodes[0];
|
||||
if (media) {
|
||||
const { node, url } = media;
|
||||
const { node, url, storageKey } = media;
|
||||
return (
|
||||
<div className="canvas-project-media size-full">
|
||||
{node.type === CanvasNodeType.Video
|
||||
? <div className="canvas-project-video size-full"><Video className="size-8" aria-label={node.title || "项目视频"} /></div>
|
||||
: <img src={url} alt={node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
|
||||
: <CachedResourceImage storageKey={storageKey} src={url} alt={node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { createCanvasShare, deleteCanvasShare, getCanvasShare, type CanvasShareStatus } from "@/services/api/canvas-share";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
|
||||
export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { projectId: string; open: boolean; onClose: () => void; beforeCreate: () => Promise<void> }) {
|
||||
export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { projectId: string; open: boolean; onClose: () => void; beforeCreate: () => Promise<boolean | void> }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [share, setShare] = useState<CanvasShareStatus>({ enabled: false });
|
||||
@@ -40,7 +40,8 @@ export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { p
|
||||
const create = async (rotate = false) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await beforeCreate();
|
||||
const saved = await beforeCreate();
|
||||
if (saved === false) return;
|
||||
const result = await createCanvasShare(projectId, { expiresDays, rotate });
|
||||
setShare(result.share);
|
||||
const url = result.share.token ? `${window.location.origin}/share/canvas/${result.share.token}` : "";
|
||||
|
||||
@@ -35,12 +35,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const options = useMemo(() => {
|
||||
const filtered = selectableModelsByCapability(config, capability);
|
||||
const current = value?.trim();
|
||||
const currentIncluded = current ? filtered.includes(current) : true;
|
||||
return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model))));
|
||||
}, [capability, config, value]);
|
||||
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
|
||||
const optionGroups = useMemo(() => {
|
||||
const channelGroups = config.channels
|
||||
.map((channel) => ({
|
||||
@@ -53,13 +48,15 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.models.length);
|
||||
const groupedModels = new Set(channelGroups.flatMap((group) => group.models.flatMap((modelGroup) => modelGroup.models)));
|
||||
const ungroupedModels = options.filter((model) => !groupedModels.has(model));
|
||||
return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: groupModelsByDisplayName(config, ungroupedModels) }] : channelGroups;
|
||||
// options 已由当前有效渠道重建;任何无法解析渠道的旧值都直接丢弃,
|
||||
// 不再显示“其他模型 / 未指定渠道”这种不可用入口。
|
||||
return channelGroups;
|
||||
}, [config, options]);
|
||||
const current = value || "";
|
||||
const resolvedCurrent = resolveCompatibleModel(config, current, requirements) || current;
|
||||
const currentPrice = modelMenuPrice(config, resolvedCurrent);
|
||||
const storedCurrent = value?.trim() || "";
|
||||
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, requirements) || storedCurrent;
|
||||
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
|
||||
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
|
||||
const currentPrice = modelMenuPrice(config, current);
|
||||
const creationVariant = variant === "creation";
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user