fix(media): 视频播放 - 资源视频改走 playback 变体,失败后刷新签名并重试

画布预览、生成回填和项目水合都解析浏览器可播地址;播放失败时绕过展示缓存,等待转码完成后强制刷新 playback 地址。
This commit is contained in:
ddcat666 committed 2026-10-08 10:54:40 +08:00
1 parent 2b3c6ceeaa
commit b4e5eb22a6
8 files changed
+285 -101

No files matched your search

@@ -15,13 +15,13 @@ import { type CanvasNodeData, CanvasNodeType } from "@/types/canvas";
import { CanvasAudioPlayer } from "./canvas-audio-player"; import { CanvasAudioPlayer } from "./canvas-audio-player";
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import"; import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview"; 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 { hydrateCanvasVideoPreview } from "@/services/canvas-video-preview";
import { type CanvasTheme } from "@/lib/canvas-theme"; import { type CanvasTheme } from "@/lib/canvas-theme";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size"; import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { getActiveUserScope } from "@/lib/user-scope"; import { getActiveUserScope } from "@/lib/user-scope";
import { prepareCanvasImage } from "@/services/canvas-image-loader"; 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 type { CanvasNodeContentProps } from "./canvas-node-content";
import { ErrorContent, LoadingContent } from "./canvas-node-status-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) { export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest }: CanvasNodeContentProps) {
const playerBoxRef = useRef<HTMLDivElement>(null); const playerBoxRef = useRef<HTMLDivElement>(null);
const { updateMediaNode } = useCanvasNodeActions(); const { updateMediaNode } = useCanvasNodeActions();
const { url, loading } = useVideoPlaybackUrl(node, mediaActive); const { url, loading, onError } = useVideoPlaybackUrl(node, mediaActive);
const preview = canvasNodeVideoPreviewReference(node); const preview = canvasNodeVideoPreviewReference(node);
const subtitleEntries = node.metadata?.subtitleEntries || []; const subtitleEntries = node.metadata?.subtitleEntries || [];
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle(); const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
@@ -159,6 +159,7 @@ export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlay
{url ? ( {url ? (
<div className={`relative z-[1] transition-opacity duration-150 ${videoReady ? "opacity-100" : "opacity-0"}`} style={{ width: fitWidth, height: Math.round(fitHeight) }}> <div className={`relative z-[1] transition-opacity duration-150 ${videoReady ? "opacity-100" : "opacity-0"}`} style={{ width: fitWidth, height: Math.round(fitHeight) }}>
<VideoPlayer <VideoPlayer
key={url}
src={url} src={url}
mimeType={node.metadata?.mimeType} mimeType={node.metadata?.mimeType}
title={node.title || "视频"} title={node.title || "视频"}
@@ -170,6 +171,7 @@ export function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlay
dataCanvasNoZoom dataCanvasNoZoom
compactControls compactControls
onCanPlay={() => setVideoReady(true)} onCanPlay={() => setVideoReady(true)}
onError={onError}
/> />
{activeEntry && activeEntry.text.trim() ? <CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} /> : null} {activeEntry && activeEntry.text.trim() ? <CanvasSubtitleOverlay text={activeEntry.text} highlight={activeHighlight} style={subtitleStyle} /> : null}
</div> </div>
@@ -212,7 +214,7 @@ export function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeCon
if (!element) return; if (!element) return;
const content = node.metadata?.content || ""; const content = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content; const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content;
return bindCanvasVideoHoverPreview(element, () => resolveMediaUrl(node.metadata?.storageKey, fallback)); return bindCanvasVideoHoverPreview(element, () => resolveVideoMediaUrl(node.metadata?.storageKey, fallback));
}, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]); }, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]);
useEffect(() => { 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<void>((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) { export function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) {
const rawContent = node.metadata?.content || ""; const rawContent = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent; const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(rawContent) : rawContent;
const storageKey = node.metadata?.storageKey || ""; const storageKey = node.metadata?.storageKey || "";
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const recoveryRef = useRef<AbortController | null>(null);
const recoveryAttemptedRef = useRef(false);
const sourceGenerationRef = useRef(0);
useEffect(() => { useEffect(() => {
sourceGenerationRef.current += 1;
recoveryAttemptedRef.current = false;
recoveryRef.current?.abort();
recoveryRef.current = null;
let cancelled = false; let cancelled = false;
if (!active) { if (!active) {
setUrl(""); setUrl("");
setLoading(false); setLoading(false);
return; return () => {
cancelled = true;
};
} }
setLoading(true); setLoading(true);
void resolveMediaUrl(storageKey, fallback) void resolveVideoMediaUrl(storageKey, fallback)
.then((resolved) => { .then((resolved) => {
if (!cancelled) setUrl(resolved); if (!cancelled) setUrl(resolved);
}) })
@@ -340,10 +367,69 @@ export function useVideoPlaybackUrl(node: CanvasNodeData, active: boolean) {
}); });
return () => { return () => {
cancelled = true; cancelled = true;
recoveryRef.current?.abort();
}; };
}, [active, fallback, storageKey]); }, [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<ReturnType<typeof refreshResource>>;
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 }) { export function InactiveMediaCard({ icon, title, hint, theme }: { icon: ReactNode; title: string; hint: string; theme: CanvasTheme }) {
+3 -1
View File
@@ -24,6 +24,7 @@ type VideoPlayerProps = {
hasAudio?: boolean; hasAudio?: boolean;
onCanPlay?: MediaPlayerProps["onCanPlay"]; onCanPlay?: MediaPlayerProps["onCanPlay"];
onPlay?: MediaPlayerProps["onPlay"]; onPlay?: MediaPlayerProps["onPlay"];
onError?: MediaPlayerProps["onError"];
}; };
const zhCNTranslations = { const zhCNTranslations = {
@@ -74,7 +75,7 @@ const supportedVideoMimeTypes = new Set<VideoMimeType>(["video/mp4", "video/webm
* 统一视频播放表面,保留原生媒体 URL 契约,同时提供可访问的完整控件布局。 * 统一视频播放表面,保留原生媒体 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<boolean | undefined>(undefined); const [detectedHasAudio, setDetectedHasAudio] = useState<boolean | undefined>(undefined);
const autoPlayAttemptedRef = useRef(false); const autoPlayAttemptedRef = useRef(false);
const audioProbeGenerationRef = useRef(0); const audioProbeGenerationRef = useRef(0);
@@ -188,6 +189,7 @@ export function VideoPlayer({ src, mimeType, title = "视频", className, brandC
style={{ "--video-brand": brandColor }} style={{ "--video-brand": brandColor }}
onCanPlay={handleCanPlay} onCanPlay={handleCanPlay}
onPlay={onPlay} onPlay={onPlay}
onError={onError}
onLoadedMetadata={(event) => { onLoadedMetadata={(event) => {
const provider = event.target.provider; const provider = event.target.provider;
const media = isVideoProvider(provider) ? provider.media : undefined; const media = isVideoProvider(provider) ? provider.media : undefined;
@@ -7,7 +7,7 @@ import { storeGeneratedAudio } from "@/services/api/audio";
import { storeGeneratedVideo } from "@/services/api/video"; import { storeGeneratedVideo } from "@/services/api/video";
import { parseBackendGenerationResult } from "@/services/api/generation-task"; import { parseBackendGenerationResult } from "@/services/api/generation-task";
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center"; 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 { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { getCachedResourceBlob } from "@/services/resource-blob-cache"; import { getCachedResourceBlob } from "@/services/resource-blob-cache";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store"; import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
@@ -71,14 +71,16 @@ export function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
mimeType: video.mimeType || "video/mp4", mimeType: video.mimeType || "video/mp4",
durationMs: video.durationMs, durationMs: video.durationMs,
hasAudio: video.hasAudio, hasAudio: video.hasAudio,
videoPreview: video.preview ? { videoPreview: video.preview
content: video.preview.url, ? {
storageKey: video.preview.storageKey, content: video.preview.url,
width: video.preview.width, storageKey: video.preview.storageKey,
height: video.preview.height, width: video.preview.width,
bytes: video.preview.bytes, height: video.preview.height,
mimeType: video.preview.mimeType, bytes: video.preview.bytes,
} : undefined, mimeType: video.preview.mimeType,
}
: undefined,
errorDetails: undefined, errorDetails: undefined,
generationErrorCode: undefined, generationErrorCode: undefined,
resourceReloadAvailable: undefined, resourceReloadAvailable: undefined,
@@ -113,14 +115,17 @@ function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
// 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。 // 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。
// 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。 // 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。
export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial<CanvasNodeMetadata> = {}, fallbackModel?: string): CanvasNodeMetadata { export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial<CanvasNodeMetadata> = {}, fallbackModel?: string): CanvasNodeMetadata {
return commitProducedModel({ return commitProducedModel(
...node.metadata, {
...workflowMetadataForResultNode(), ...node.metadata,
...media, ...workflowMetadataForResultNode(),
...extra, ...media,
errorDetails: undefined, ...extra,
assetId: undefined, errorDetails: undefined,
}, fallbackModel); assetId: undefined,
},
fallbackModel,
);
} }
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node], outputIndex = 0): Promise<CanvasNodeData> { export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node], outputIndex = 0): Promise<CanvasNodeData> {
@@ -166,9 +171,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
if (mode === "video") { if (mode === "video") {
if (!result.video?.storageKey && !result.video?.dataUrl) throw new Error("后端任务没有返回视频"); if (!result.video?.storageKey && !result.video?.dataUrl) throw new Error("后端任务没有返回视频");
const video = result.video.storageKey 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" });
? 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 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 const geometry = node.metadata?.locked
? {} ? {}
@@ -197,7 +200,18 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
return { return {
...node, ...node,
type: CanvasNodeType.Text, 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<UploadedFile> { async function cacheGeneratedRemoteVideo(result: GeneratedVideoResult & { storageKey: string }): Promise<UploadedFile> {
const blob = await getCachedResourceBlob(result.storageKey); const blob = await getCachedResourceBlob(result.storageKey);
if (!blob) throw new Error("生成视频资源缓存失败,未标记为成功"); 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("生成视频资源地址为空,未标记为成功"); if (!url) throw new Error("生成视频资源地址为空,未标记为成功");
return { return {
url, url,
@@ -297,9 +311,15 @@ export function generationTaskOutputsApplied(node: CanvasNodeData, task: Generat
} }
export function shouldRecoverCanvasImageOutputs(node: CanvasNodeData) { export function shouldRecoverCanvasImageOutputs(node: CanvasNodeData) {
return node.type === CanvasNodeType.Image && node.metadata?.status === "success" && Boolean(node.metadata.taskId) return (
&& node.metadata.generationOutputCount === undefined && !node.metadata.isBatchRoot && !node.metadata.batchRootId node.type === CanvasNodeType.Image &&
&& /(?:midjourney|(?:^|::)mj-)/i.test(node.metadata.producedModel || node.metadata.model || ""); 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[]) { 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) { for (let index = 0; index < images.length; index += 1) {
const id = `${node.id}:task:${task.id}:image:${index}`; const id = `${node.id}:task:${task.id}:image:${index}`;
const existing = nodes.find((item) => item.id === id); 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); const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index);
children.push({ children.push({
...child, ...child,
@@ -318,8 +341,14 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
metadata: { metadata: {
...child.metadata, ...child.metadata,
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId, assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
isBatchRoot: undefined, batchChildIds: undefined, primaryImageId: undefined, imageBatchExpanded: undefined, isBatchRoot: undefined,
batchRootId: node.id, versionOfNodeId: undefined, versionLabel: undefined, versionPrimary: undefined, batchChildIds: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
batchRootId: node.id,
versionOfNodeId: undefined,
versionLabel: undefined,
versionPrimary: undefined,
agentGenerationContinuation: undefined, agentGenerationContinuation: undefined,
}, },
}); });
@@ -331,9 +360,12 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
metadata: { metadata: {
...resultNode.metadata, ...resultNode.metadata,
...imageMetadata({ ...imageMetadata({
url: primary.metadata!.content!, storageKey: primary.metadata!.storageKey!, url: primary.metadata!.content!,
width: primary.metadata!.naturalWidth!, height: primary.metadata!.naturalHeight!, storageKey: primary.metadata!.storageKey!,
bytes: primary.metadata!.bytes || 0, mimeType: primary.metadata!.mimeType || "image/png", width: primary.metadata!.naturalWidth!,
height: primary.metadata!.naturalHeight!,
bytes: primary.metadata!.bytes || 0,
mimeType: primary.metadata!.mimeType || "image/png",
}), }),
assetId: primary.metadata?.assetId, assetId: primary.metadata?.assetId,
isBatchRoot: true, isBatchRoot: true,
+70 -54
View File
@@ -2,7 +2,7 @@ import { type GenerationTask } from "@/services/api/task-center";
import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task"; 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 { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, normalizeRunningHubCapability, resolveModelRequestConfig, type AiConfig, type WorkflowFieldMapping } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage"; 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 { resourceIdFromStorageKey } from "@/services/api/resources";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas"; import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options"; import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
@@ -359,10 +359,12 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
return Promise.all( return Promise.all(
nodes.map(async (node) => { nodes.map(async (node) => {
const content = node.metadata?.content; const content = node.metadata?.content;
const videoPreview = node.type === CanvasNodeType.Video && node.metadata?.videoPreview?.storageKey const videoPreview =
? { ...node.metadata.videoPreview, content: await resolveImageUrl(node.metadata.videoPreview.storageKey, node.metadata.videoPreview.content, { cacheMiss: true }) } node.type === CanvasNodeType.Video && node.metadata?.videoPreview?.storageKey
: node.metadata?.videoPreview; ? { ...node.metadata.videoPreview, content: await resolveImageUrl(node.metadata.videoPreview.storageKey, node.metadata.videoPreview.content, { cacheMiss: true }) }
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 } }; : 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 (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 && 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; 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 workflowParameters = node?.metadata?.workflowParameters || {};
const runningHubWorkflowId = node?.metadata?.runningHubWorkflowId?.trim() || config.runningHub.workflowId.trim(); const runningHubWorkflowId = node?.metadata?.runningHubWorkflowId?.trim() || config.runningHub.workflowId.trim();
const selectedRunningHubWorkflow = workflowProvider === "runninghub" const selectedRunningHubWorkflow =
? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind)) workflowProvider === "runninghub"
: undefined; ? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind))
const selectedWorkflowFields = workflowProvider === "runninghub" : undefined;
? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson) const selectedWorkflowFields = workflowProvider === "runninghub" ? (selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)) : [];
: [];
const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters); const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters);
const workflowParameterValue = (source: string) => { const workflowParameterValue = (source: string) => {
const value = workflowParameters[`source:${source}`]; const value = workflowParameters[`source:${source}`];
@@ -437,12 +438,16 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
const requestedConfig: AiConfig = { const requestedConfig: AiConfig = {
...config, ...config,
quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality, quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality,
size: workflowProvider === "model" size: workflowProvider === "model" ? (node?.metadata?.size ?? config.size ?? defaultConfig.size) : workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
? node?.metadata?.size ?? config.size ?? defaultConfig.size
: workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground, 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), videoSeconds:
vquality: workflowProvider === "model" ? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality) : String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality), 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, videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark, videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark,
videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload, videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload,
@@ -458,46 +463,57 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
// 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型, // 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型,
// 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。 // 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。
const liveOptions = modelRequestOptions(requestedConfig, mode); const liveOptions = modelRequestOptions(requestedConfig, mode);
const baseRequirements = requirements?.capability const baseRequirements = requirements?.capability ? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } } : { capability: mode, options: liveOptions };
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } } const model = workflowProvider === "model" ? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel : preferredModel;
: { capability: mode, options: liveOptions }; const generationDefaults: Partial<ModelGenerationDefaults> =
const model = workflowProvider === "model" workflowProvider === "model"
? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel ? resolveModelGenerationDefaults(
: preferredModel; config,
const generationDefaults: Partial<ModelGenerationDefaults> = workflowProvider === "model" model,
? resolveModelGenerationDefaults( mode === "image" ? "image" : mode === "video" ? "video" : undefined,
config, mode === "image"
model, ? {
mode === "image" ? "image" : mode === "video" ? "video" : undefined, size: node?.metadata?.size,
mode === "image" quality: node?.metadata?.quality,
? { transparentBackground: node?.metadata?.transparentBackground,
size: node?.metadata?.size, count: requestedConfig.count,
quality: node?.metadata?.quality, }
transparentBackground: node?.metadata?.transparentBackground, : {
count: requestedConfig.count, size: node?.metadata?.size,
} videoSeconds: node?.metadata?.seconds,
: { vquality: node?.metadata?.vquality,
size: node?.metadata?.size, videoGenerateAudio: node?.metadata?.generateAudio,
videoSeconds: node?.metadata?.seconds, videoWatermark: node?.metadata?.watermark,
vquality: node?.metadata?.vquality, },
videoGenerateAudio: node?.metadata?.generateAudio, {
videoWatermark: node?.metadata?.watermark, size: requestedConfig.size,
}, quality: requestedConfig.quality,
{ transparentBackground: requestedConfig.transparentBackground,
size: requestedConfig.size, count: requestedConfig.count,
quality: requestedConfig.quality, videoSeconds: requestedConfig.videoSeconds,
transparentBackground: requestedConfig.transparentBackground, vquality: requestedConfig.vquality,
count: requestedConfig.count, videoGenerateAudio: requestedConfig.videoGenerateAudio,
videoSeconds: requestedConfig.videoSeconds, videoWatermark: requestedConfig.videoWatermark,
vquality: requestedConfig.vquality, },
videoGenerateAudio: requestedConfig.videoGenerateAudio, )
videoWatermark: requestedConfig.videoWatermark, : {};
},
)
: {};
const modeCapability = mode === "video" || mode === "audio" ? mode : "image"; const modeCapability = mode === "video" || mode === "audio" ? mode : "image";
const runningHubCapability = normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability)); 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 { return {
...requestedConfig, ...requestedConfig,
taskWorkflowProvider: workflowProvider, taskWorkflowProvider: workflowProvider,
+18 -1
View File
@@ -120,6 +120,11 @@ export type ResourceAccess = {
originalHeight?: number; 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 ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
type CachedResourceAccess = { value: ResourceAccess; expiresAt: number }; type CachedResourceAccess = { value: ResourceAccess; expiresAt: number };
const accessCache = new Map<string, CachedResourceAccess>(); const accessCache = new Map<string, CachedResourceAccess>();
@@ -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); const id = resourceIdFromStorageKey(storageKey);
if (!id) throw new Error("当前媒体尚未上传到后端资源存储"); if (!id) throw new Error("当前媒体尚未上传到后端资源存储");
const scope = getActiveUserScope(); const scope = getActiveUserScope();
const generation = accessGeneration; const generation = accessGeneration;
const key = `${scope}:${id}:${purpose}:${variant}:${downloadName}`; 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); const cached = accessCache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.value; if (cached && cached.expiresAt > Date.now()) return cached.value;
const pending = accessRequests.get(key); const pending = accessRequests.get(key);
+2 -2
View File
@@ -1,5 +1,5 @@
import { captureVideoPoster } from "@/lib/video-poster"; 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 { uploadImage } from "@/services/image-storage";
import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas"; 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<HydratedCanvasVideoPreview | null> { async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal): Promise<HydratedCanvasVideoPreview | null> {
await waitForBrowserIdle(signal); await waitForBrowserIdle(signal);
throwIfAborted(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; if (!source) return null;
const captured = await captureVideoPoster(source, { signal, maxWidth: 400 }); const captured = await captureVideoPoster(source, { signal, maxWidth: 400 });
throwIfAborted(signal); throwIfAborted(signal);
+19 -4
View File
@@ -3,7 +3,7 @@ import { nanoid } from "nanoid";
import { getActiveUserScope } from "@/lib/user-scope"; import { getActiveUserScope } from "@/lib/user-scope";
import { captureVideoPoster, detectVideoAudioTrackFromBlob } from "@/lib/video-poster"; 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 { uploadImage, type UploadedImage } from "@/services/image-storage";
import { getCachedResourceBlob, primeResourceBlobCache } from "@/services/resource-blob-cache"; 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 }); 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) { } catch (error) {
// 与图片上传同一套判定:永久性失败必须当场暴露,不能混进“稍后自动同步”。 // 与图片上传同一套判定:永久性失败必须当场暴露,不能混进“稍后自动同步”。
if (error instanceof ResourceUploadError && error.permanent) throw 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; if (!storageKey) return fallback;
const resourceId = resourceIdFromStorageKey(storageKey); const resourceId = resourceIdFromStorageKey(storageKey);
if (resourceId) { if (resourceId) {
// 展示直接命中 OSS/CDN;平台资源文件接口只保留给私有源站代理或本地存储兜底。 // 展示直接命中 OSS/CDN;平台资源文件接口只保留给私有源站代理或本地存储兜底。
return resolveResourceAccessURL((await getResourceAccess(storageKey, "display")).url); return resolveResourceAccessURL((await getResourceAccess(storageKey, "display", variant)).url);
} }
const cached = objectUrls.get(storageKey); const cached = objectUrls.get(storageKey);
if (cached) return cached; if (cached) return cached;
@@ -127,6 +137,11 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") {
return url; 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) { export async function getMediaBlob(storageKey: string) {
if (resourceIdFromStorageKey(storageKey)) return getCachedResourceBlob(storageKey); if (resourceIdFromStorageKey(storageKey)) return getCachedResourceBlob(storageKey);
return store.getItem<Blob>(storageKey); return store.getItem<Blob>(storageKey);
+17 -1
View File
@@ -16,6 +16,9 @@ const canvasAudioPlayerSource = readFileSync(resolve(import.meta.dir, "../src/co
const canvasMentionSource = moduleGroupSource("components/canvas/canvas-resource-mention-textarea.tsx"); 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 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 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 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 canvasNodeEditorSource = readFileSync(resolve(import.meta.dir, "../src/pages/canvas/use-canvas-node-editor.ts"), "utf8");
const assetLibrarySource = moduleGroupSource("pages/assets/index.tsx"); 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("useVideoPlaybackUrl(node, mediaActive)");
expect(canvasNodeContentSource).toContain("onMediaPlayRequest?.(node.id)"); expect(canvasNodeContentSource).toContain("onMediaPlayRequest?.(node.id)");
expect(canvasNodeContentSource).toMatch(/autoPlay\s+preload="metadata"/); 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", () => { test("downloads OSS media by browser navigation without fetching it into a Blob", () => {