diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index d0f201d34..3bbecd500 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -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 { diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go index 37d280dd1..03697500b 100644 --- a/backend/internal/model/models_project.go +++ b/backend/internal/model/models_project.go @@ -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"` diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index b081094e3..1cf4e235b 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -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 { diff --git a/web/src/components/cached-resource-image.tsx b/web/src/components/cached-resource-image.tsx index 9eab73374..9d4e633d0 100644 --- a/web/src/components/cached-resource-image.tsx +++ b/web/src/components/cached-resource-image.tsx @@ -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, "src"> & { storageKey?: string; @@ -9,6 +11,7 @@ type CachedResourceImageProps = Omit, "src"> fallback?: ReactNode; loadingFallback?: ReactNode; eager?: boolean; + variant?: ResourceAccessVariant; }; /** @@ -16,14 +19,19 @@ type CachedResourceImageProps = Omit, "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(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) => { 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 ; - } + if (failedIdentity === identity && fallback) return <>{fallback}; + if (!remoteResource) return ; return ( - {cachedSrc && !cacheFailed ? : cacheFailed ? fallback : loadingFallback} + {cachedSrc ? : failedIdentity === identity ? fallback : loadingFallback} ); } diff --git a/web/src/components/canvas/canvas-asset-tray.tsx b/web/src/components/canvas/canvas-asset-tray.tsx index 6eb136398..5ab743602 100644 --- a/web/src/components/canvas/canvas-asset-tray.tsx +++ b/web/src/components/canvas/canvas-asset-tray.tsx @@ -282,7 +282,7 @@ function AssetTrayRow({ title, imageUrl, storageKey, icon, active = false, dragg onDragStartCapture={onDragStart} > - {imageUrl || storageKey ? } /> : } + {imageUrl || storageKey ? } /> : } {title} diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx index e4992b79f..96f3e695f 100644 --- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx +++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx @@ -68,6 +68,7 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) const overlayRef = useRef(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); } diff --git a/web/src/components/canvas/canvas-node-content.tsx b/web/src/components/canvas/canvas-node-content.tsx index 853a161d7..f455ae753 100644 --- a/web/src/components/canvas/canvas-node-content.tsx +++ b/web/src/components/canvas/canvas-node-content.tsx @@ -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 ; + // 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 ; + } if (props.renderLOD === "shell") return ; if (props.renderLOD === "preview") return ; - if (props.node.metadata?.fileUpload) return ; 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" ? ( @@ -512,7 +520,7 @@ function ImageNodeContent(props: CanvasNodeContentProps) { ); } - if (!props.node.metadata?.content) return ; + if (!hasImage) return ; return ( ) { const imageContainerRef = useRef(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 ( -
- {url ? ( - {node.title} 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"}`} - /> - ) : ( -
- {loading ? : } -
- )} +
+
); } -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 ( + <> + {alt event.preventDefault()} + className={`absolute inset-0 ${className}`} + style={{ visibility: currentDisplayedSrc ? "visible" : "hidden" }} + /> + {!currentDisplayedSrc ? ( +
+ {loading || preparing ? : } +
+ ) : 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) { diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 99138b1de..56405a70f 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -104,7 +104,7 @@ const resourceCache = new Map(); const resourceRequests = new Map>(); const missingResourceIds = new Set(); 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 } }; diff --git a/web/src/services/canvas-image-loader.ts b/web/src/services/canvas-image-loader.ts new file mode 100644 index 000000000..3ea55d3fe --- /dev/null +++ b/web/src/services/canvas-image-loader.ts @@ -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; + 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 { + 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"); +}