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);

View File

@@ -1,19 +1,38 @@
import { buildLibTVVideoPreviewUrl } from "@/lib/canvas/libtv-import";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export type CanvasVideoPreviewReference = {
src: string;
storageKey?: string;
};
/**
* Returns the passive preview image reference for a video node.
*
* `storageKey` is kept separate from the fallback URL so UI callers can
* resolve a fresh OSS/CDN display URL instead of mounting a stale historical
* `/api/resources/:id/file` URL stored in older canvas data.
*/
export function canvasNodeVideoPreviewReference(node: CanvasNodeData): CanvasVideoPreviewReference | null {
if (node.type !== CanvasNodeType.Video) return null;
const content = node.metadata?.content || "";
const generatedPreview = node.metadata?.videoPreview;
if (generatedPreview?.storageKey || (generatedPreview?.content && generatedPreview.content !== content)) {
return { src: generatedPreview.content || "", storageKey: generatedPreview.storageKey };
}
const explicitPreview = node.metadata?.previewContent || "";
if (explicitPreview && explicitPreview !== content) return { src: explicitPreview };
const importedPreview = buildLibTVVideoPreviewUrl(content);
return importedPreview ? { src: importedPreview } : null;
}
/**
* Returns an image source that is safe to mount for a passive video preview.
* The original video URL is deliberately never returned: callers must fall
* back to a video icon instead of creating a decoder outside the active node.
*/
export function canvasNodeVideoPreviewUrl(node: CanvasNodeData) {
if (node.type !== CanvasNodeType.Video) return "";
const content = node.metadata?.content || "";
const generatedPreview = node.metadata?.videoPreview?.content || "";
if (generatedPreview && generatedPreview !== content) return generatedPreview;
const explicitPreview = node.metadata?.previewContent || "";
if (explicitPreview && explicitPreview !== content) return explicitPreview;
return buildLibTVVideoPreviewUrl(content);
return canvasNodeVideoPreviewReference(node)?.src || "";
}
export function canvasVideoAssetPreviewUrl(videoUrl: string, coverUrl?: string) {

View File

@@ -8,7 +8,27 @@ export const statusLabel: Record<TaskStatus, string> = {
cancelled: "已取消",
};
type GenerationTaskDisplayTarget = Pick<GenerationTask, "status" | "stage" | "mediaStage">;
type GenerationTaskDisplayTarget = Pick<GenerationTask, "status" | "stage" | "mediaStage" | "progress" | "providerRequestId" | "providerCancelStatus">;
const cancellablePreSubmissionStages = new Set(["queued", "等待队列调度", "后端接管任务", "正在准备创作"]);
/**
* 只有在第三方请求尚未提交时允许取消。
*
* 一旦拿到 providerRequestId、进入取消协调状态,或提交结果不确定,
* 任务可能已经产生上游费用;所有画布入口必须共用这条规则,避免
* 活动面板、详情弹窗和任务列表出现不一致。
*/
export function canCancelGenerationTask(task: GenerationTaskDisplayTarget) {
if (task.status !== "queued" && task.status !== "running") return false;
if (task.providerRequestId || task.providerCancelStatus || task.mediaStage) return false;
const stage = (task.stage || "").trim().toLowerCase();
// Unknown running stages are treated as already submitted. A provider may
// return a custom stage name, and showing a cancel button in that state is
// more dangerous than conservatively hiding it after billing may begin.
if (!stage) return task.status === "queued";
return cancellablePreSubmissionStages.has(stage);
}
export function mediaDeliverySummary(status: TaskStatus | undefined, stage: GenerationTask["mediaStage"]) {
if (!stage) return "";
@@ -31,9 +51,24 @@ export function generationTaskStatusLabel(task: GenerationTaskDisplayTarget) {
export function generationTaskStageLabel(task: GenerationTaskDisplayTarget) {
if (isGenerationTaskSubmissionUncertain(task)) return "为避免重复扣费,未自动重试";
if (task.stage === "generating") return "生成中";
if (task.stage === "queued") return "排队中";
return task.stage || generationTaskStatusLabel(task);
switch (task.stage) {
case "queued":
case "等待队列调度":
case "后端接管任务":
return "正在准备创作";
case "generating":
case "调用生成模型":
case "正在准备参考素材":
case "正在连接上游":
case "作品创作中":
case "上游生成中":
case "后台仍在生成":
return "作品创作中";
case "等待上游任务同步":
return "正在同步创作进度";
default:
return task.stage || generationTaskStatusLabel(task);
}
}
export function generationTaskShowsProgress(task: GenerationTaskDisplayTarget) {
@@ -41,8 +76,8 @@ export function generationTaskShowsProgress(task: GenerationTaskDisplayTarget) {
if (isGenerationTaskSubmissionUncertain(task)) return false;
// 排队、后端接管和连接供应商都没有真实百分比。只有上游状态响应
// 已经写回任务后才显示进度,避免所有图片/视频长期停在同一个假数值。
if (["等待队列调度", "后端接管任务", "正在连接上游", "调用生成模型"].includes(task.stage || "")) return false;
return true;
if (["queued", "等待队列调度", "后端接管任务", "正在准备参考素材", "正在连接上游", "调用生成模型", "作品创作中", "submitting", "submitted"].includes(task.stage || "")) return false;
return typeof task.progress === "number" && task.progress > 0;
}
export const operationOptions = [

View File

@@ -578,7 +578,7 @@ export default function StorageSettingsPage() {
<Form.Item name="requireCDN" label="必须走 CDN" valuePropName="checked" extra="开启后 CDN 鉴权配置不完整时直接失败,不会静默回源。">
<Switch checkedChildren="严格" unCheckedChildren="允许回源" />
</Form.Item>
<Form.Item name="allowPrivateProxy" label="允许私有源站代理" valuePropName="checked" extra="仅作为显式兜底;开启后源站私有时由平台承担带宽。">
<Form.Item name="allowPrivateProxy" label="允许模型输入代理" valuePropName="checked" extra="仅允许服务端向第三方模型提交参考素材时读取私有源站。浏览器展示、复制、下载和本地处理始终直连 OSS/CDN,不会经平台中转媒体正文。">
<Switch checkedChildren="允许" unCheckedChildren="禁止" />
</Form.Item>
</div>

View File

@@ -13,7 +13,6 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { Switch } from "@/components/ui/base/switch";
import { saveAs } from "file-saver";
import { cn } from "@/lib/utils";
import { useCopyText } from "@/hooks/use-copy-text";
@@ -23,6 +22,7 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
import { uploadMediaFile } from "@/services/file-storage";
import { downloadBrowserMedia } from "@/services/browser-download";
import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
import { exportAssets, readAssetPackage } from "./asset-transfer";
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
@@ -395,11 +395,15 @@ export default function AssetsPage() {
copyText(asset.data.content, "文本已复制");
};
const downloadImage = (asset: LibraryAsset) => {
const downloadImage = async (asset: LibraryAsset) => {
if (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio" && asset.kind !== "model") return;
const url = asset.kind === "image" ? asset.data.dataUrl : asset.data.url;
const extension = asset.kind === "model" ? asset.data.fileName.split(".").pop() || "glb" : asset.data.mimeType.split("/")[1] || "png";
saveAs(url, `${asset.title || "asset"}.${extension}`);
try {
await downloadBrowserMedia({ storageKey: asset.data.storageKey, url, fileName: `${asset.title || "asset"}.${extension}` });
} catch (error) {
message.error(error instanceof Error ? error.message : "下载失败");
}
};
const exportAllAssets = async () => {

View File

@@ -1,14 +1,16 @@
import { useEffect, useState } from "react";
import { Button, Modal } from "antd";
import { XCircle } from "lucide-react";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { TaskDetailItem } from "./canvas-project-feedback";
import { generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
import { canCancelGenerationTask, generationTaskShowsProgress, generationTaskStageLabel } from "@/lib/generation-task-display";
import { formatTaskLog, type GenerationTask, type TaskLog } from "@/services/api/task-center";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { VideoPlayer } from "@/components/video-player";
import { AppModal } from "@/components/ui/product/app-modal";
import { modelDisplayName, useEffectiveConfig } from "@/stores/use-config-store";
import { resolveMediaUrl } from "@/services/file-storage";
type CanvasProjectStatusDialogsProps = {
theme: { node: { stroke: string; panel: string; muted: string; fill: string } };
@@ -52,7 +54,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
</div>
</div>
<TaskGenerationParameters inputJson={task.inputJson} theme={theme} />
{onCancelTask && (task.status === "queued" || task.status === "running") ? (
{onCancelTask && canCancelGenerationTask(task) ? (
<div className="flex justify-end">
<Button danger icon={<XCircle className="size-4" />} onClick={() => onCancelTask(task)}>
取消任务
@@ -77,7 +79,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
<AppModal
title="视频预览"
open={Boolean(previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video)}
open={Boolean(previewNode?.type === CanvasNodeType.Video && (previewNode.metadata?.content || previewNode.metadata?.storageKey))}
centered
onCancel={onClosePreview}
footer={null}
@@ -85,14 +87,15 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
flush
styles={{ body: { display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "var(--workspace-canvas-deep)" } }}
>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? (
<VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} hasAudio={typeof previewNode.metadata.hasAudio === "boolean" ? previewNode.metadata.hasAudio : undefined} className="max-h-[84vh] max-w-full bg-black" />
{previewNode?.type === CanvasNodeType.Video && (previewNode.metadata?.content || previewNode.metadata?.storageKey) ? (
<ResolvedCanvasVideoPreview node={previewNode} />
) : null}
</AppModal>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Image ? (
{previewNode?.type === CanvasNodeType.Image && (previewNode.metadata?.content || previewNode.metadata?.storageKey) ? (
<CanvasImagePreview
src={previewNode.metadata.content}
storageKey={previewNode.metadata.storageKey}
alt={previewNode.title || "图片"}
onClose={onClosePreview}
/>
@@ -118,6 +121,34 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
);
}
function ResolvedCanvasVideoPreview({ node }: { node: CanvasNodeData }) {
const [src, setSrc] = useState(node.metadata?.storageKey ? "" : node.metadata?.content || "");
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setSrc(node.metadata?.storageKey ? "" : node.metadata?.content || "");
setFailed(false);
void resolveMediaUrl(node.metadata?.storageKey, node.metadata?.content || "")
.then((url) => {
if (!cancelled) {
setSrc(url);
setFailed(!url);
}
})
.catch(() => {
if (!cancelled) setFailed(true);
});
return () => {
cancelled = true;
};
}, [node.metadata?.content, node.metadata?.storageKey]);
if (failed) return <div className="grid min-h-72 w-full place-items-center text-sm text-white/55">视频地址获取失败,请关闭后重试</div>;
if (!src) return <div className="grid min-h-72 w-full place-items-center text-sm text-white/55">正在加载视频…</div>;
return <VideoPlayer src={src} mimeType={node.metadata?.mimeType} title={node.title || "视频预览"} hasAudio={typeof node.metadata?.hasAudio === "boolean" ? node.metadata.hasAudio : undefined} className="max-h-[84vh] max-w-full bg-black" />;
}
function TaskGenerationParameters({ inputJson, theme }: { inputJson?: string; theme: CanvasProjectStatusDialogsProps["theme"] }) {
const fields = taskParameterRows(inputJson);
return (

View File

@@ -1,6 +1,7 @@
import { CanvasWorkspacePanel } from "@/components/canvas/canvas-workspace-panel";
import { isCanvasNodeGenerating } from "@/lib/canvas/canvas-node-task-state";
import { createCanvasStateWriter } from "@/lib/canvas/canvas-editor-state";
import { canCancelGenerationTask } from "@/lib/generation-task-display";
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { Dispatch, MouseEvent as ReactMouseEvent, SetStateAction } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -570,6 +571,10 @@ function InfiniteCanvasPage() {
const cancelCanvasTask = useCallback(
(task: import("@/services/api/task-center").GenerationTask) => {
if (!canCancelGenerationTask(task)) {
message.info("第三方请求已提交,任务将继续创作,不能取消");
return;
}
modal.confirm({
title: "取消生成任务?",
content: "任务会立即停止本地执行;如果已经提交到上游,系统会继续核对取消结果和积分状态。",
@@ -577,6 +582,11 @@ function InfiniteCanvasPage() {
okButtonProps: { danger: true },
cancelText: "继续等待",
onOk: async () => {
const latestTask = taskDetail?.id === task.id ? taskDetail : task;
if (!canCancelGenerationTask(latestTask)) {
message.info("第三方请求已提交,任务将继续创作,不能取消");
return;
}
try {
const next = await cancelGenerationTask(task.id);
const node = nodesRef.current.find((item) => item.metadata?.taskId === task.id);
@@ -590,7 +600,7 @@ function InfiniteCanvasPage() {
},
});
},
[bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail],
[bindGenerationTask, message, modal, nodesRef, projectId, queryClient, setTaskDetail, taskDetail],
);
useEffect(() => {
@@ -1331,6 +1341,13 @@ function InfiniteCanvasPage() {
const characterCover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
const type = asset.category === "character" || asset.mediaType === "image" ? CanvasNodeType.Image : asset.mediaType === "video" ? CanvasNodeType.Video : asset.mediaType === "audio" ? CanvasNodeType.Audio : CanvasNodeType.Text;
const remoteResourceId = resourceIdFromStorageKey(asset.storageKey);
const storageKey = characterCover
? `resource:${characterCover.resourceId}`
: local?.kind === "image" || local?.kind === "video" || local?.kind === "audio"
? local.data.storageKey
: remoteResourceId
? asset.storageKey
: undefined;
const content = characterCover
? resourceFileUrl(characterCover.resourceId)
: local?.kind === "image"
@@ -1342,7 +1359,7 @@ function InfiniteCanvasPage() {
: remoteResourceId
? resourceFileUrl(remoteResourceId)
: asset.previewText || "";
const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content } };
const preview: CanvasNodeData = { id: asset.id, type, title: asset.title, position: { x: 0, y: 0 }, width: 240, height: 160, metadata: { assetId: asset.id, content, storageKey } };
const current = result.get(asset.folderId) || [];
current.push(preview);
result.set(asset.folderId, current);

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App } from "antd";
import { saveAs } from "file-saver";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { FOLDER_COLLAPSED_HEIGHT, FOLDER_COLLAPSED_WIDTH, FRAME_COLLAPSED_HEIGHT, FRAME_COLLAPSED_WIDTH, getFrameChildIds, isCanvasFolderNode, isFrameNode } from "@/lib/canvas/canvas-frame";
@@ -10,6 +9,7 @@ import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt";
import { applyBatchPrimaryImage, applyNodeConfigPatch } from "@/lib/canvas/canvas-project-domain";
import { resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { downloadBrowserMedia } from "@/services/browser-download";
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData, type CanvasNodeMetadata, type Position } from "@/types/canvas";
@@ -196,10 +196,18 @@ export function useCanvasNodeEditor({
.catch((error) => message.error(error instanceof Error ? error.message : "资产分类更新失败"));
}, [canvasId, domainProjectId, message, nodesRef, queryClient, setNodes]);
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) || !node.metadata?.content) return;
saveAs(node.metadata.content, buildCanvasMediaDownloadFileName(canvasTitle, node));
}, [canvasTitle]);
const downloadNodeImage = useCallback(async (node: CanvasNodeData) => {
const supported = node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio;
const content = node.metadata?.content?.trim();
const storageKey = node.metadata?.storageKey?.trim();
if (!supported || (!content && !storageKey)) return;
try {
const downloadName = buildCanvasMediaDownloadFileName(canvasTitle, node);
await downloadBrowserMedia({ storageKey, url: content, fileName: downloadName });
} catch (error) {
message.error(error instanceof Error ? error.message : "下载失败");
}
}, [canvasTitle, message]);
const saveNodeAsset = useCallback(async (node: CanvasNodeData) => {
if (node.type !== CanvasNodeType.Text && node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video && node.type !== CanvasNodeType.Audio) return message.error("当前节点类型不能保存为素材");

View File

@@ -16,7 +16,8 @@ import { CANVAS_FOLDER_THEME_OPTIONS, resolveCanvasFolderTheme } from "@/lib/can
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { CHARACTER_VOICE_FORMAT_LABEL, CHARACTER_VOICE_UPLOAD_ACCEPT, characterVoiceFormatName, characterVoiceTitleFromFileName, isSupportedCharacterVoiceFile } from "@/lib/character-voice-formats";
import { ASSET_CATEGORIES, defaultAssetCategoryForKind, normalizeAssetCategory } from "@/lib/asset-category";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources";
import { downloadBrowserMedia } from "@/services/browser-download";
import { uploadMediaFile } from "@/services/file-storage";
import {
bindProjectCharacterVoice,
@@ -44,7 +45,6 @@ import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { CanvasNodeType, type CanvasFolderStyle, type CanvasFolderTheme, type CanvasNodeData } from "@/types/canvas";
import { saveAs } from "file-saver";
import { ProjectCharacterCard } from "./project-character-card";
import { linkSelectedProjectAssets } from "./project-asset-linking";
@@ -371,20 +371,28 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
if (folderEditor?.folder) renameFolderMutation.mutate({ id: folderEditor.folder.id, name });
else if (folderEditor) createFolderMutation.mutate({ name, parentId: folderEditor.parentId });
};
const downloadPreviewAsset = (asset: ProjectAsset) => {
const personal = personalAssets.find((item) => item.id === asset.id);
if (personal && (personal.kind === "image" || personal.kind === "video" || personal.kind === "audio" || personal.kind === "model")) {
const url = personal.kind === "image" ? personal.data.dataUrl : personal.data.url;
const extension = personal.kind === "model" ? personal.data.fileName.split(".").pop() || "glb" : personal.data.mimeType.split("/")[1] || "bin";
saveAs(url, `${asset.title || "asset"}.${extension}`);
return;
}
const cover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
if (cover) saveAs(resourceFileUrl(cover.resourceId), `${asset.title || "character"}.png`);
else {
const downloadPreviewAsset = async (asset: ProjectAsset) => {
try {
const personal = personalAssets.find((item) => item.id === asset.id);
if (personal && (personal.kind === "image" || personal.kind === "video" || personal.kind === "audio" || personal.kind === "model")) {
const url = personal.kind === "image" ? personal.data.dataUrl : personal.data.url;
const extension = personal.kind === "model" ? personal.data.fileName.split(".").pop() || "glb" : personal.data.mimeType.split("/")[1] || "bin";
await downloadBrowserMedia({ storageKey: personal.data.storageKey, url, fileName: `${asset.title || "asset"}.${extension}` });
return;
}
const cover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
if (cover) {
await downloadBrowserMedia({ storageKey: resourceStorageKey(cover.resourceId), fileName: `${asset.title || "character"}.png` });
return;
}
const remoteUrl = projectAssetRemoteUrl(asset);
if (remoteUrl) saveAs(remoteUrl, `${asset.title || "asset"}.${projectAssetFileExtension(asset.mediaType)}`);
else message.warning("当前资产没有可下载的媒体文件");
if (!remoteUrl) {
message.warning("当前资产没有可下载的媒体文件");
return;
}
await downloadBrowserMedia({ storageKey: asset.storageKey, url: remoteUrl, fileName: `${asset.title || "asset"}.${projectAssetFileExtension(asset.mediaType)}` });
} catch (error) {
message.error(error instanceof Error ? error.message : "下载失败");
}
};
return (

View File

@@ -22,6 +22,7 @@ import { formatVideoResolutionLabel } from "@/lib/video-generation-options";
import { submitBackendGenerationTask } from "@/services/api/generation-task";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { type GenerationTask } from "@/services/api/task-center";
import { downloadBrowserMedia } from "@/services/browser-download";
import {
createUnitWorkflow,
deleteProjectShot,
@@ -37,7 +38,7 @@ import {
type ShotRevisionInput,
type WorkflowStep,
} from "@/services/api/projects";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources";
import { skillRuntime } from "@/services/skill-runtime";
import { configuredModelMatchesCapability, modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -757,15 +758,10 @@ function revisionInput(values: ShotEditorValues): ShotRevisionInput {
async function downloadArtifact(artifact: ShotArtifact, shotTitle: string, onError: (content: string) => void) {
if (!artifact.resourceId) return;
try {
const response = await fetch(resourceFileUrl(artifact.resourceId), { credentials: "include" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${shotTitle || "shot"}-v${artifact.version}.${artifact.type === "video" ? "mp4" : "png"}`;
anchor.click();
URL.revokeObjectURL(url);
await downloadBrowserMedia({
storageKey: resourceStorageKey(artifact.resourceId),
fileName: `${shotTitle || "shot"}-v${artifact.version}.${artifact.type === "video" ? "mp4" : "png"}`,
});
} catch (error) {
onError(error instanceof Error ? `下载失败:${error.message}` : "下载失败");
}

View File

@@ -5,7 +5,7 @@ import { Eye, FileText, Image as ImageIcon, RotateCcw, Video } from "lucide-reac
import { MediaPreview } from "@/components/media-preview";
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { generationTaskStatusLabel } from "@/lib/generation-task-display";
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed, statusDotClassName, taskAttentionReason, TaskDate } from "./task-shared";
import { TaskVideoThumbnail } from "./task-video-thumbnail";
@@ -17,6 +17,8 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
const isVideo = task.previewKind === "video";
const thumbnailUrl = isVideo ? task.previewPosterUrl : task.previewUrl;
const fallbackVideo = task.type.includes("video");
const stageLabel = generationTaskStageLabel(task);
const showsProgress = generationTaskShowsProgress(task);
const Icon = fallbackVideo ? Video : task.type.includes("image") ? ImageIcon : FileText;
return (
<article className={`product-collection-card task-grid-card${isFailed ? " is-attention" : ""}`}>
@@ -60,9 +62,9 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
<TaskDate value={task.createdAt} />
</span>
</div>
{isActive && task.mediaStage ? <p>{task.stage || "作品已生成,正在保存"}</p> : isActive ? (
<div className="task-grid-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
<span>{task.stage || "正在生成"}</span>
{isActive && !showsProgress ? <p>{stageLabel}</p> : isActive ? (
<div className="task-grid-progress" role="progressbar" aria-label={stageLabel} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
<span>{stageLabel}</span>
<strong>{task.progress || 0}%</strong>
<i><b style={{ width: `${task.progress || 0}%` }} /></i>
</div>

View File

@@ -6,7 +6,7 @@ import { useState } from "react";
import { MediaPreview } from "@/components/media-preview";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, generationTaskStatusLabel } from "@/lib/generation-task-display";
import { formatTaskKind, generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import type { AiConfig } from "@/stores/use-config-store";
import { formatModelName, getTaskCanvasContext, isTaskFailed, statusDotClassName, taskAttentionReason, TaskBilling, TaskDate } from "./task-shared";
@@ -37,6 +37,8 @@ export function TaskListRow({
const isActive = task.status === "queued" || task.status === "running";
const isFailed = isTaskFailed(task);
const retryDisabled = task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error);
const stageLabel = generationTaskStageLabel(task);
const showsProgress = generationTaskShowsProgress(task);
return (
<article className={`product-collection-card task-record-row group${isFailed ? " is-attention" : ""}`}>
<TaskPreviewThumbnail task={task} onOpen={onPreview} />
@@ -60,9 +62,9 @@ export function TaskListRow({
{context.projectName ? ` · ${context.projectName}` : ""}
</span>
</div>
{isActive && task.mediaStage ? <p>{task.stage || "作品已生成,正在保存"}</p> : isActive ? (
<div className="task-record-progress" role="progressbar" aria-label={task.stage || "任务生成进度"} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
<span>{task.stage || "正在生成"}</span>
{isActive && !showsProgress ? <p>{stageLabel}</p> : isActive ? (
<div className="task-record-progress" role="progressbar" aria-label={stageLabel} aria-valuemin={0} aria-valuemax={100} aria-valuenow={task.progress || 0}>
<span>{stageLabel}</span>
<span>{task.progress || 0}%</span>
<i>
<b style={{ width: `${task.progress || 0}%` }} />

View File

@@ -1,4 +1,4 @@
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { getResourceAccess, resolveResourceAccessURL, resourceIdFromStorageKey } from "@/services/api/resources";
export type CanvasAudioSource = {
nodeId: string;
@@ -256,7 +256,7 @@ function sameSource(left: CanvasAudioSource, right: CanvasAudioSource) {
async function resolveAudioSource(source: CanvasAudioSource) {
if (source.storageKey) {
const resourceId = resourceIdFromStorageKey(source.storageKey);
if (resourceId) return resourceFileUrl(resourceId);
if (resourceId) return resolveResourceAccessURL((await getResourceAccess(source.storageKey, "display", "playback")).url);
}
return source.content;
}

View File

@@ -5,30 +5,23 @@ import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
type VideoPreview = NonNullable<CanvasNodeMetadata["videoPreview"]>;
const previewRequests = new Map<string, Promise<VideoPreview | null>>();
export type HydratedCanvasVideoPreview = {
/** 页面先用这个 object URL 渲染首帧,不等待 OSS 预览图上传。 */
localUrl: string;
/** 持久化上传在首帧已经可见后后台完成;失败不影响本地首帧。 */
persisted: Promise<VideoPreview | null>;
};
export function hydrateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal) {
const sourceKey = node.metadata?.storageKey || node.metadata?.content || "";
if (!sourceKey) return Promise.resolve(null);
const requestKey = `${node.id}:${sourceKey}`;
const existing = previewRequests.get(requestKey);
if (existing) return existing;
const request = generateCanvasVideoPreview(node, signal)
.then((preview) => {
if (!preview) previewRequests.delete(requestKey);
return preview;
})
.catch((error: unknown) => {
previewRequests.delete(requestKey);
if (error instanceof DOMException && error.name === "AbortError") throw error;
return null;
});
previewRequests.set(requestKey, request);
return request;
return generateCanvasVideoPreview(node, signal).catch((error: unknown) => {
if (error instanceof DOMException && error.name === "AbortError") throw error;
return null;
});
}
async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal): Promise<VideoPreview | null> {
async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSignal): Promise<HydratedCanvasVideoPreview | null> {
await waitForBrowserIdle(signal);
throwIfAborted(signal);
const source = await resolveMediaUrl(node.metadata?.storageKey, node.metadata?.content || "");
@@ -36,16 +29,22 @@ async function generateCanvasVideoPreview(node: CanvasNodeData, signal?: AbortSi
const captured = await captureVideoPoster(source, { signal, maxWidth: 400 });
throwIfAborted(signal);
if (!captured.poster) return null;
const preview = await uploadImage(captured.poster);
throwIfAborted(signal);
return {
content: preview.url,
storageKey: preview.storageKey,
width: preview.width,
height: preview.height,
bytes: preview.bytes,
mimeType: preview.mimeType,
};
const localUrl = URL.createObjectURL(captured.poster);
const persisted = uploadImage(captured.poster)
.then((preview) => ({
content: preview.url,
storageKey: preview.storageKey,
width: preview.width,
height: preview.height,
bytes: preview.bytes,
mimeType: preview.mimeType,
}))
.catch((error) => {
// 首帧已经在本地可见;预览图持久化失败只影响后续刷新,不阻断视频节点。
console.warn("视频首帧持久化失败,保留本地首帧", { nodeId: node.id, error });
return null;
});
return { localUrl, persisted };
}
function waitForBrowserIdle(signal?: AbortSignal) {