From 9997422fcd9009d335c34c77e2ce03a8087bedf8 Mon Sep 17 00:00:00 2001 From: ddcat666 <26564566+ddcat666@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:54:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20=E8=A7=86=E9=A2=91=E6=92=AD?= =?UTF-8?q?=E6=94=BE=20-=20=E8=B5=84=E6=BA=90=E8=A7=86=E9=A2=91=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=20playback=20=E5=8F=98=E4=BD=93=EF=BC=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=90=8E=E5=88=B7=E6=96=B0=E7=AD=BE=E5=90=8D=E5=B9=B6?= =?UTF-8?q?=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 画布预览、生成回填和项目水合都解析浏览器可播地址;播放失败时绕过展示缓存,等待转码完成后强制刷新 playback 地址。 --- .../canvas/canvas-node-media-content.tsx | 100 +++++++++++++- web/src/components/video-player.tsx | 4 +- .../lib/canvas/canvas-generation-task-sync.ts | 94 ++++++++----- .../lib/canvas/canvas-project-generation.ts | 124 ++++++++++-------- web/src/services/api/resources.ts | 19 ++- web/src/services/canvas-video-preview.ts | 4 +- web/src/services/file-storage.ts | 23 +++- web/test/canvas-media-performance.test.ts | 18 ++- 8 files changed, 285 insertions(+), 101 deletions(-) diff --git a/web/src/components/canvas/canvas-node-media-content.tsx b/web/src/components/canvas/canvas-node-media-content.tsx index c326bfc94..49b91e4ec 100644 --- a/web/src/components/canvas/canvas-node-media-content.tsx +++ b/web/src/components/canvas/canvas-node-media-content.tsx @@ -15,13 +15,13 @@ import { type CanvasNodeData, CanvasNodeType } from "@/types/canvas"; import { CanvasAudioPlayer } from "./canvas-audio-player"; import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import"; import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview"; -import { resolveMediaUrl } from "@/services/file-storage"; +import { resolveVideoMediaUrl } from "@/services/file-storage"; import { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview"; import { type CanvasTheme } from "@/lib/canvas-theme"; import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { getActiveUserScope } from "@/lib/user-scope"; import { prepareCanvasImage } from "@/services/canvas-image-loader"; -import { getResourceAccess, resolveResourceAccessURL } from "@/services/api/resources"; +import { getResourceAccess, refreshResource, resourceIdFromStorageKey, resolveResourceAccessURL } from "@/services/api/resources"; import type { CanvasNodeContentProps } from "./canvas-node-content"; import { ErrorContent, LoadingContent } from "./canvas-node-status-content"; @@ -97,7 +97,7 @@ export function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchP export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) { const playerBoxRef = useRef(null); const { updateMediaNode } = useCanvasNodeActions(); - const { url, loading } = useVideoPlaybackUrl(node, mediaActive); + const { url, loading, onError } = useVideoPlaybackUrl(node, mediaActive); const preview = canvasNodeVideoPreviewReference(node); const subtitleEntries = node.metadata?.subtitleEntries || []; const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); @@ -159,6 +159,7 @@ export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlay {url ? (
setVideoReady(true)} + onError={onError} /> {activeEntry && activeEntry.text.trim() ? : null}
@@ -212,7 +214,7 @@ export function InactiveVideoPreview({ node, theme, onPlay }: Pick resolveMediaUrl(node.metadata?.storageKey, fallback)); + return bindCanvasVideoHoverPreview(element, () => resolveVideoMediaUrl(node.metadata?.storageKey, fallback)); }, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]); useEffect(() => { @@ -313,22 +315,47 @@ export function VideoPreviewPlayButton({ title, onPlay }: { title: string; onPla ); } +const videoPlaybackRecoveryMaxAttempts = 8; +const videoPlaybackRecoveryDelayMs = 1_500; + +function waitForVideoPlaybackRecovery(signal: AbortSignal) { + return new Promise((resolve) => { + let timer = 0; + const finish = () => { + window.clearTimeout(timer); + signal.removeEventListener("abort", finish); + resolve(); + }; + timer = window.setTimeout(finish, videoPlaybackRecoveryDelayMs); + signal.addEventListener("abort", finish, { once: true }); + }); +} + export function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) { const rawContent = node.metadata?.content || ""; const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent; const storageKey = node.metadata?.storageKey || ""; const [url, setUrl] = useState(""); const [loading, setLoading] = useState(false); + const recoveryRef = useRef(null); + const recoveryAttemptedRef = useRef(false); + const sourceGenerationRef = useRef(0); useEffect(() => { + sourceGenerationRef.current += 1; + recoveryAttemptedRef.current = false; + recoveryRef.current?.abort(); + recoveryRef.current = null; let cancelled = false; if (!active) { setUrl(""); setLoading(false); - return; + return () => { + cancelled = true; + }; } setLoading(true); - void resolveMediaUrl(storageKey, fallback) + void resolveVideoMediaUrl(storageKey, fallback) .then((resolved) => { if (!cancelled) setUrl(resolved); }) @@ -340,10 +367,69 @@ export function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) { }); return () => { cancelled = true; + recoveryRef.current?.abort(); }; }, [active, fallback, storageKey]); - return { url, loading }; + const onError = () => { + const resourceId = resourceIdFromStorageKey(storageKey); + if (!active || !resourceId || recoveryAttemptedRef.current) { + if (!resourceId) setUrl(""); + return; + } + recoveryAttemptedRef.current = true; + const controller = new AbortController(); + const generation = sourceGenerationRef.current; + recoveryRef.current?.abort(); + recoveryRef.current = controller; + setUrl(""); + setLoading(true); + + const recover = async () => { + for (let attempt = 0; attempt < videoPlaybackRecoveryMaxAttempts; attempt += 1) { + if (controller.signal.aborted || generation !== sourceGenerationRef.current) return; + if (attempt > 0) await waitForVideoPlaybackRecovery(controller.signal); + if (controller.signal.aborted || generation !== sourceGenerationRef.current) return; + + let resource: Awaited>; + try { + resource = await refreshResource(resourceId); + } catch { + break; + } + const status = String(resource.playbackStatus || "").toLowerCase(); + if (resource.status !== "ready" || status === "none" || status === "failed") break; + if (status !== "ready") { + if (resource.provider !== "local") break; + continue; + } + + try { + const access = await getResourceAccess(storageKey, "display", "playback", "", { forceRefresh: true }); + if (access.actualVariant !== "playback") continue; + const resolved = resolveResourceAccessURL(access.url); + if (!resolved) continue; + if (!controller.signal.aborted && generation === sourceGenerationRef.current) { + setUrl(resolved); + setLoading(false); + } + return; + } catch { + // A ready status can race the access endpoint; the next bounded refresh retries it. + } + } + if (!controller.signal.aborted && generation === sourceGenerationRef.current) { + setUrl(""); + setLoading(false); + } + }; + + void recover().finally(() => { + if (recoveryRef.current === controller) recoveryRef.current = null; + }); + }; + + return { url, loading, onError }; } export function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) { diff --git a/web/src/components/video-player.tsx b/web/src/components/video-player.tsx index ae31046e5..dc0849f3c 100644 --- a/web/src/components/video-player.tsx +++ b/web/src/components/video-player.tsx @@ -24,6 +24,7 @@ type VideoPlayerProps = { hasAudio?: boolean; onCanPlay?: MediaPlayerProps["onCanPlay"]; onPlay?: MediaPlayerProps["onPlay"]; + onError?: MediaPlayerProps["onError"]; }; const zhCNTranslations = { @@ -74,7 +75,7 @@ const supportedVideoMimeTypes = new Set(["video/mp4", "video/webm * 统一视频播放表面,保留原生媒体 URL 契约,同时提供可访问的完整控件布局。 * 画布节点需要隔离播放器手势,避免拖动进度条时被误判为拖动画布。 */ -export function VideoPlayer({ src, mimeType, title = "视频", className, brandColor = "#f5f5f5", preload = "metadata", autoPlay = false, dataCanvasNoZoom = false, compactControls = false, hasAudio, onCanPlay, onPlay }: VideoPlayerProps) { +export function VideoPlayer({ src, mimeType, title = "视频", className, brandColor = "#f5f5f5", preload = "metadata", autoPlay = false, dataCanvasNoZoom = false, compactControls = false, hasAudio, onCanPlay, onPlay, onError }: VideoPlayerProps) { const [detectedHasAudio, setDetectedHasAudio] = useState(undefined); const autoPlayAttemptedRef = useRef(false); const audioProbeGenerationRef = useRef(0); @@ -188,6 +189,7 @@ export function VideoPlayer({ src, mimeType, title = "视频", className, brandC style={{ "--video-brand": brandColor }} onCanPlay={handleCanPlay} onPlay={onPlay} + onError={onError} onLoadedMetadata={(event) => { const provider = event.target.provider; const media = isVideoProvider(provider) ? provider.media : undefined; diff --git a/web/src/lib/canvas/canvas-generation-task-sync.ts b/web/src/lib/canvas/canvas-generation-task-sync.ts index 0cc744132..fd721245f 100644 --- a/web/src/lib/canvas/canvas-generation-task-sync.ts +++ b/web/src/lib/canvas/canvas-generation-task-sync.ts @@ -7,7 +7,7 @@ import { storeGeneratedAudio } from "@/services/api/audio"; import { storeGeneratedVideo } from "@/services/api/video"; import { parseBackendGenerationResult } from "@/services/api/generation-task"; import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center"; -import { resolveMediaUrl, type UploadedFile } from "@/services/file-storage"; +import { resolveMediaUrl, resolveVideoMediaUrl, type UploadedFile } from "@/services/file-storage"; import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage"; import { getCachedResourceBlob } from "@/services/resource-blob-cache"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; @@ -71,14 +71,16 @@ export function videoMetadata(video: UploadedFile): CanvasNodeMetadata { mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs, hasAudio: video.hasAudio, - videoPreview: video.preview ? { - content: video.preview.url, - storageKey: video.preview.storageKey, - width: video.preview.width, - height: video.preview.height, - bytes: video.preview.bytes, - mimeType: video.preview.mimeType, - } : undefined, + videoPreview: video.preview + ? { + content: video.preview.url, + storageKey: video.preview.storageKey, + width: video.preview.width, + height: video.preview.height, + bytes: video.preview.bytes, + mimeType: video.preview.mimeType, + } + : undefined, errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, @@ -113,14 +115,17 @@ function workflowMetadataForResultNode(): Partial { // 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。 // 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。 export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial = {}, fallbackModel?: string): CanvasNodeMetadata { - return commitProducedModel({ - ...node.metadata, - ...workflowMetadataForResultNode(), - ...media, - ...extra, - errorDetails: undefined, - assetId: undefined, - }, fallbackModel); + return commitProducedModel( + { + ...node.metadata, + ...workflowMetadataForResultNode(), + ...media, + ...extra, + errorDetails: undefined, + assetId: undefined, + }, + fallbackModel, + ); } export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node], outputIndex = 0): Promise { @@ -166,9 +171,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: if (mode === "video") { if (!result.video?.storageKey && !result.video?.dataUrl) throw new Error("后端任务没有返回视频"); - const video = result.video.storageKey - ? await cacheGeneratedRemoteVideo({ ...result.video, storageKey: result.video.storageKey }) - : await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" }); + const video = result.video.storageKey ? await cacheGeneratedRemoteVideo({ ...result.video, storageKey: result.video.storageKey }) : await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" }); const videoSize = fitNodeSize(video.width || node.width || VIDEO_NODE_MAX_SIZE.width, video.height || node.height || VIDEO_NODE_MAX_SIZE.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height); const geometry = node.metadata?.locked ? {} @@ -197,7 +200,18 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: return { ...node, type: CanvasNodeType.Text, - metadata: { ...node.metadata, content: result.text, richText: undefined, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined }, + metadata: { + ...node.metadata, + content: result.text, + richText: undefined, + prompt, + ...completedTaskMetadata(task), + status: "success", + errorDetails: undefined, + generationErrorCode: undefined, + resourceReloadAvailable: undefined, + failedPromptFingerprint: undefined, + }, }; } @@ -218,7 +232,7 @@ type GeneratedVideoResult = { async function cacheGeneratedRemoteVideo(result: GeneratedVideoResult & { storageKey: string }): Promise { const blob = await getCachedResourceBlob(result.storageKey); if (!blob) throw new Error("生成视频资源缓存失败,未标记为成功"); - const url = await resolveMediaUrl(result.storageKey, result.dataUrl || ""); + const url = await resolveVideoMediaUrl(result.storageKey, result.dataUrl || ""); if (!url) throw new Error("生成视频资源地址为空,未标记为成功"); return { url, @@ -297,9 +311,15 @@ export function generationTaskOutputsApplied(node: CanvasNodeData, task: Generat } export function shouldRecoverCanvasImageOutputs(node: CanvasNodeData) { - return node.type === CanvasNodeType.Image && node.metadata?.status === "success" && Boolean(node.metadata.taskId) - && node.metadata.generationOutputCount === undefined && !node.metadata.isBatchRoot && !node.metadata.batchRootId - && /(?:midjourney|(?:^|::)mj-)/i.test(node.metadata.producedModel || node.metadata.model || ""); + return ( + node.type === CanvasNodeType.Image && + node.metadata?.status === "success" && + Boolean(node.metadata.taskId) && + node.metadata.generationOutputCount === undefined && + !node.metadata.isBatchRoot && + !node.metadata.batchRootId && + /(?:midjourney|(?:^|::)mj-)/i.test(node.metadata.producedModel || node.metadata.model || "") + ); } async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[]) { @@ -310,7 +330,10 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera for (let index = 0; index < images.length; index += 1) { const id = `${node.id}:task:${task.id}:image:${index}`; const existing = nodes.find((item) => item.id === id); - if (existing) { children.push(existing); continue; } + if (existing) { + children.push(existing); + continue; + } const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index); children.push({ ...child, @@ -318,8 +341,14 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera metadata: { ...child.metadata, assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId, - isBatchRoot: undefined, batchChildIds: undefined, primaryImageId: undefined, imageBatchExpanded: undefined, - batchRootId: node.id, versionOfNodeId: undefined, versionLabel: undefined, versionPrimary: undefined, + isBatchRoot: undefined, + batchChildIds: undefined, + primaryImageId: undefined, + imageBatchExpanded: undefined, + batchRootId: node.id, + versionOfNodeId: undefined, + versionLabel: undefined, + versionPrimary: undefined, agentGenerationContinuation: undefined, }, }); @@ -331,9 +360,12 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera metadata: { ...resultNode.metadata, ...imageMetadata({ - url: primary.metadata!.content!, storageKey: primary.metadata!.storageKey!, - width: primary.metadata!.naturalWidth!, height: primary.metadata!.naturalHeight!, - bytes: primary.metadata!.bytes || 0, mimeType: primary.metadata!.mimeType || "image/png", + url: primary.metadata!.content!, + storageKey: primary.metadata!.storageKey!, + width: primary.metadata!.naturalWidth!, + height: primary.metadata!.naturalHeight!, + bytes: primary.metadata!.bytes || 0, + mimeType: primary.metadata!.mimeType || "image/png", }), assetId: primary.metadata?.assetId, isBatchRoot: true, diff --git a/web/src/lib/canvas/canvas-project-generation.ts b/web/src/lib/canvas/canvas-project-generation.ts index 451f609ce..bc008065b 100644 --- a/web/src/lib/canvas/canvas-project-generation.ts +++ b/web/src/lib/canvas/canvas-project-generation.ts @@ -2,7 +2,7 @@ import { type GenerationTask } from "@/services/api/task-center"; import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task"; import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, normalizeRunningHubCapability, resolveModelRequestConfig, type AiConfig, type WorkflowFieldMapping } from "@/stores/use-config-store"; import { resolveImageUrl, uploadImage } from "@/services/image-storage"; -import { resolveMediaUrl } from "@/services/file-storage"; +import { resolveMediaUrl, resolveVideoMediaUrl } from "@/services/file-storage"; import { resourceIdFromStorageKey } from "@/services/api/resources"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; @@ -359,10 +359,12 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) { return Promise.all( nodes.map(async (node) => { const content = node.metadata?.content; - const videoPreview = node.type === CanvasNodeType.Video && node.metadata?.videoPreview?.storageKey - ? { ...node.metadata.videoPreview, content: await resolveImageUrl(node.metadata.videoPreview.storageKey, node.metadata.videoPreview.content, { cacheMiss: true }) } - : node.metadata?.videoPreview; - if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content), videoPreview } }; + const videoPreview = + node.type === CanvasNodeType.Video && node.metadata?.videoPreview?.storageKey + ? { ...node.metadata.videoPreview, content: await resolveImageUrl(node.metadata.videoPreview.storageKey, node.metadata.videoPreview.content, { cacheMiss: true }) } + : node.metadata?.videoPreview; + if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) + return { ...node, metadata: { ...node.metadata, content: node.type === CanvasNodeType.Video ? await resolveVideoMediaUrl(node.metadata.storageKey, content) : await resolveMediaUrl(node.metadata.storageKey, content), videoPreview } }; if (videoPreview !== node.metadata?.videoPreview) return { ...node, metadata: { ...node.metadata, videoPreview } }; if (node.type === CanvasNodeType.Image && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content, { cacheMiss: true }) } }; if (node.type !== CanvasNodeType.Image || !content) return node; @@ -423,12 +425,11 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u // 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。 const workflowParameters = node?.metadata?.workflowParameters || {}; const runningHubWorkflowId = node?.metadata?.runningHubWorkflowId?.trim() || config.runningHub.workflowId.trim(); - const selectedRunningHubWorkflow = workflowProvider === "runninghub" - ? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind)) - : undefined; - const selectedWorkflowFields = workflowProvider === "runninghub" - ? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson) - : []; + const selectedRunningHubWorkflow = + workflowProvider === "runninghub" + ? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind)) + : undefined; + const selectedWorkflowFields = workflowProvider === "runninghub" ? (selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)) : []; const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters); const workflowParameterValue = (source: string) => { const value = workflowParameters[`source:${source}`]; @@ -437,12 +438,16 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u const requestedConfig: AiConfig = { ...config, quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality, - size: workflowProvider === "model" - ? node?.metadata?.size ?? config.size ?? defaultConfig.size - : workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size, + size: workflowProvider === "model" ? (node?.metadata?.size ?? config.size ?? defaultConfig.size) : workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size, transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground, - videoSeconds: workflowProvider === "model" ? normalizeVideoDuration(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds) : String(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds), - vquality: workflowProvider === "model" ? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality) : String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality), + videoSeconds: + workflowProvider === "model" + ? normalizeVideoDuration(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds) + : String(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds), + vquality: + workflowProvider === "model" + ? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality) + : String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality), videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio, videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark, videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload, @@ -458,46 +463,57 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u // 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型, // 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。 const liveOptions = modelRequestOptions(requestedConfig, mode); - const baseRequirements = requirements?.capability - ? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } } - : { capability: mode, options: liveOptions }; - const model = workflowProvider === "model" - ? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel - : preferredModel; - const generationDefaults: Partial = workflowProvider === "model" - ? resolveModelGenerationDefaults( - config, - model, - mode === "image" ? "image" : mode === "video" ? "video" : undefined, - mode === "image" - ? { - size: node?.metadata?.size, - quality: node?.metadata?.quality, - transparentBackground: node?.metadata?.transparentBackground, - count: requestedConfig.count, - } - : { - size: node?.metadata?.size, - videoSeconds: node?.metadata?.seconds, - vquality: node?.metadata?.vquality, - videoGenerateAudio: node?.metadata?.generateAudio, - videoWatermark: node?.metadata?.watermark, - }, - { - size: requestedConfig.size, - quality: requestedConfig.quality, - transparentBackground: requestedConfig.transparentBackground, - count: requestedConfig.count, - videoSeconds: requestedConfig.videoSeconds, - vquality: requestedConfig.vquality, - videoGenerateAudio: requestedConfig.videoGenerateAudio, - videoWatermark: requestedConfig.videoWatermark, - }, - ) - : {}; + const baseRequirements = requirements?.capability ? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } } : { capability: mode, options: liveOptions }; + const model = workflowProvider === "model" ? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel : preferredModel; + const generationDefaults: Partial = + workflowProvider === "model" + ? resolveModelGenerationDefaults( + config, + model, + mode === "image" ? "image" : mode === "video" ? "video" : undefined, + mode === "image" + ? { + size: node?.metadata?.size, + quality: node?.metadata?.quality, + transparentBackground: node?.metadata?.transparentBackground, + count: requestedConfig.count, + } + : { + size: node?.metadata?.size, + videoSeconds: node?.metadata?.seconds, + vquality: node?.metadata?.vquality, + videoGenerateAudio: node?.metadata?.generateAudio, + videoWatermark: node?.metadata?.watermark, + }, + { + size: requestedConfig.size, + quality: requestedConfig.quality, + transparentBackground: requestedConfig.transparentBackground, + count: requestedConfig.count, + videoSeconds: requestedConfig.videoSeconds, + vquality: requestedConfig.vquality, + videoGenerateAudio: requestedConfig.videoGenerateAudio, + videoWatermark: requestedConfig.videoWatermark, + }, + ) + : {}; const modeCapability = mode === "video" || mode === "audio" ? mode : "image"; const runningHubCapability = normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability)); - const runningHub = { ...config.runningHub, enabled: workflowProvider === "runninghub" && config.runningHub.enabled, selectedKind: selectedRunningHubWorkflow?.kind === "app" ? "app" as const : "workflow" as const, workflowId: runningHubWorkflowId, capability: runningHubCapability, workflows: workflowProvider === "runninghub" ? config.runningHub.workflows.map((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind) ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.runningHub.workflows }; + const runningHub = { + ...config.runningHub, + enabled: workflowProvider === "runninghub" && config.runningHub.enabled, + selectedKind: selectedRunningHubWorkflow?.kind === "app" ? ("app" as const) : ("workflow" as const), + workflowId: runningHubWorkflowId, + capability: runningHubCapability, + workflows: + workflowProvider === "runninghub" + ? config.runningHub.workflows.map((item) => + item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind) + ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : (workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[]), workflowParameters) } + : item, + ) + : config.runningHub.workflows, + }; return { ...requestedConfig, taskWorkflowProvider: workflowProvider, diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index 56405a70f..013cff4f6 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -120,6 +120,11 @@ export type ResourceAccess = { originalHeight?: number; }; +export type ResourceAccessFetchOptions = { + /** Bypass both in-memory and persisted display-address caches. */ + forceRefresh?: boolean; +}; + type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } }; type CachedResourceAccess = { value: ResourceAccess; expiresAt: number }; const accessCache = new Map(); @@ -373,12 +378,24 @@ function assertResourceRequestCurrent(generation: number, scope: string) { } } -export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "") { +export async function getResourceAccess(storageKey: string | undefined, purpose: ResourceAccessPurpose = "display", variant: ResourceAccessVariant = "original", downloadName = "", fetchOptions: ResourceAccessFetchOptions = {}) { const id = resourceIdFromStorageKey(storageKey); if (!id) throw new Error("当前媒体尚未上传到后端资源存储"); const scope = getActiveUserScope(); const generation = accessGeneration; const key = `${scope}:${id}:${purpose}:${variant}:${downloadName}`; + const forceRefresh = fetchOptions.forceRefresh === true; + if (forceRefresh) { + accessCache.delete(key); + accessRequests.delete(key); + if (purpose === "display") { + try { + await localForageStorageForScope(scope).removeItem(persistedDisplayAccessKey(id, variant)); + } catch { + // Persisted address caching is optional; the network request below remains authoritative. + } + } + } const cached = accessCache.get(key); if (cached && cached.expiresAt > Date.now()) return cached.value; const pending = accessRequests.get(key); diff --git a/web/src/services/canvas-video-preview.ts b/web/src/services/canvas-video-preview.ts index da018fa00..dd7f687f2 100644 --- a/web/src/services/canvas-video-preview.ts +++ b/web/src/services/canvas-video-preview.ts @@ -1,5 +1,5 @@ import { captureVideoPoster } from "@/lib/video-poster"; -import { resolveMediaUrl } from "@/services/file-storage"; +import { resolveVideoMediaUrl } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; @@ -24,7 +24,7 @@ export function hydrateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSi async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal): Promise { await waitForBrowserIdle(signal); throwIfAborted(signal); - const source = await resolveMediaUrl(node.metadata?.storageKey, node.metadata?.content || ""); + const source = await resolveVideoMediaUrl(node.metadata?.storageKey, node.metadata?.content || ""); if (!source) return null; const captured = await captureVideoPoster(source, { signal, maxWidth: 400 }); throwIfAborted(signal); diff --git a/web/src/services/file-storage.ts b/web/src/services/file-storage.ts index 0593f8d24..9c5b9e408 100644 --- a/web/src/services/file-storage.ts +++ b/web/src/services/file-storage.ts @@ -3,7 +3,7 @@ import { nanoid } from "nanoid"; import { getActiveUserScope } from "@/lib/user-scope"; import { captureVideoPoster, detectVideoAudioTrackFromBlob } from "@/lib/video-poster"; -import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile } from "@/services/api/resources"; +import { getResourceAccess, resolveResourceAccessURL, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, ResourceUploadError, uploadResourceFile, type ResourceAccessVariant } from "@/services/api/resources"; import { uploadImage, type UploadedImage } from "@/services/image-storage"; import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache"; @@ -93,7 +93,17 @@ export async function uploadMediaFile(input: Blob, prefix = "file", onProgress?: // 缓存只影响后续读取性能,服务端资源已经成功落盘,不得把缓存失败误报为上传失败。 console.warn("预热媒体缓存失败,服务端资源已保存", { resourceId: resource.id, error }); } - return { url: resource.publicUrl || resourceFileUrl(resource.id), storageKey: resourceStorageKey(resource.id), bytes: resource.size || blob.size, mimeType: resource.mimeType || blob.type || "application/octet-stream", width: resource.width || meta.width, height: resource.height || meta.height, durationMs: resource.durationMs || meta.durationMs, hasAudio: meta.hasAudio, preview: poster }; + return { + url: resource.publicUrl || resourceFileUrl(resource.id), + storageKey: resourceStorageKey(resource.id), + bytes: resource.size || blob.size, + mimeType: resource.mimeType || blob.type || "application/octet-stream", + width: resource.width || meta.width, + height: resource.height || meta.height, + durationMs: resource.durationMs || meta.durationMs, + hasAudio: meta.hasAudio, + preview: poster, + }; } catch (error) { // 与图片上传同一套判定:永久性失败必须当场暴露,不能混进“稍后自动同步”。 if (error instanceof ResourceUploadError && error.permanent) throw error; @@ -111,12 +121,12 @@ export async function uploadMediaFile(input: Blob, prefix = "file", onProgress?: } } -export async function resolveMediaUrl(storageKey?: string, fallback = "") { +export async function resolveMediaUrl(storageKey?: string, fallback = "", variant: ResourceAccessVariant = "original") { if (!storageKey) return fallback; const resourceId = resourceIdFromStorageKey(storageKey); if (resourceId) { // 展示直接命中 OSS/CDN;平台资源文件接口只保留给私有源站代理或本地存储兜底。 - return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url); + return resolveResourceAccessURL((await getResourceAccess(storageKey, "display", variant)).url); } const cached = objectUrls.get(storageKey); if (cached) return cached; @@ -127,6 +137,11 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") { return url; } +/** Resolve a video through the browser-compatible playback variant when one exists. */ +export function resolveVideoMediaUrl(storageKey?: string, fallback = "") { + return resolveMediaUrl(storageKey, fallback, "playback"); +} + export async function getMediaBlob(storageKey: string) { if (resourceIdFromStorageKey(storageKey)) return getCachedResourceBlob(storageKey); return store.getItem(storageKey); diff --git a/web/test/canvas-media-performance.test.ts b/web/test/canvas-media-performance.test.ts index 46a7c3af1..6017bddf7 100644 --- a/web/test/canvas-media-performance.test.ts +++ b/web/test/canvas-media-performance.test.ts @@ -16,6 +16,9 @@ const canvasAudioPlayerSource = readFileSync(resolve(import.meta.dir, "../src/co const canvasMentionSource = moduleGroupSource("components/canvas/canvas-resource-mention-textarea.tsx"); const canvasNodeSource = readFileSync(resolve(import.meta.dir, "../src/components/canvas/canvas-node.tsx"), "utf8"); const canvasVideoPreviewSource = readFileSync(resolve(import.meta.dir, "../src/services/canvas-video-preview.ts"), "utf8"); +const fileStorageSource = readFileSync(resolve(import.meta.dir, "../src/services/file-storage.ts"), "utf8"); +const canvasGenerationTaskSource = readFileSync(resolve(import.meta.dir, "../src/lib/canvas/canvas-generation-task-sync.ts"), "utf8"); +const canvasProjectGenerationSource = readFileSync(resolve(import.meta.dir, "../src/lib/canvas/canvas-project-generation.ts"), "utf8"); const browserDownloadSource = readFileSync(resolve(import.meta.dir, "../src/services/browser-download.ts"), "utf8"); const canvasNodeEditorSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-node-editor.ts"), "utf8"); const assetLibrarySource = moduleGroupSource("pages/assets/index.tsx"); @@ -90,7 +93,20 @@ describe("large canvas media rendering", () => { expect(canvasNodeContentSource).toContain("useVideoPlaybackUrl(node, mediaActive)"); expect(canvasNodeContentSource).toContain("onMediaPlayRequest?.(node.id)"); expect(canvasNodeContentSource).toMatch(/autoPlay\s+preload="metadata"/); - expect(canvasNodeContentSource).toContain("resolveMediaUrl(storageKey, fallback)"); + expect(canvasNodeContentSource).toContain("resolveVideoMediaUrl(storageKey, fallback)"); + expect(canvasNodeContentSource).toContain("refreshResource(resourceId)"); + expect(canvasNodeContentSource).toContain('getResourceAccess(storageKey, "display", "playback", "", { forceRefresh: true })'); + expect(canvasNodeContentSource).toContain('status === "none" || status === "failed"'); + }); + + test("routes resource-backed video playback through a browser-compatible variant", () => { + expect(fileStorageSource).toContain('getResourceAccess(storageKey, "display", variant)'); + expect(fileStorageSource).toContain('return resolveMediaUrl(storageKey, fallback, "playback");'); + expect(canvasNodeContentSource).toContain("resolveVideoMediaUrl(node.metadata?.storageKey, fallback)"); + expect(canvasNodeContentSource).toContain("resolveVideoMediaUrl(storageKey, fallback)"); + expect(canvasVideoPreviewSource).toContain('resolveVideoMediaUrl(node.metadata?.storageKey, node.metadata?.content || "")'); + expect(canvasGenerationTaskSource).toContain('resolveVideoMediaUrl(result.storageKey, result.dataUrl || "")'); + expect(canvasProjectGenerationSource).toContain("node.type === CanvasNodeType.Video ? await resolveVideoMediaUrl"); }); test("downloads OSS media by browser navigation without fetching it into a Blob", () => {