fix(canvas): 画布媒体 - 修复预览首帧音量和创作状态展示
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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 || "默认模型"} />
|
||||
|
||||
32
web/src/components/canvas/canvas-video-preview-image.tsx
Normal file
32
web/src/components/canvas/canvas-video-preview-image.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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("当前节点类型不能保存为素材");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}` : "下载失败");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}%` }} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user