feat(canvas): 画布能力 - 完善 LibTV 导入与资源预览
This commit is contained in:
@@ -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}` : "";
|
||||
|
||||
Reference in New Issue
Block a user