feat(canvas): 画布资源 - 缩略图字段与缓存加载支持

This commit is contained in:
ddcat666
2026-09-27 22:26:32 +08:00
parent bf2682c38e
commit f9b8c5a43e
9 changed files with 312 additions and 101 deletions

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 40
const CurrentSchemaVersion int64 = 41
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -28,6 +28,7 @@ const cloudAgentGeminiCacheIdentityChecksum = "sha256:cloud-agent-gemini-cache-i
const prefixedIDSequenceReconcileChecksum = "sha256:prefixed-id-sequence-reconcile-v38-20260926"
const skillLibraryCategoriesChecksum = "sha256:skill-library-categories-v39-20260926"
const builtinSkillTombstonesChecksum = "sha256:builtin-skill-tombstones-v40-20260927"
const resourceThumbnailChecksum = "sha256:resource-thumbnail-v41-20260927"
const postgresSchemaMigrationLockID int64 = 73123910420260830
@@ -135,6 +136,9 @@ var schemaMigrations = []migration{
{version: 40, name: "builtin_skill_tombstones", checksum: builtinSkillTombstonesChecksum, apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.BuiltinSkillTombstone{})
}},
{version: 41, name: "resource_thumbnail", checksum: resourceThumbnailChecksum, apply: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.Resource{})
}},
}
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {

View File

@@ -51,6 +51,12 @@ type Resource struct {
PlaybackStatus string `json:"playbackStatus" gorm:"index;size:24"`
PlaybackObjectKey string `json:"playbackObjectKey"`
PlaybackError string `json:"playbackError" gorm:"type:text"`
// 单档画布展示缩略图;对象键固定由 ObjectKey 派生,不单独持久化。
ThumbnailStatus string `json:"thumbnailStatus" gorm:"size:24"`
ThumbnailMimeType string `json:"thumbnailMimeType" gorm:"size:120"`
ThumbnailSize int64 `json:"thumbnailSize"`
ThumbnailWidth int `json:"thumbnailWidth"`
ThumbnailHeight int `json:"thumbnailHeight"`
// UploadKey 是客户端逻辑上传身份的摘要;NULL 表示不参与幂等约束。
UploadKey *string `json:"-" gorm:"size:64;uniqueIndex:idx_resources_user_upload_key,priority:2"`
Error string `json:"error"`

View File

@@ -1008,6 +1008,14 @@ func (r *Repository) SaveResource(resource *model.Resource) error {
return r.db.Save(resource).Error
}
// UpdateResourceThumbnail only updates a still-ready row and cannot resurrect a deleted resource.
func (r *Repository) UpdateResourceThumbnail(userID string, id string, values map[string]any) (bool, error) {
result := r.db.Model(&model.Resource{}).
Where("id = ? AND user_id = ? AND status = ?", id, userID, model.ResourceStatusReady).
Updates(values)
return result.RowsAffected == 1, result.Error
}
func (r *Repository) ResourceByUploadKey(userID string, uploadKey string) (*model.Resource, error) {
var resource model.Resource
if err := r.db.First(&resource, "user_id = ? AND upload_key = ?", userID, uploadKey).Error; err != nil {

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey } from "@/services/api/resources";
import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey, type ResourceAccessVariant } from "@/services/api/resources";
import { getActiveUserScope } from "@/lib/user-scope";
import { resolveImageUrl } from "@/services/image-storage";
import { prepareCanvasImage } from "@/services/canvas-image-loader";
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
storageKey?: string;
@@ -9,6 +11,7 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
fallback?: ReactNode;
loadingFallback?: ReactNode;
eager?: boolean;
variant?: ResourceAccessVariant;
};
/**
@@ -16,14 +19,19 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
* Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把
* `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。
*/
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) {
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, variant = "original", onError, ...props }: CachedResourceImageProps) {
const resourceId = resourceIdFromStorageKey(storageKey);
const remoteResource = Boolean(resourceId);
const localImageResource = Boolean(storageKey && storageKey.startsWith("image:"));
const scope = getActiveUserScope();
const identity = `${scope}:${resourceId || storageKey || src}`;
const targetRef = useRef<HTMLSpanElement>(null);
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src);
const [cacheFailed, setCacheFailed] = useState(false);
const [displayed, setDisplayed] = useState<{ identity: string; src: string } | null>(null);
const displayedRef = useRef(displayed);
displayedRef.current = displayed;
const [failedIdentity, setFailedIdentity] = useState("");
const cachedSrc = displayed?.identity === identity ? displayed.src : "";
useEffect(() => {
if (!remoteResource || eager) {
@@ -49,75 +57,83 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
}, [eager, remoteResource]);
useEffect(() => {
if (remoteResource && !nearViewport) return;
const controller = new AbortController();
let cancelled = false;
setCacheFailed(false);
if (remoteResource && resourceId) {
if (!nearViewport) {
setCachedSrc("");
return () => {
cancelled = true;
};
const isCurrent = () => !cancelled && !controller.signal.aborted && getActiveUserScope() === scope;
const load = async () => {
let candidate = src;
if (remoteResource && resourceId) {
try {
const access = await getResourceAccess(storageKey, "display", variant);
candidate = resolveResourceAccessURL(access.url);
} catch (error) {
if (variant !== "thumbnail") throw error;
const access = await getResourceAccess(storageKey, "display", "original");
candidate = resolveResourceAccessURL(access.url);
}
} else if (localImageResource && storageKey) {
candidate = (await resolveImageUrl(storageKey, src)) || src;
}
void getResourceAccess(storageKey, "display")
.then((access) => {
if (!cancelled) setCachedSrc(resolveResourceAccessURL(access.url));
})
.catch(() => {
if (!cancelled) setCacheFailed(true);
});
return () => {
cancelled = true;
};
}
if (localImageResource && storageKey) {
void resolveImageUrl(storageKey, src)
.then((url) => {
if (!cancelled) setCachedSrc(url || src);
})
.catch(() => {
if (!cancelled) setCachedSrc(src);
});
return () => {
cancelled = true;
};
}
setCachedSrc(src);
if (!candidate) throw new Error("图片地址为空");
try {
await prepareCanvasImage(candidate, controller.signal);
} catch (error) {
if (!remoteResource || variant !== "thumbnail") {
if (candidate !== src && src) await prepareCanvasImage(src, controller.signal);
else throw error;
} else {
const access = await getResourceAccess(storageKey, "display", "original");
const originalURL = resolveResourceAccessURL(access.url);
if (!originalURL || originalURL === candidate) throw error;
await prepareCanvasImage(originalURL, controller.signal);
candidate = originalURL;
}
}
if (isCurrent()) {
setDisplayed({ identity, src: candidate });
setFailedIdentity("");
}
};
void load().catch(() => {
if (isCurrent() && displayedRef.current?.identity !== identity) setFailedIdentity(identity);
});
return () => {
cancelled = true;
controller.abort();
};
}, [localImageResource, nearViewport, remoteResource, resourceId, src, storageKey]);
}, [identity, localImageResource, nearViewport, remoteResource, resourceId, scope, src, storageKey, variant]);
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
void resolveImageUrl(storageKey)
.then((url) => {
if (url && url !== cachedSrc) {
setCachedSrc(url);
const controller = new AbortController();
void prepareCanvasImage(url, controller.signal).then(() => setDisplayed({ identity, src: url })).catch(() => {
setFailedIdentity(identity);
onError?.(e);
});
return;
}
setCacheFailed(true);
setFailedIdentity(identity);
onError?.(e);
})
.catch(() => {
setCacheFailed(true);
setFailedIdentity(identity);
onError?.(e);
});
return;
}
setCacheFailed(true);
setFailedIdentity(identity);
onError?.(e);
};
if (!remoteResource) {
if (cacheFailed && fallback) return <>{fallback}</>;
return <img {...props} src={cachedSrc} onError={handleImgError} />;
}
if (failedIdentity === identity && fallback) return <>{fallback}</>;
if (!remoteResource) return <img {...props} src={cachedSrc || undefined} onError={handleImgError} />;
return (
<span ref={targetRef} className="cached-resource-image-shell">
{cachedSrc && !cacheFailed ? <img {...props} src={cachedSrc} onError={handleImgError} /> : cacheFailed ? fallback : loadingFallback}
{cachedSrc ? <img {...props} src={cachedSrc} onError={handleImgError} /> : failedIdentity === identity ? fallback : loadingFallback}
</span>
);
}

View File

@@ -282,7 +282,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)]" style={{ background: theme.node.fill }}>
{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" />}
{imageUrl || storageKey ? <CachedResourceImage storageKey={storageKey} src={imageUrl} variant="thumbnail" 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>

View File

@@ -68,6 +68,7 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
const overlayRef = useRef<OverlayScene | null>(null);
const viewportRef = useRef(props.viewport);
const rasterViewportRef = useRef(props.viewport);
const containerSizeRef = useRef({ width: 1, height: 1 });
const propsRef = useRef(props);
propsRef.current = props;
@@ -80,12 +81,14 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
const underlay = createUnderlayScene(underlayHost);
const overlay = createOverlayScene(overlayHost);
containerSizeRef.current = { width: Math.max(1, container.clientWidth), height: Math.max(1, container.clientHeight) };
underlayRef.current = underlay;
overlayRef.current = overlay;
const resize = () => {
const rect = container.getBoundingClientRect();
const size = { width: Math.max(1, rect.width), height: Math.max(1, rect.height), pixelRatio: canvasPixelRatio() };
containerSizeRef.current = { width: Math.max(1, rect.width), height: Math.max(1, rect.height) };
const size = { ...containerSizeRef.current, pixelRatio: canvasPixelRatio() };
underlay.leafer.resize(size);
overlay.leafer.resize(size);
syncViewport(rasterViewportRef.current, size.width, size.height, underlay, overlay, propsRef.current);
@@ -98,10 +101,9 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
window.addEventListener("resize", resize);
const unsubscribe = subscribeCanvasGraphicsViewportPreview(container, (next) => {
viewportRef.current = next;
const rect = container.getBoundingClientRect();
if (isViewportPreview(container, next, rasterViewportRef.current)) {
if (shouldRebaseCanvasRaster(next, rasterViewportRef.current)) {
syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current);
syncViewport(next, containerSizeRef.current.width, containerSizeRef.current.height, underlay, overlay, propsRef.current);
rasterViewportRef.current = next;
forceSceneRender(underlay, overlay);
resetScenePreview(underlay, overlay);
@@ -112,7 +114,7 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
}
resetScenePreview(underlay, overlay);
if (sameCanvasViewport(next, rasterViewportRef.current)) return;
syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current);
syncViewport(next, containerSizeRef.current.width, containerSizeRef.current.height, underlay, overlay, propsRef.current);
rasterViewportRef.current = next;
});
const unsubscribeSelection = subscribeCanvasSelectionPreview(container, (selection) => {
@@ -156,10 +158,9 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
const container = props.containerRef.current;
if (!underlay || !overlay || !container) return;
viewportRef.current = props.viewport;
const rect = container.getBoundingClientRect();
const hadPreview = hasScenePreview(underlay, overlay);
if (hadPreview || !sameCanvasViewport(props.viewport, rasterViewportRef.current)) {
syncViewport(props.viewport, rect.width, rect.height, underlay, overlay, props);
syncViewport(props.viewport, containerSizeRef.current.width, containerSizeRef.current.height, underlay, overlay, props);
}
rasterViewportRef.current = props.viewport;
// 新视口先同步到真实 DPR backing store,再撤销交互期的合成变换,避免出现跳帧。
@@ -172,8 +173,7 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps)
const overlay = overlayRef.current;
const container = props.containerRef.current;
if (!underlay || !overlay || !container) return;
const rect = container.getBoundingClientRect();
syncViewport(rasterViewportRef.current, rect.width, rect.height, underlay, overlay, props);
syncViewport(rasterViewportRef.current, containerSizeRef.current.width, containerSizeRef.current.height, underlay, overlay, props);
if (isViewportPreview(container, viewportRef.current, rasterViewportRef.current)) {
applyScenePreview(viewportRef.current, rasterViewportRef.current, underlay, overlay);
}

View File

@@ -22,6 +22,9 @@ import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import type { GenerationTask } from "@/services/api/task-center";
import { resolveMediaUrl } from "@/services/file-storage";
import { prepareCanvasImage } from "@/services/canvas-image-loader";
import { getResourceAccess, resolveResourceAccessURL } from "@/services/api/resources";
import { getActiveUserScope } from "@/lib/user-scope";
import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
@@ -71,9 +74,13 @@ export type CanvasNodeContentProps = {
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
// Keep the image renderer mounted while node LOD changes. Visibility still gates first load.
if (props.node.type === CanvasNodeType.Image && props.renderLOD !== "full" && (props.node.metadata?.content || props.node.metadata?.storageKey)) {
return <ImageNodeContent {...props} />;
}
if (props.renderLOD === "shell") return <CanvasNodeShellContent node={props.node} theme={props.theme} />;
if (props.renderLOD === "preview") return <CanvasNodePreviewContent node={props.node} theme={props.theme} />;
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
const hasCustomContent =
props.node.type === CanvasNodeType.Config ||
props.node.type === CanvasNodeType.Script ||
@@ -489,7 +496,8 @@ function skillOutputModeLabel(mode?: string) {
}
function ImageNodeContent(props: CanvasNodeContentProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const hasImage = Boolean(props.node.metadata?.content || props.node.metadata?.storageKey);
if (!hasImage && props.isBatchRoot) {
const content =
props.node.metadata?.status === "loading" ? (
<LoadingContent node={props.node} theme={props.theme} />
@@ -512,7 +520,7 @@ function ImageNodeContent(props: CanvasNodeContentProps) {
</BatchFrame>
);
}
if (!props.node.metadata?.content) return <EmptyImageContent {...props} />;
if (!hasImage) return <EmptyImageContent {...props} />;
return (
<ImageContent
batchPreviewNodes={props.batchPreviewNodes}
@@ -851,7 +859,7 @@ function ImageContent({
}: Pick<CanvasNodeContentProps, "node" | "theme" | "isBatchRoot" | "batchCount" | "batchPreviewNodes" | "batchExpanded" | "batchOpening" | "batchRecovering" | "onToggleBatch">) {
const imageContainerRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(imageContainerRef);
const { url, loading } = useNodeResourceUrl(node, nearViewport);
const { url, loading, originalWidth, originalHeight } = useNodeResourceUrl(node, nearViewport, "thumbnail");
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
const { updateMediaNode } = useCanvasNodeActions();
const measuredSizeRef = useRef<{ width: number; height: number } | null>(null);
@@ -866,11 +874,11 @@ function ImageContent({
* 存过 naturalWidth 的旧节点永远得不到修正(第一版就是这么写的,所以没生效)。
* 手动拉过(manualSize)或自由比例(freeResize)的节点只补记尺寸、不动宽高。
*/
const fitToImage = (element: HTMLImageElement) => {
const fitToImage = (element: HTMLImageElement, sourceSize?: { width?: number; height?: number }) => {
// LibTV 已提供原图尺寸和节点尺寸;960px 缩略图不能反向覆盖这些数据。
if (importedFromLibTV) return;
const naturalWidth = element.naturalWidth;
const naturalHeight = element.naturalHeight;
const naturalWidth = sourceSize?.width || element.naturalWidth;
const naturalHeight = sourceSize?.height || element.naturalHeight;
if (!naturalWidth || !naturalHeight) return;
if (measuredSizeRef.current?.width === naturalWidth && measuredSizeRef.current.height === naturalHeight) return;
measuredSizeRef.current = { width: naturalWidth, height: naturalHeight };
@@ -893,29 +901,108 @@ function ImageContent({
return (
<BatchFrame batchPreviewNodes={batchPreviewNodes} 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()}
onLoad={(event) => fitToImage(event.currentTarget)}
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 ref={imageContainerRef} className="relative h-full w-full overflow-hidden rounded-[var(--node-radius)]">
<RetainedCanvasImage
identity={`${getActiveUserScope()}:${node.id}:${node.metadata?.storageKey || node.metadata?.content || "empty"}`}
src={url}
storageKey={node.metadata?.storageKey}
fallbackSrc={node.metadata?.content || ""}
originalSize={{ width: originalWidth, height: originalHeight }}
alt={node.title}
fitToImage={fitToImage}
className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`}
loading={loading}
theme={theme}
/>
</div>
</BatchFrame>
);
}
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
/** A stable visible img is updated only after a queued candidate has loaded and decoded. */
function RetainedCanvasImage({ identity, src, storageKey, fallbackSrc, originalSize, alt, fitToImage, className, loading, theme }: {
identity: string;
src: string;
storageKey?: string;
fallbackSrc: string;
originalSize: { width?: number; height?: number };
alt?: string;
fitToImage: (image: HTMLImageElement, size?: { width?: number; height?: number }) => void;
className: string;
loading: boolean;
theme: CanvasTheme;
}) {
const [displayed, setDisplayed] = useState<{ identity: string; src: string } | null>(null);
const [preparing, setPreparing] = useState(false);
const displayedRef = useRef(displayed);
displayedRef.current = displayed;
const originalWidth = originalSize.width;
const originalHeight = originalSize.height;
const currentRef = useRef({ identity, src, storageKey, fallbackSrc, originalSize, fitToImage });
currentRef.current = { identity, src, storageKey, fallbackSrc, originalSize, fitToImage };
const currentDisplayedSrc = displayed?.identity === identity ? displayed.src : "";
useEffect(() => {
const controller = new AbortController();
const target = currentRef.current;
if (!target.src || (displayedRef.current?.identity === identity && displayedRef.current.src === target.src)) {
setPreparing(false);
return () => controller.abort();
}
setPreparing(true);
const isCurrent = () => !controller.signal.aborted && currentRef.current.identity === identity && currentRef.current.src === target.src;
const prepare = async () => {
try {
const candidate = await prepareCanvasImage(target.src, controller.signal);
if (!isCurrent()) return;
currentRef.current.fitToImage(candidate, target.originalSize);
setDisplayed({ identity, src: target.src });
} catch (error) {
if (controller.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) return;
const resourceId = target.storageKey?.startsWith("resource:") ? target.storageKey.slice("resource:".length) : "";
if (!resourceId) return;
try {
const access = await getResourceAccess(target.storageKey, "display", "original");
const originalURL = resolveResourceAccessURL(access.url);
if (!isCurrent() || !originalURL || originalURL === target.src) return;
const original = await prepareCanvasImage(originalURL, controller.signal);
if (!isCurrent()) return;
currentRef.current.fitToImage(original, { width: access.originalWidth, height: access.originalHeight });
setDisplayed({ identity, src: originalURL });
} catch (fallbackError) {
if (!controller.signal.aborted && !(fallbackError instanceof DOMException && fallbackError.name === "AbortError")) {
// Keep the already visible same-resource image; a first-load failure remains an explicit placeholder.
}
}
} finally {
if (isCurrent()) setPreparing(false);
}
};
void prepare();
return () => controller.abort();
}, [identity, originalHeight, originalWidth, src, storageKey]);
return (
<>
<img
src={currentDisplayedSrc || undefined}
alt={alt || ""}
decoding="async"
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`absolute inset-0 ${className}`}
style={{ visibility: currentDisplayedSrc ? "visible" : "hidden" }}
/>
{!currentDisplayedSrc ? (
<div className="absolute inset-0 grid place-items-center" style={{ color: theme.node.muted }}>
{loading || preparing ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}
</div>
) : null}
</>
);
}
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean, variant: "original" | "thumbnail" = "original") {
const storageKey = node.metadata?.storageKey || "";
const rawContent = node.metadata?.content || "";
const content = node.type === CanvasNodeType.Video && node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent;
@@ -933,39 +1020,36 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
const isLazyVisual = node.type === CanvasNodeType.Image;
const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:"));
const initialUrl = eager && !isRemoteResource && isLazyVisual && isHttpUrl ? fallback : isRemoteResource || isLazyVisual ? "" : fallback;
const [url, setUrl] = useState(() => initialUrl);
const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager);
const scope = getActiveUserScope();
const identity = `${scope}:${node.id}:${storageKey || fallback}`;
const [resolved, setResolved] = useState<{ identity: string; url: string; loading: boolean; originalWidth?: number; originalHeight?: number }>(() => ({ identity, url: initialUrl, loading: !initialUrl && isRemoteResource && eager }));
const current = resolved.identity === identity ? resolved : { identity, url: initialUrl, loading: isRemoteResource && eager };
useEffect(() => {
if (!isRemoteResource) {
setUrl(isLazyVisual && !eager ? "" : fallback);
setLoading(false);
setResolved({ identity, url: isLazyVisual && !eager ? "" : fallback, loading: false });
return;
}
if (!eager) {
setUrl("");
setLoading(false);
setResolved({ identity, url: "", loading: false });
return;
}
let cancelled = false;
setUrl("");
setLoading(true);
void resolveMediaUrl(storageKey, fallback)
.then((resolved) => {
if (!cancelled) setUrl(resolved);
setResolved({ identity, url: "", loading: true });
void getResourceAccess(storageKey, "display", variant)
.then((access) => {
if (!cancelled) setResolved({ identity, url: resolveResourceAccessURL(access.url), loading: false, originalWidth: access.originalWidth, originalHeight: access.originalHeight });
})
.catch(() => {
if (!cancelled) setUrl(fallback);
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!cancelled) setResolved({ identity, url: fallback, loading: false });
});
return () => {
cancelled = true;
};
}, [eager, fallback, isLazyVisual, isRemoteResource, storageKey]);
}, [eager, fallback, identity, isLazyVisual, isRemoteResource, storageKey, variant]);
return { url, loading };
const isThumbnail = variant === "thumbnail";
return { url: current.url, loading: current.loading, originalWidth: isThumbnail ? current.originalWidth : undefined, originalHeight: isThumbnail ? current.originalHeight : undefined };
}
function useNearViewport(ref: RefObject<Element | null>) {

View File

@@ -104,7 +104,7 @@ const resourceCache = new Map<string, RemoteResource>();
const resourceRequests = new Map<string, Promise<RemoteResource>>();
const missingResourceIds = new Set<string>();
export type ResourceAccessPurpose = "display" | "copy" | "download" | "browser-process" | "provider-input";
export type ResourceAccessVariant = "original" | "playback";
export type ResourceAccessVariant = "original" | "playback" | "thumbnail";
export type ResourceAccess = {
resourceId: string;
requestedVariant: ResourceAccessVariant;
@@ -116,6 +116,8 @@ export type ResourceAccess = {
refreshAt: string;
revision: string;
fallbackReason?: string;
originalWidth?: number;
originalHeight?: number;
};
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };

View File

@@ -0,0 +1,91 @@
const MAX_ACTIVE_IMAGE_PREPARATIONS = 4;
const IMAGE_PREPARATION_TIMEOUT_MS = 60_000;
type ImagePreparationJob = {
src: string;
signal: AbortSignal;
resolve: (image: HTMLImageElement) => void;
reject: (error: unknown) => void;
started: boolean;
settled: boolean;
image?: HTMLImageElement;
timeout?: ReturnType<typeof setTimeout>;
onAbort: () => void;
};
const queue: ImagePreparationJob[] = [];
let activePreparations = 0;
/** Load and decode one image under a shared concurrency budget. Queued work is cancellable. */
export function prepareCanvasImage(src: string, signal: AbortSignal, timeoutMs = IMAGE_PREPARATION_TIMEOUT_MS): Promise<HTMLImageElement> {
if (signal.aborted) return Promise.reject(abortError());
return new Promise((resolve, reject) => {
const job: ImagePreparationJob = { src, signal, resolve, reject, started: false, settled: false, onAbort: () => {} };
job.onAbort = () => {
if (job.settled) return;
if (!job.started) {
const index = queue.indexOf(job);
if (index >= 0) queue.splice(index, 1);
settle(job, undefined, abortError());
return;
}
job.image?.removeAttribute("src");
settle(job, undefined, abortError());
};
signal.addEventListener("abort", job.onAbort, { once: true });
queue.push(job);
pump(timeoutMs);
});
}
export function canvasImagePreparationStats() {
return { active: activePreparations, queued: queue.length, limit: MAX_ACTIVE_IMAGE_PREPARATIONS };
}
function pump(timeoutMs: number) {
while (activePreparations < MAX_ACTIVE_IMAGE_PREPARATIONS && queue.length) {
const job = queue.shift()!;
if (job.settled || job.signal.aborted) {
settle(job, undefined, abortError());
continue;
}
job.started = true;
activePreparations += 1;
job.timeout = setTimeout(() => {
job.image?.removeAttribute("src");
settle(job, undefined, new Error("图片加载或解码超时"));
}, timeoutMs);
const image = new Image();
job.image = image;
image.decoding = "async";
image.onload = () => {
if (job.settled || job.signal.aborted) return;
void image.decode().then(
() => settle(job, image),
(error) => settle(job, undefined, error),
);
};
image.onerror = () => settle(job, undefined, new Error("图片加载失败"));
image.src = job.src;
if (image.complete && image.naturalWidth > 0) image.onload?.call(image, new Event("load"));
}
}
function settle(job: ImagePreparationJob, image?: HTMLImageElement, error?: unknown) {
if (job.settled) return;
job.settled = true;
job.signal.removeEventListener("abort", job.onAbort);
if (job.timeout) clearTimeout(job.timeout);
if (job.started) {
activePreparations = Math.max(0, activePreparations - 1);
pump(IMAGE_PREPARATION_TIMEOUT_MS);
}
if (error !== undefined) job.reject(error);
else if (image) job.resolve(image);
else job.reject(new Error("图片准备未完成"));
}
function abortError() {
return new DOMException("图片准备已取消", "AbortError");
}