fix(canvas): 画布媒体 - 修复预览首帧音量和创作状态展示

This commit is contained in:
Codex Audit
2026-09-23 11:10:45 +08:00
parent 7737047a8d
commit 12796b048c
30 changed files with 689 additions and 182 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveImageUrl } from "@/services/image-storage";
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
@@ -12,7 +12,7 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
};
/**
* 远程资源图片统一使用稳定的资源文件地址。
* 远程资源图片统一使用 OSS/CDN 授权地址。
* Blob 缓存仍可用于导出、抽帧等字节处理,但不作为媒体展示 src,避免把
* `blob:http(s)://...` 泄露到节点、素材库和浏览器媒体链路中。
*/
@@ -59,7 +59,13 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, loa
cancelled = true;
};
}
setCachedSrc(resourceFileUrl(resourceId));
void getResourceAccess(storageKey, "display")
.then((access) => {
if (!cancelled) setCachedSrc(resolveResourceAccessURL(access.url));
})
.catch(() => {
if (!cancelled) setCacheFailed(true);
});
return () => {
cancelled = true;
};

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { formatCredits } from "@/constant/credits";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import { canCancelGenerationTask, formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import { canvasThemes } from "@/lib/canvas-theme";
import type { GenerationTask } from "@/services/api/task-center";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
@@ -224,7 +224,7 @@ function ActiveTaskCard({
{generationTaskStageLabel(task)}
</span>
</div>
{onCancelTask && (task.status === "queued" || task.status === "running") ? (
{onCancelTask && canCancelGenerationTask(task) ? (
<button
type="button"
className="mt-3 inline-flex h-7 items-center gap-1 rounded-[var(--r-sm)] px-2 text-[var(--fs-tiny)] font-medium transition-colors"

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button, Dropdown } from "antd";
import localforage from "localforage";
import type { CanvasAssistantMessage } from "@/types/canvas";
@@ -7,7 +7,8 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { assertCreativeBriefSpecifications, initialCreativeState, normalizeCreativeProposal, type CreativeAgentState } from "@/lib/creation/creative-agent-state";
import type { CreativeAnswers, CreativeQuote } from "@/lib/creation/creative-agent-contract";
import { CreativeAgentController, type CreativeCanvasAdapter, type CreativeControllerView } from "@/services/creative-agent-controller";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import { CreativeProposalCard, CreativeQuestionCard, CreativeQuoteCard } from "@/components/creation/creative-agent-cards";
import { snapshotCreativePlan } from "@/lib/creation/creative-plan";
@@ -155,16 +156,16 @@ export function CanvasCreativeInteraction(props: Props) {
{props.active && view.quote && <CreativeQuoteCard quote={view.quote} busy={view.busy} disabled={blocked} onApprove={() => invoke(controller.current?.approvePayment())} onRefresh={() => invoke(controller.current?.refreshQuotes())} />}
{(detail.quotes || []).filter((quote) => !props.active || quote.id !== view.quote?.id).map((quote) => <details key={quote.id} className="creative-agent-receipt"><summary>{quote.approvedQuantity ? "已确认生成" : "历史报价"} · {quote.amountLabel} · {quote.items.length} 项</summary><p>{quote.basis}</p><ul>{quote.items.map((item) => <li key={item.id}>{item.label} · {item.model} · {item.specification}</li>)}</ul></details>)}
{view.state.media.filter((item) => item.taskId || item.storageKey || item.error).map((item) => {
const resource = resourceIdFromStorageKey(item.storageKey), url = resource ? resourceFileUrl(resource) : "";
const hasResource = Boolean(resourceIdFromStorageKey(item.storageKey));
const video = view.state.proposal?.generationItems.find((entry) => entry.ref === item.ref)?.mode === "video";
return <div className="creative-agent-card" key={item.ref}>
<strong>{view.state.proposal?.workflow.nodes.find((node) => node.ref === item.ref)?.title || item.ref}</strong>
{!item.error && !url && item.taskId && <p className="creative-agent-muted">{view.busy ? item.status === "running" ? "正在生成,完成后自动显示。" : "已提交,正在等待生成结果。" : "任务已提交。点击下方“继续处理”读取最新结果,无需重复确认费用。"}</p>}
{url && (video ? <video className="w-full max-h-80 object-contain" src={url} controls preload="metadata" /> : <img className="w-full max-h-80 object-contain" src={url} alt={item.ref} />)}
{!item.error && !hasResource && item.taskId && <p className="creative-agent-muted">{view.busy ? item.status === "running" ? "正在生成,完成后自动显示。" : "已提交,正在等待生成结果。" : "任务已提交。点击下方“继续处理”读取最新结果,无需重复确认费用。"}</p>}
{hasResource ? <CreativeAgentMedia storageKey={item.storageKey!} video={video} alt={item.ref} actions={props.active ? <Dropdown trigger={["click"]} disabled={blocked} menu={{ items: [{ key: "adjust", label: "调整作品" }, { key: "redo", label: "重新生成(费用另行确认)" }], onClick: ({ key }) => key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}><Button type="text" disabled={blocked}>更多</Button></Dropdown> : null} /> : null}
{item.error && <p className="creative-agent-muted">这项作品还需要处理,已有结果会保留。</p>}
{item.error && <details className="creative-agent-receipt"><summary>查看失败原因</summary><p>{item.error}</p></details>}
{props.active && item.status === "failed" && item.failureKind !== "observation" && <Button type="primary" disabled={blocked} onClick={() => invoke(controller.current?.redo(item.ref))}>重新生成此项 · 先确认费用</Button>}
<div className="creative-agent-actions">{url && <a href={url} target="_blank" rel="noreferrer">查看作品</a>}{props.active && <Dropdown trigger={["click"]} disabled={blocked} menu={{ items: [{ key: "adjust", label: "调整作品" }, { key: "redo", label: "重新生成(费用另行确认)" }], onClick: ({ key }) => key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}><Button type="text" disabled={blocked}>更多</Button></Dropdown>}</div>
{!hasResource && props.active ? <div className="creative-agent-actions"><Dropdown trigger={["click"]} disabled={blocked} menu={{ items: [{ key: "adjust", label: "调整作品" }, { key: "redo", label: "重新生成(费用另行确认)" }], onClick: ({ key }) => key === "redo" ? invoke(controller.current?.redo(item.ref)) : modify(`请调整《${view.state.proposal?.workflow.nodes.find((entry) => entry.ref === item.ref)?.title || item.ref}》,保留其他作品:`) }}><Button type="text" disabled={blocked}>更多</Button></Dropdown></div> : null}
</div>;
})}
{!props.active && rawError && <details className="creative-agent-receipt"><summary>这一步未完成,后续对话中可继续调整</summary><p>{feedback}</p></details>}
@@ -183,6 +184,32 @@ export function CanvasCreativeInteraction(props: Props) {
</div>;
}
function CreativeAgentMedia({ storageKey, video, alt, actions }: { storageKey: string; video: boolean; alt: string; actions?: ReactNode }) {
const [url, setUrl] = useState("");
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setUrl("");
setFailed(false);
void resolveMediaUrl(storageKey)
.then((resolved) => {
if (!cancelled) setUrl(resolved);
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [storageKey]);
return <>
{url ? (video ? <video className="w-full max-h-80 object-contain" src={url} controls preload="metadata" /> : <img className="w-full max-h-80 object-contain" src={url} alt={alt} />) : <p className="creative-agent-muted">{failed ? "作品地址读取失败,请刷新后重试。" : "正在读取作品…"}</p>}
<div className="creative-agent-actions">{url ? <a href={url} target="_blank" rel="noreferrer">查看作品</a> : null}{actions}</div>
</>;
}
export function creativeInteractionSeed(history: CanvasAssistantMessage[]): CreativeAgentState {
const previous = history.findLast((message) => canvasCreativeDetail(message));
const state = previous ? canvasCreativeDetail(previous)!.state : initialCreativeState();

View File

@@ -2,8 +2,8 @@ import React, { useEffect, useMemo, useState } from "react";
import { Dropdown } from "antd";
import { FileAudio, FileText, MoreHorizontal, Pencil, Plus, SlidersHorizontal, Sparkles, Video } from "lucide-react";
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme, resolveCanvasFolderThemeCover } from "@/lib/canvas/canvas-folder-theme";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import type { CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData } from "@/types/canvas";
import { CanvasNodeType } from "@/types/canvas";
@@ -190,14 +190,10 @@ function FolderNodeMedia({ node }: { node?: CanvasNodeData }) {
if (node?.type === CanvasNodeType.Image && node.metadata?.content) {
return <img src={node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
}
const videoPreview = node ? canvasNodeVideoPreviewUrl(node) : "";
if (videoPreview) {
return <img src={videoPreview} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Video) return <CanvasVideoPreviewImage node={node} alt="" loading="lazy" decoding="async" draggable={false} fallback={<Video className="canvas-folder-file-icon" />} />;
if (node?.type === CanvasNodeType.Drawing && (node.metadata?.drawingPreviewUrl || node.metadata?.content)) {
return <img src={node.metadata.drawingPreviewUrl || node.metadata.content} alt="" loading="lazy" decoding="async" draggable={false} />;
}
if (node?.type === CanvasNodeType.Video) return <Video className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Audio) return <FileAudio className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Skill) return <Sparkles className="canvas-folder-file-icon" />;
if (node?.type === CanvasNodeType.Config) return <SlidersHorizontal className="canvas-folder-file-icon" />;

View File

@@ -4,8 +4,8 @@ import { ChevronDown, ChevronRight, Video } from "lucide-react";
import { CometCard } from "@/components/ui/aceternity/comet-card";
import { CanvasFolderPreview } from "@/components/canvas/canvas-folder-preview";
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
import { FRAME_HEADER_HEIGHT, FRAME_PADDING, isCanvasFolderNode } from "@/lib/canvas/canvas-frame";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type Position } from "@/types/canvas";
@@ -295,11 +295,9 @@ function FramePreview({ nodes, frame, theme }: { nodes: CanvasNodeData[]; frame:
<div className="pointer-events-none absolute inset-x-2 bottom-2 overflow-hidden rounded-md" style={{ top: FRAME_HEADER_HEIGHT, background: theme.frame.preview }}>
{layout.length ? (
layout.map(({ node, ...style }) => {
const videoPreview = canvasNodeVideoPreviewUrl(node);
return <div key={node.id} className="absolute overflow-hidden rounded-[var(--r-xs)] border" style={{ ...style, background: theme.node.fill, borderColor: theme.node.stroke }}>
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" draggable={false} /> : null}
{videoPreview ? <img src={videoPreview} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" draggable={false} /> : null}
{node.type === CanvasNodeType.Video && !videoPreview ? <Video className="m-auto size-4 h-full opacity-40" /> : null}
{node.type === CanvasNodeType.Video ? <CanvasVideoPreviewImage node={node} alt="" className="h-full w-full object-cover" loading="lazy" decoding="async" draggable={false} fallback={<Video className="m-auto size-4 h-full opacity-40" />} /> : null}
{node.type === CanvasNodeType.Text ? <div className="line-clamp-3 p-1 text-[var(--fs-nano)] leading-[9px]" style={{ color: theme.node.text }}>{node.metadata?.content || node.title}</div> : null}
{node.type === CanvasNodeType.Script ? <div className="p-1 text-[var(--fs-nano)] leading-[9px]" style={{ color: theme.node.text }}>分镜脚本 · {node.metadata?.storyboard?.rows.length || 0} 镜</div> : null}
</div>;

View File

@@ -1,27 +1,148 @@
import { Image } from "antd";
import { useEffect, useState } from "react";
import { Image as ImageIcon, LoaderCircle } from "lucide-react";
import { AppModal } from "@/components/ui/product/app-modal";
import { resolveImageUrl } from "@/services/image-storage";
type CanvasImagePreviewProps = {
src: string;
src?: string;
storageKey?: string;
alt?: string;
onClose: () => void;
};
export function CanvasImagePreview({ src, alt = "图片", onClose }: CanvasImagePreviewProps) {
if (!src) return null;
export function CanvasImagePreview({ src = "", storageKey, alt = "图片", onClose }: CanvasImagePreviewProps) {
const [resolvedSrc, setResolvedSrc] = useState(storageKey ? "" : src);
const [loading, setLoading] = useState(Boolean(src || storageKey));
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
setResolvedSrc(storageKey ? "" : src);
setLoading(Boolean(src || storageKey));
setError(false);
if (!src && !storageKey) return () => {
cancelled = true;
};
void resolveImageUrl(storageKey, src)
.then((url) => {
if (cancelled) return;
if (!url) {
setLoading(false);
setError(true);
return;
}
setResolvedSrc(url);
})
.catch(() => {
if (!cancelled) {
setLoading(false);
setError(true);
}
});
return () => {
cancelled = true;
};
}, [src, storageKey]);
if (!src && !storageKey) return null;
return (
<Image
src={src}
alt={alt}
style={{ display: "none" }}
preview={{
open: true,
movable: true,
minScale: 0.5,
maxScale: 12,
scaleStep: 0.25,
onOpenChange: (open) => !open && onClose(),
}}
/>
<AppModal
open
flush
title={
<div className="flex min-w-0 items-center gap-2.5">
<span
className="grid size-7 shrink-0 place-items-center rounded-[var(--r-sm)] border"
style={{
borderColor: "var(--workspace-border)",
background: "color-mix(in srgb, var(--workspace-surface-strong) 72%, var(--workspace-surface))",
color: "var(--workspace-text-muted)",
}}
>
<ImageIcon className="size-4" />
</span>
<span className="min-w-0 truncate text-sm font-semibold" title={alt}>{alt}</span>
</div>
}
footer={null}
centered
width="min(1160px, calc(100vw - 32px))"
onCancel={onClose}
className="canvas-image-preview-modal"
>
<div
className="flex min-h-[min(72vh,680px)] items-center justify-center border-t p-4 sm:p-6"
style={{
borderColor: "var(--workspace-border)",
background: "color-mix(in srgb, var(--workspace-surface) 86%, var(--background))",
}}
>
<div
className="relative flex min-h-[min(62vh,560px)] w-full items-center justify-center overflow-hidden rounded-[var(--r-lg)] border p-3 sm:p-5"
style={{
borderColor: "var(--workspace-border-strong)",
background: "var(--workspace-surface-strong)",
boxShadow: "inset 0 1px 0 color-mix(in srgb, var(--foreground) 5%, transparent), 0 18px 50px color-mix(in srgb, var(--foreground) 14%, transparent)",
}}
>
{loading && !error ? (
<div
className="absolute inset-3 grid place-items-center rounded-[var(--r-md)] border border-dashed sm:inset-5"
style={{
borderColor: "var(--workspace-border)",
background: "color-mix(in srgb, var(--workspace-surface-strong) 68%, var(--workspace-surface))",
}}
role="status"
aria-label="图片加载中"
>
<div className="flex flex-col items-center gap-3" style={{ color: "var(--workspace-text-muted)" }}>
<span className="grid size-11 place-items-center rounded-full border bg-[var(--workspace-surface-strong)]" style={{ borderColor: "var(--workspace-border)" }}>
<LoaderCircle className="size-5 animate-spin motion-reduce:animate-none" />
</span>
<span className="text-xs font-medium">正在加载高清图片</span>
</div>
</div>
) : null}
{error ? (
<div
className="absolute inset-3 grid place-items-center rounded-[var(--r-md)] border border-dashed sm:inset-5"
style={{
borderColor: "var(--workspace-border-strong)",
background: "color-mix(in srgb, var(--workspace-surface-strong) 68%, var(--workspace-surface))",
color: "var(--workspace-text-muted)",
}}
role="alert"
>
<div className="flex flex-col items-center gap-3 text-center">
<ImageIcon className="size-8 opacity-45" />
<div>
<div className="text-sm font-medium">图片暂时无法显示</div>
<div className="mt-1 text-xs opacity-70">请关闭预览后重试</div>
</div>
</div>
</div>
) : null}
{resolvedSrc ? (
<img
src={resolvedSrc}
alt={alt}
draggable={false}
className={`max-h-[min(66vh,620px)] max-w-full rounded-[var(--r-sm)] border object-contain shadow-xl transition-opacity duration-200 motion-reduce:transition-none ${loading || error ? "opacity-0" : "opacity-100"}`}
style={{ borderColor: "var(--workspace-border-strong)" }}
onLoad={() => {
setLoading(false);
setError(false);
}}
onError={() => {
setLoading(false);
setError(true);
}}
/>
) : null}
</div>
</div>
</AppModal>
);
}

View File

@@ -8,13 +8,12 @@ import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { canvasTextFontSize } from "@/lib/canvas/canvas-text-scale";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { canvasNodeVideoPreviewReference } from "@/lib/canvas/canvas-media-preview";
import { bindCanvasVideoHoverPreview } from "@/lib/canvas/canvas-video-hover-preview";
import { buildLibTVImagePreviewUrl, buildLibTVVideoSourceUrl } from "@/lib/canvas/libtv-import";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center";
import { scheduleResourceBlobCache } from "@/services/resource-blob-cache";
import { resolveMediaUrl } from "@/services/file-storage";
@@ -27,6 +26,7 @@ import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textare
import { CanvasAudioPlayer } from "./canvas-audio-player";
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { CanvasSubtitleOverlay } from "./canvas-subtitle-overlay";
import { CanvasVideoPreviewImage } from "./canvas-video-preview-image";
import { CanvasFileUploadContent } from "./canvas-file-upload-content";
import { MarkdownNodeContent } from "./nodes/markdown-node";
import { ChartNodeContent } from "./nodes/chart-node";
@@ -459,7 +459,7 @@ function VideoNodeContent({ node, theme, mediaActive = false, onMediaPlayRequest
};
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMediaNode, url]);
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
if (!node.metadata?.content && !node.metadata?.storageKey) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
if (!mediaActive) return <InactiveVideoPreview node={node} theme={theme} onPlay={() => onMediaPlayRequest?.(node.id)} />;
if (!url) return <MediaLoadingState icon={<LoaderCircle className="size-5 animate-spin" />} label={loading ? "正在加载视频" : "视频资源不可用"} />;
@@ -498,10 +498,17 @@ function AudioNodeContent({ node, theme }: CanvasNodeContentProps) {
function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeContentProps, "node" | "theme"> & { onPlay: () => void }) {
const previewRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(previewRef);
const previewUrl = canvasNodeVideoPreviewUrl(node);
const persistedPreview = canvasNodeVideoPreviewReference(node);
const persistedPreviewSource = persistedPreview?.src || "";
const persistedPreviewStorageKey = persistedPreview?.storageKey || "";
const hasPersistedPreview = Boolean(persistedPreviewSource || persistedPreviewStorageKey);
const { updateMetadata } = useCanvasNodeActions();
const updateMetadataRef = useRef(updateMetadata);
const [hydrating, setHydrating] = useState(false);
const [localPreviewUrl, setLocalPreviewUrl] = useState("");
const localPreviewUrlRef = useRef("");
const [passiveVideoUrl, setPassiveVideoUrl] = useState("");
const [passiveVideoReady, setPassiveVideoReady] = useState(false);
useEffect(() => {
const element = previewRef.current;
@@ -515,27 +522,129 @@ function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeContentPro
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);
useEffect(() => () => {
if (localPreviewUrlRef.current) URL.revokeObjectURL(localPreviewUrlRef.current);
}, []);
useEffect(() => {
if (previewUrl || !nearViewport || !node.metadata?.content || !updateMetadataRef.current) {
if (hasPersistedPreview || localPreviewUrl || !nearViewport || (!node.metadata?.content && !node.metadata?.storageKey)) {
setPassiveVideoUrl("");
setPassiveVideoReady(false);
return;
}
let cancelled = false;
const content = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content;
setPassiveVideoUrl("");
setPassiveVideoReady(false);
// This is a real <video> fallback rather than canvas extraction. It can
// paint the first decoded frame even when OSS allows media playback but
// does not expose the CORS headers required by drawImage/toBlob.
void resolveMediaUrl(node.metadata?.storageKey, fallback)
.then((url) => {
if (!cancelled) setPassiveVideoUrl(url);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [hasPersistedPreview, localPreviewUrl, nearViewport, node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]);
useEffect(() => {
if (hasPersistedPreview || !nearViewport || (!node.metadata?.content && !node.metadata?.storageKey) || !updateMetadataRef.current) {
if (hasPersistedPreview && localPreviewUrlRef.current) {
URL.revokeObjectURL(localPreviewUrlRef.current);
localPreviewUrlRef.current = "";
setLocalPreviewUrl("");
}
setHydrating(false);
return;
}
if (localPreviewUrlRef.current) {
URL.revokeObjectURL(localPreviewUrlRef.current);
localPreviewUrlRef.current = "";
setLocalPreviewUrl("");
}
const controller = new AbortController();
setHydrating(true);
void hydrateCanvasVideoPreview(node, controller.signal)
.then((videoPreview) => {
if (!controller.signal.aborted && videoPreview) updateMetadataRef.current?.(node.id, { videoPreview });
.then((hydrated) => {
if (!hydrated || controller.signal.aborted) {
if (hydrated?.localUrl) URL.revokeObjectURL(hydrated.localUrl);
return;
}
if (localPreviewUrlRef.current) URL.revokeObjectURL(localPreviewUrlRef.current);
localPreviewUrlRef.current = hydrated.localUrl;
setLocalPreviewUrl(hydrated.localUrl);
void hydrated.persisted.then((videoPreview) => {
if (!controller.signal.aborted && videoPreview) updateMetadataRef.current?.(node.id, { videoPreview });
});
})
.catch(() => undefined)
.finally(() => {
if (!controller.signal.aborted) setHydrating(false);
});
return () => controller.abort();
}, [nearViewport, node.id, node.metadata?.content, node.metadata?.storageKey, previewUrl]);
return () => {
controller.abort();
};
}, [hasPersistedPreview, nearViewport, node.id, node.metadata?.content, node.metadata?.storageKey, persistedPreviewSource, persistedPreviewStorageKey]);
if (previewUrl) {
if (hasPersistedPreview || localPreviewUrl || passiveVideoUrl) {
return <div ref={previewRef} className="group/video-preview relative size-full overflow-hidden rounded-[var(--node-radius)] bg-black">
<img src={previewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" />
{hasPersistedPreview ? (
<CanvasVideoPreviewImage
node={node}
alt={`${node.title || "视频"} 静态预览`}
loading="lazy"
decoding="async"
draggable={false}
className="pointer-events-none size-full select-none object-contain"
loadingFallback={<LoaderCircle className="size-5 animate-spin text-white/55" />}
fallback={<Video className="size-7 text-white/40" />}
/>
) : localPreviewUrl ? (
<img src={localPreviewUrl} alt={`${node.title || "视频"} 静态预览`} loading="lazy" decoding="async" draggable={false} className="pointer-events-none size-full select-none object-contain" />
) : (
<>
<video
src={passiveVideoUrl}
aria-hidden="true"
tabIndex={-1}
muted
playsInline
preload="auto"
draggable={false}
className={`pointer-events-none size-full select-none object-contain transition-opacity ${passiveVideoReady ? "opacity-100" : "opacity-0"}`}
onLoadedMetadata={(event) => {
const video = event.currentTarget;
if (video.readyState >= 2) {
setPassiveVideoReady(true);
return;
}
// Some browsers stop at metadata when the element is muted and
// offscreen. A tiny seek forces an actual frame decode without
// starting playback or requiring canvas/CORS access.
if (Number.isFinite(video.duration) && video.duration > 0) {
try {
video.currentTime = Math.min(0.001, video.duration / 2);
} catch {
// loadeddata/canplay will still reveal the frame if seeking is unavailable.
}
}
}}
onLoadedData={() => setPassiveVideoReady(true)}
onCanPlay={() => setPassiveVideoReady(true)}
onSeeked={(event) => {
if (event.currentTarget.readyState >= 2) setPassiveVideoReady(true);
}}
onError={() => {
setPassiveVideoReady(false);
setPassiveVideoUrl("");
}}
/>
{!passiveVideoReady ? <div className="absolute inset-0"><InactiveMediaCard icon={<Video className="size-7" />} title={node.title || "视频"} hint={hydrating ? "正在生成首帧" : "正在读取首帧"} theme={theme} /></div> : null}
</>
)}
<VideoPreviewPlayButton title={node.title || "视频"} onPlay={onPlay} />
</div>;
}
@@ -651,17 +760,13 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
? content
: node.metadata?.previewContent
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
const resourceId = resourceIdFromStorageKey(storageKey);
const isRemoteResource = Boolean(resourceId);
// 远程资源展示统一走稳定的云端资源文件地址。Blob 缓存只服务于导出、抽帧和
// 供应商输入等字节处理,不再把缓存生成的 blob: URL 写入媒体展示链路。
const remoteUrl = isRemoteResource ? resourceFileUrl(resourceId) : "";
const isRemoteResource = storageKey.startsWith("resource:");
// Inline data URLs are already local, but decoding thousands of them is
// still expensive. Images must wait for the same viewport gate as remote
// resources; otherwise DOM virtualization does not reduce image work.
const isLazyVisual = node.type === CanvasNodeType.Image;
const isHttpUrl = Boolean(fallback && !fallback.startsWith("data:"));
const initialUrl = eager && isRemoteResource ? remoteUrl : (eager && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback));
const initialUrl = eager && !isRemoteResource && isLazyVisual && isHttpUrl ? fallback : (isRemoteResource || isLazyVisual ? "" : fallback);
const [url, setUrl] = useState(() => initialUrl);
const [loading, setLoading] = useState(() => !initialUrl && isRemoteResource && eager);
@@ -671,9 +776,28 @@ function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
setLoading(false);
return;
}
setUrl(eager ? remoteUrl : "");
setLoading(false);
}, [eager, fallback, isLazyVisual, isRemoteResource, remoteUrl]);
if (!eager) {
setUrl("");
setLoading(false);
return;
}
let cancelled = false;
setUrl("");
setLoading(true);
void resolveMediaUrl(storageKey, fallback)
.then((resolved) => {
if (!cancelled) setUrl(resolved);
})
.catch(() => {
if (!cancelled) setUrl(fallback);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [eager, fallback, isLazyVisual, isRemoteResource, storageKey]);
return { url, loading };
}

View File

@@ -2,9 +2,9 @@ import { memo, useEffect, useMemo, useState, type KeyboardEvent } from "react";
import { Input, Modal } from "antd";
import { AudioLines, BookOpenText, Clock3, FileText, Image, Pencil, Search, Video } from "lucide-react";
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes, searchCanvasNodes } from "@/lib/canvas/canvas-node-search";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -125,13 +125,18 @@ const CanvasNodeSearchResult = memo(function CanvasNodeSearchResult({ node, acti
function CanvasNodeSearchThumbnail({ node }: { node: CanvasNodeData }) {
const [failed, setFailed] = useState(false);
const mediaSource = node.type === CanvasNodeType.Video ? canvasNodeVideoPreviewUrl(node) : node.metadata?.drawingPreviewUrl
const mediaSource = node.metadata?.drawingPreviewUrl
|| node.metadata?.characterCoverUrl
|| node.metadata?.folder?.themeCover
|| ((node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade) ? node.metadata?.content : undefined);
const commonClass = "h-11 w-16 rounded-[var(--r-sm)] border object-cover";
const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" };
if (node.type === CanvasNodeType.Video) {
const fallback = <span aria-hidden="true" className="grid h-11 w-16 place-items-center rounded-[var(--r-sm)] border text-foreground/48" style={commonStyle}><Video className="size-4" /></span>;
return <CanvasVideoPreviewImage node={node} alt="" width={64} height={44} loading="lazy" decoding="async" className={commonClass} style={commonStyle} fallback={fallback} />;
}
if (mediaSource && !failed) {
return <img src={mediaSource} alt="" width={64} height={44} loading="lazy" decoding="async" className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
}

View File

@@ -1,8 +1,8 @@
import { Button, Modal } from "antd";
import { Check, Star } from "lucide-react";
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -13,7 +13,6 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
{versions.map((node) => {
const videoPreview = canvasNodeVideoPreviewUrl(node);
return (
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
@@ -21,7 +20,7 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.accent.primary }}><Check className="size-3" />主版本</span> : null}
</div>
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : videoPreview ? <img src={videoPreview} alt={node.title || "版本视频"} className="size-full object-contain" loading="lazy" decoding="async" /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video ? <CanvasVideoPreviewImage node={node} alt={node.title || "版本视频"} className="size-full object-contain" loading="lazy" decoding="async" fallback={<span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>暂无视频首帧</span>} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
</button>
<div className="space-y-2 p-3 text-[var(--fs-label)]">
<Info label="模型" value={node.metadata?.model || "默认模型"} />

View File

@@ -0,0 +1,32 @@
import type { ImgHTMLAttributes, ReactNode } from "react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { canvasNodeVideoPreviewReference } from "@/lib/canvas/canvas-media-preview";
import type { CanvasNodeData } from "@/types/canvas";
type CanvasVideoPreviewImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
node: CanvasNodeData;
fallback?: ReactNode;
loadingFallback?: ReactNode;
eager?: boolean;
};
/**
* Passive video poster image. Persisted posters are always resolved through
* their resource storage key so remote media is served by OSS/CDN rather than
* an obsolete platform proxy URL embedded in older canvas snapshots.
*/
export function CanvasVideoPreviewImage({ node, fallback = null, loadingFallback = fallback, eager = false, ...props }: CanvasVideoPreviewImageProps) {
const preview = canvasNodeVideoPreviewReference(node);
if (!preview) return <>{fallback}</>;
return (
<CachedResourceImage
{...props}
storageKey={preview.storageKey}
src={preview.src}
fallback={fallback}
loadingFallback={loadingFallback}
eager={eager}
/>
);
}

View File

@@ -1,9 +1,9 @@
import { memo, useState } from "react";
import { AudioLines, Clock3, FileText, Image, Layers, Pencil, Search, X } from "lucide-react";
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { canvasNodeMaterialSummary, canvasNodeSearchContext, canvasNodeSearchTimes } from "@/lib/canvas/canvas-node-search";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { Video as CanvasVideoIcon } from "lucide-react";
@@ -103,15 +103,18 @@ const CanvasNodeListItem = memo(function CanvasNodeListItem({ node, active, onSe
function CanvasNodeListThumbnail({ node }: { node: CanvasNodeData }) {
const [failed, setFailed] = useState(false);
const mediaSource =
node.type === CanvasNodeType.Video
? canvasNodeVideoPreviewUrl(node)
: node.metadata?.drawingPreviewUrl ||
node.metadata?.characterCoverUrl ||
node.metadata?.folder?.themeCover ||
(node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade ? node.metadata?.content : undefined);
node.metadata?.drawingPreviewUrl ||
node.metadata?.characterCoverUrl ||
node.metadata?.folder?.themeCover ||
(node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Panorama || node.type === CanvasNodeType.ColorGrade ? node.metadata?.content : undefined);
const commonClass = "h-9 w-11 rounded-[var(--r-sm)] border object-cover";
const commonStyle = { borderColor: "color-mix(in srgb, var(--foreground) 9%, transparent)", background: "color-mix(in srgb, var(--foreground) 5%, transparent)" };
if (node.type === CanvasNodeType.Video) {
const fallback = <span aria-hidden="true" className="grid h-9 w-11 place-items-center rounded-[var(--r-sm)] border text-foreground/48" style={commonStyle}><CanvasVideoIcon className="size-3.5" /></span>;
return <CanvasVideoPreviewImage node={node} alt="" width={44} height={36} loading="lazy" decoding="async" className={commonClass} style={commonStyle} fallback={fallback} />;
}
if (mediaSource && !failed) {
return <img src={mediaSource} alt="" width={44} height={36} loading="lazy" decoding="async" className={commonClass} style={commonStyle} onError={() => setFailed(true)} />;
}

View File

@@ -3,7 +3,7 @@ import { CheckCircle2, ChevronDown, ChevronUp, Clock3, Coins, LoaderCircle, More
import { Dropdown, type MenuProps } from "antd";
import { formatCredits } from "@/constant/credits";
import { formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import { canCancelGenerationTask, formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { useUserStore } from "@/stores/use-user-store";
@@ -28,7 +28,7 @@ export const TaskListItem = memo(function TaskListItem({ task, onCancelTask }: {
const isActive = task.status === "queued" || task.status === "running";
const statusColor = task.status === "running" ? "var(--primary)" : task.status === "succeeded" ? "var(--success, #16a34a)" : task.status === "failed" ? "var(--danger, #dc2626)" : "color-mix(in srgb, var(--foreground) 40%, transparent)";
const menuItems: MenuProps["items"] = [...(isActive && onCancelTask ? [{ key: "cancel", danger: true, icon: <XCircle className="size-3.5" />, label: "取消任务", onClick: () => onCancelTask(task) }] : [])];
const menuItems: MenuProps["items"] = [...(onCancelTask && canCancelGenerationTask(task) ? [{ key: "cancel", danger: true, icon: <XCircle className="size-3.5" />, label: "取消任务", onClick: () => onCancelTask(task) }] : [])];
return (
<div

View File

@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { Image as ImageIcon, Music2, Play, UserRound } from "lucide-react";
import { canvasNodeVideoPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { CanvasVideoPreviewImage } from "@/components/canvas/canvas-video-preview-image";
import { isStoryboardPreviewAsset } from "@/lib/canvas/canvas-storyboard-materializer";
import { resolveMediaUrl } from "@/services/file-storage";
import { CanvasNodeType, type CanvasNodeData, type StoryboardAssetBinding } from "@/types/canvas";
@@ -58,17 +58,16 @@ export function StoryboardAssetsCell({ bindings, nodes, limit = 4 }: { bindings:
}
function AssetThumbnail({ node }: { node: CanvasNodeData }) {
const videoPreview = canvasNodeVideoPreviewUrl(node);
const source = useNodeMediaSource(node.type === CanvasNodeType.Video ? null : node);
if (node.type === CanvasNodeType.Audio) return <Music2 className="size-4" />;
if (node.metadata?.workflowKind === "character" && !source) return <UserRound className="size-4" />;
if (node.type === CanvasNodeType.Video) {
return videoPreview ? (
return (
<>
<img src={videoPreview} alt="" loading="lazy" decoding="async" draggable={false} className="size-full object-cover" />
<CanvasVideoPreviewImage node={node} alt="" loading="lazy" decoding="async" draggable={false} className="size-full object-cover" fallback={<Play className="size-4" />} />
<span className="absolute inset-0 grid place-items-center bg-black/15"><Play className="size-3.5 fill-white text-white" /></span>
</>
) : <Play className="size-4" />;
);
}
return source ? <img src={source} alt="" loading="lazy" decoding="async" draggable={false} className="size-full object-cover" /> : <ImageIcon className="size-4" />;
}

View File

@@ -165,7 +165,7 @@ export function UserOSSSettingsForm() {
extra={isTencentCOS
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"
: isQiniuKodo
? "选填。填写后浏览器直连七牛私有下载地址;留空时采用“浏览器 → 当前后端 /api/resources/:id/file → 七牛 S3 Endpoint”的代理链路,后端使用 AK/SK 读取并返回文件,无需绑定域名。"
? "选填。配置可访问的绑定域名后,浏览器直接从七牛下载与预览;不配置时也必须保证对象源站可由用户浏览器访问,不会由平台中转媒体正文。"
: "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源。CDN URL 不附带 OSS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"}
rules={[{ type: "url", message: "请填写完整的 http/https CDN 加速域名" }]}
className="mb-3"
@@ -183,7 +183,7 @@ export function UserOSSSettingsForm() {
<Form.Item name="requireCDN" label="必须走 CDN" valuePropName="checked" extra="开启后 CDN 鉴权未配置或不支持时直接失败,不会静默回源。" className="mb-3">
<Switch checkedChildren="必须" unCheckedChildren="允许回源" />
</Form.Item>
<Form.Item name="allowPrivateProxy" label="允许私有源站代理" valuePropName="checked" extra="仅作为显式兜底;开启后私有源站会经平台返回,平台仍会承担带宽。" className="mb-3">
<Form.Item name="allowPrivateProxy" label="允许模型输入代理" valuePropName="checked" extra="仅允许服务端向第三方模型提交参考素材时读取私有源站。浏览器展示、复制、下载和本地处理始终直连 OSS/CDN,不会经平台中转媒体正文。" className="mb-3">
<Switch checkedChildren="允许" unCheckedChildren="禁止" />
</Form.Item>
<Form.Item name="bucket" label="Bucket" className="mb-3">

View File

@@ -86,7 +86,7 @@ export function VideoPlayer({ src, mimeType, title = "视频", className, brandC
// A conclusive container probe wins over stale persisted metadata in
// either direction. Browser runtime probes remain conservative when they
// cannot expose an audio-track list.
const effectiveHasAudio = detectedHasAudio === false ? false : hasAudio === true || detectedHasAudio === true ? true : hasAudio === false ? false : undefined;
const effectiveHasAudio = detectedHasAudio === false ? false : hasAudio === true || detectedHasAudio === true ? true : undefined;
const noAudio = effectiveHasAudio === false;
const layoutIcons = useMemo(() => {
if (!noAudio) return defaultLayoutIcons;
@@ -127,7 +127,6 @@ export function VideoPlayer({ src, mimeType, title = "视频", className, brandC
}, [noAudio]);
const probeRemoteAudioTrack = () => {
if (hasAudio === false) return;
const generation = audioProbeGenerationRef.current;
void detectVideoAudioTrackFromUrl(src).then((detected) => {
if (generation === audioProbeGenerationRef.current && detected !== undefined) setDetectedHasAudio(detected);