feat(canvas): 画布资源 - 缩略图字段与缓存加载支持
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
91
web/src/services/canvas-image-loader.ts
Normal file
91
web/src/services/canvas-image-loader.ts
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user