feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选

This commit is contained in:
ddCat
2026-09-08 17:26:34 +08:00
parent 95b004ec0f
commit 068a8daea6
56 changed files with 3316 additions and 274 deletions

View File

@@ -0,0 +1,27 @@
import type { CSSProperties } from "react";
import { AlertCircle, ImageUp, FileText, Film, Music2 } from "lucide-react";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { uploadVeilOpacity } from "@/lib/canvas/canvas-file-upload";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { useCanvasNodeActions } from "./canvas-node-action-context";
export function CanvasFileUploadContent({ node, theme, reduceMotion = false }: { node: CanvasNodeData; theme: CanvasTheme; reduceMotion?: boolean }) {
const { upload } = useCanvasNodeActions();
const failed = node.metadata?.fileUpload === "error";
const percent = node.metadata?.fileUploadProgress;
const label = node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "文本";
const Icon = failed ? AlertCircle : node.type === CanvasNodeType.Video ? Film : node.type === CanvasNodeType.Audio ? Music2 : node.type === CanvasNodeType.Text ? FileText : ImageUp;
return (
<div className="canvas-file-upload" data-static={reduceMotion || failed || undefined} style={{ "--upload-surface": theme.node.fill, "--upload-text": theme.node.text, "--upload-muted": theme.node.muted } as CSSProperties}>
<div className="canvas-file-upload-glow" aria-hidden="true" style={{ opacity: uploadVeilOpacity(percent) }} />
<div className="canvas-file-upload-frost" aria-hidden="true" style={{ opacity: uploadVeilOpacity(percent) }} />
<div className="canvas-file-upload-copy" role="status" aria-live="polite" aria-busy={!failed}>
<Icon className="size-5" aria-hidden="true" />
<span className="canvas-file-upload-label">{failed ? `${label}上传未完成` : `正在上传${label}`}</span>
<span className="canvas-file-upload-detail">{failed ? node.metadata?.errorDetails || "请重新选择文件上传" : percent === 100 ? "文件已传输,正在保存与处理" : percent !== undefined ? `已上传 ${percent}%` : "正在准备文件"}</span>
{!failed && <span role="progressbar" aria-label={`${label}上传进度`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="sr-only" />}
{failed && upload ? <button type="button" data-canvas-no-zoom className="canvas-file-upload-retry" onPointerDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); upload(node); }}>重新选择文件</button> : null}
</div>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import type { CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
// 但画布节点经 CanvasProjectWorldLayers 渲染、不便逐个透传 handler,
// 通过 Context 注入,避免改动 world-layers。无 Provider 时静默降级为 no-op。
export type CanvasNodeActionContextValue = {
upload?: (node: CanvasNodeData) => void;
download?: (node: CanvasNodeData) => void;
duplicate?: (node: CanvasNodeData) => void;
deleteNode?: (node: CanvasNodeData) => void;

View File

@@ -8,6 +8,7 @@ import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { canvasNodeVideoPreviewUrl } 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";
@@ -26,6 +27,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 { CanvasFileUploadContent } from "./canvas-file-upload-content";
import { MarkdownNodeContent } from "./nodes/markdown-node";
import { ChartNodeContent } from "./nodes/chart-node";
import { CompareNodeContent } from "./nodes/compare-node";
@@ -62,6 +64,7 @@ export type CanvasNodeContentProps = {
};
export function CanvasNodeContent(props: CanvasNodeContentProps) {
if (props.node.metadata?.fileUpload) return <CanvasFileUploadContent node={props.node} theme={props.theme} reduceMotion={props.reduceMediaEffects} />;
const hasCustomContent = props.node.type === CanvasNodeType.Config
|| props.node.type === CanvasNodeType.Script
|| Boolean(props.node.metadata?.directorSceneId)
@@ -496,6 +499,14 @@ function InactiveVideoPreview({ node, theme, onPlay }: Pick<CanvasNodeContentPro
const updateMetadataRef = useRef(updateMetadata);
const [hydrating, setHydrating] = useState(false);
useEffect(() => {
const element = previewRef.current;
if (!element) return;
const content = node.metadata?.content || "";
const fallback = node.metadata?.importSource?.provider === "libtv" ? buildLibTVVideoSourceUrl(content) : content;
return bindCanvasVideoHoverPreview(element, () => resolveMediaUrl(node.metadata?.storageKey, fallback));
}, [node.metadata?.content, node.metadata?.storageKey, node.metadata?.importSource?.provider]);
useEffect(() => {
updateMetadataRef.current = updateMetadata;
}, [updateMetadata]);

View File

@@ -5,6 +5,7 @@ import { AlertCircle, BookOpenCheck, CheckCircle2, ChevronRight, Clapperboard, C
import { useCanvasNodeActions } from "./canvas-node-action-context";
import { canvasThemes } from "@/lib/canvas-theme";
import { canvasConnectionTilt } from "@/lib/canvas/canvas-connection-tilt";
import { storyboardMinNodeHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -28,6 +29,7 @@ type CanvasNodeProps = {
isRelated: boolean;
isFocusRelated: boolean;
isConnectionTarget: boolean;
connectionApproach?: Position;
forceInputVisible?: boolean;
showImageInfo: boolean;
reduceMediaEffects?: boolean;
@@ -75,6 +77,7 @@ export const CanvasNode = React.memo(function CanvasNode({
isRelated,
isFocusRelated,
isConnectionTarget,
connectionApproach,
forceInputVisible = false,
showImageInfo,
reduceMediaEffects = false,
@@ -265,6 +268,9 @@ export const CanvasNode = React.memo(function CanvasNode({
if (next !== data.title) onTitleChange?.(data.id, next);
};
const connectionTilt = isConnectionTarget && !reduceMediaEffects && !dragOffset
? canvasConnectionTilt(data, connectionApproach) : undefined;
return (
<div
data-node-id={data.id}
@@ -302,13 +308,17 @@ export const CanvasNode = React.memo(function CanvasNode({
<div
className="canvas-node-shell relative h-full w-full overflow-visible rounded-[var(--node-radius)]"
data-node-state={nodeState}
data-connection-tilt={connectionTilt ? "true" : undefined}
data-state={data.metadata?.status || (isActive ? "active" : isRelated ? "related" : "idle")}
style={{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
// 固定占位但不绘制描边,避免聚焦切换时边框宽度变化造成白边跳动。
border: isComposerNode ? "0" : "1px solid transparent",
boxShadow: isComposerNode ? "none" : isSelected || isFocusRelated ? theme.node.hoverShadow : theme.node.shadow,
}}
"--connection-tilt-x": `${connectionTilt?.rotateX || 0}deg`,
"--connection-tilt-y": `${connectionTilt?.rotateY || 0}deg`,
transformOrigin: connectionTilt?.origin,
} as React.CSSProperties}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
if (isBatchRoot) {
@@ -488,6 +498,8 @@ function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProp
previous.isRelated === next.isRelated &&
previous.isFocusRelated === next.isFocusRelated &&
previous.isConnectionTarget === next.isConnectionTarget &&
previous.connectionApproach?.x === next.connectionApproach?.x &&
previous.connectionApproach?.y === next.connectionApproach?.y &&
previous.forceInputVisible === next.forceInputVisible &&
previous.showImageInfo === next.showImageInfo &&
previous.reduceMediaEffects === next.reduceMediaEffects &&

View File

@@ -6,6 +6,7 @@ import { Palette, Info } from "lucide-react";
import { FloatingDock } from "@/components/ui/aceternity/floating-dock";
import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface";
import { CanvasAppearanceControls } from "@/components/canvas/canvas-appearance-controls";
import { useCanvasOverlayLayer } from "@/components/canvas/canvas-overlay-layer";
import { CanvasCreateMenu, type CanvasCreateCommand } from "@/components/canvas/canvas-create-menu";
import { useCanvasCreateCommands } from "@/components/canvas/use-canvas-create-commands";
import { ToolbarSettingsModal } from "@/components/canvas/toolbars/toolbar-settings-modal";
@@ -89,6 +90,7 @@ export function CanvasToolbar({
onOpenProjectCharacters: () => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)");
const dockRef = useRef<HTMLDivElement>(null);
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
@@ -98,6 +100,10 @@ export function CanvasToolbar({
const [panelX, setPanelX] = useState(0);
const [prefs, setPrefs] = useState<ToolbarPrefs | null>(() => readToolbarPrefs("main"));
useEffect(() => {
if (addOpen || appearanceOpen) bringToFront();
}, [addOpen, appearanceOpen, bringToFront]);
// 设置面板关闭后重新读取偏好(用户可能调整了排序/显隐)
useEffect(() => {
if (!settingsOpen) setPrefs(readToolbarPrefs("main"));
@@ -186,7 +192,7 @@ export function CanvasToolbar({
const createCommands = useCanvasCreateCommands(ctx, runAddAction);
return (
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] z-[var(--z-toolbar)] flex justify-center">
<div ref={rootRef} data-canvas-no-zoom className="pointer-events-none absolute inset-x-[var(--canvas-inset-x)] bottom-[var(--canvas-inset-y)] flex justify-center" style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}>
<AnimatePresence>
{addOpen ? (
<AddNodeMenu

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { App, Button, Modal, Upload, type UploadFile } from "antd";
import { FileImage, Film, Music2, UploadCloud, X } from "lucide-react";
import { FileImage, FileText, Film, Music2, UploadCloud, X } from "lucide-react";
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
const CANVAS_UPLOAD_ACCEPT = "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
import { CANVAS_UPLOAD_ACCEPT, isTextUploadFile, uploadNodeType } from "@/lib/canvas/canvas-file-upload";
type CanvasUploadModalProps = {
open: boolean;
@@ -29,7 +29,9 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
if (!files.length) return;
setUploading(true);
try {
if (await onUpload(files)) onClose();
const pendingUpload = onUpload(files);
onClose();
await pendingUpload;
} catch (error) {
message.error(error instanceof Error ? error.message : "文件上传失败,请稍后重试");
} finally {
@@ -54,7 +56,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border py-0 pl-5 pr-12">
<div className="min-w-0">
<div role="heading" aria-level={2} className="text-sm font-semibold leading-5">上传文件</div>
<div className="mt-0.5 text-[var(--fs-label)] leading-4 text-foreground/45">批量导入图片、视频和音频到当前画布</div>
<div className="mt-0.5 text-[var(--fs-label)] leading-4 text-foreground/45">批量导入图片、视频、音频和文本到当前画布</div>
</div>
<span className="shrink-0 text-[var(--fs-label)] text-foreground/45">已选 {fileList.length} 项</span>
</header>
@@ -67,7 +69,7 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
fileList={fileList}
beforeUpload={(file) => {
if (isCanvasUploadFile(file)) return false;
message.warning(`“${file.name}”不是支持的图片、视频或音频文件`);
message.warning(`“${file.name}”不是支持的图片、视频、音频或 TXT / Markdown 文件`);
return Upload.LIST_IGNORE;
}}
onChange={({ fileList: nextFileList }) => setFileList(nextFileList)}
@@ -86,7 +88,8 @@ export function CanvasUploadModal({ open, onClose, onUpload }: CanvasUploadModal
<div className="mt-4 flex flex-wrap items-center justify-center gap-3 text-[var(--fs-label)] text-foreground/45" aria-label="支持的文件类型">
<span className="inline-flex items-center gap-1"><FileImage className="size-3.5" aria-hidden="true" />图片</span>
<span className="inline-flex items-center gap-1"><Film className="size-3.5" aria-hidden="true" />视频</span>
<span className="inline-flex items-center gap-1"><Music2 className="size-3.5" aria-hidden="true" />MP3 / WAV</span>
<span className="inline-flex items-center gap-1"><Music2 className="size-3.5" aria-hidden="true" />音频</span>
<span className="inline-flex items-center gap-1"><FileText className="size-3.5" aria-hidden="true" />TXT / Markdown</span>
</div>
</div>
</Upload.Dragger>
@@ -148,11 +151,11 @@ function CanvasUploadFilePreview({ file }: { file: UploadFile }) {
}
return (
<div className="grid size-full place-items-center text-foreground/45">
{source && isAudioFile(source) ? <Music2 className="size-7" aria-hidden="true" /> : <FileImage className="size-7" aria-hidden="true" />}
{source && isTextUploadFile(source) ? <FileText className="size-7" aria-hidden="true" /> : source && (source.type.startsWith("audio/") || isAudioFile(source)) ? <Music2 className="size-7" aria-hidden="true" /> : <FileImage className="size-7" aria-hidden="true" />}
</div>
);
}
function isCanvasUploadFile(file: File) {
return file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file);
return Boolean(uploadNodeType(file));
}

View File

@@ -397,6 +397,7 @@ export function InfiniteCanvas({ containerRef, viewport, appearance, backgroundM
return (
<div
ref={containerRef}
data-canvas-viewport
data-canvas-pan-state={isPanning ? "grabbing" : isSpacePressed || !boxSelectEnabled ? "grab" : undefined}
className={`relative h-full w-full select-none overflow-hidden touch-none ${isPanning ? "cursor-grabbing" : isSpacePressed || !boxSelectEnabled ? "cursor-grab" : "canvas-cursor-select"}`}
style={{

View File

@@ -132,7 +132,7 @@ function flattenCharacterCandidates(value: unknown): unknown[] | undefined {
return undefined;
}
function extractCharacterBreakdownJson(raw: string) {
export function extractCharacterBreakdownJson(raw: string, allowEmptyCharacters = false) {
for (let start = 0; start < raw.length; start += 1) {
if (raw[start] !== "{" && raw[start] !== "[") continue;
const end = findJsonValueEnd(raw, start);
@@ -144,6 +144,9 @@ function extractCharacterBreakdownJson(raw: string) {
// 模型的推理文字可能包含 aliases: [] 等合法 JSON 片段;角色契约只接受带 characters 字段的对象。
const candidates = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? flattenCharacterCandidates((parsed as { characters?: unknown }).characters) : undefined;
if (Array.isArray(candidates) && candidates.length) return parsed;
// 章节资产允许无人场景;仍交由章节解析器校验三个必填数组。
if (allowEmptyCharacters && parsed && typeof parsed === "object" && !Array.isArray(parsed)
&& Array.isArray((parsed as { characters?: unknown }).characters)) return parsed;
} catch {
// Ignore unrelated braces in model prose and continue to the next complete JSON value.
}

View File

@@ -0,0 +1,18 @@
import type { CanvasNodeData, Position } from "@/types/canvas";
export type CanvasConnectionApproach = { nodeId: string; point: Position } | null;
/** Capture only entry into a target, not every pointer frame or snap adjustment. */
export function latchCanvasConnectionApproach(previous: CanvasConnectionApproach, nodeId: string | null, point: Position): CanvasConnectionApproach {
if (!nodeId) return null;
return previous?.nodeId === nodeId ? previous : { nodeId, point: { ...point } };
}
/** Canvas-space coordinates keep the approach direction stable at every zoom level. */
export function canvasConnectionTilt(node: Pick<CanvasNodeData, "position" | "width" | "height">, point?: Position) {
if (!point || node.width <= 0 || node.height <= 0) return undefined;
const x = Math.max(0, Math.min(1, (point.x - node.position.x) / node.width));
const y = Math.max(0, Math.min(1, (point.y - node.position.y) / node.height));
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { rotateX: (0.5 - y) * 10, rotateY: (x - 0.5) * 10, origin: `${x * 100}% ${y * 100}%` };
}

View File

@@ -0,0 +1,87 @@
import { fitNodeSize } from "@/lib/canvas/canvas-node-size";
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
export const CANVAS_UPLOAD_ACCEPT = "image/*,video/*,audio/*,.mp3,.wav,text/plain,text/markdown,.txt,.md,.markdown";
export function isTextUploadFile(file: Pick<File, "name" | "type">) {
return /\.(txt|md|markdown)$/i.test(file.name) || file.type === "text/plain" || file.type === "text/markdown";
}
export function uploadNodeType(file: Pick<File, "name" | "type">) {
if (file.type.startsWith("image/")) return CanvasNodeType.Image;
if (file.type.startsWith("video/")) return CanvasNodeType.Video;
if (file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name)) return CanvasNodeType.Audio;
if (isTextUploadFile(file)) return CanvasNodeType.Text;
return null;
}
export function uploadPercent(loaded: number, total: number): number | undefined {
if (!Number.isFinite(loaded) || !Number.isFinite(total) || total <= 0) return undefined;
return Math.round(Math.min(1, Math.max(0, loaded / total)) * 100);
}
export function uploadVeilOpacity(percent = 0) {
return 1 - Math.min(100, Math.max(0, percent)) / 100 * 0.9;
}
export async function createFileUploadPlaceholder(id: string, file: File, position: Position) {
const type = uploadNodeType(file);
if (!type) throw new Error("请选择图片、视频、音频或 TXT / Markdown 文件");
if (type === CanvasNodeType.Image) return createImageUploadPlaceholder(id, file, position, await readUploadImageSize(file));
const size = type === CanvasNodeType.Video ? fitNodeSize(...await readUploadVideoSize(file)) : NODE_DEFAULT_SIZE[type];
return {
id, type, title: file.name, ...size,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
metadata: { fileUpload: "uploading", bytes: file.size, mimeType: file.type },
} satisfies CanvasNodeData;
}
async function readUploadVideoSize(file: File): Promise<[number, number]> {
const url = URL.createObjectURL(file);
const video = document.createElement("video");
try {
return await new Promise((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error("视频尺寸读取超时,请确认文件可播放")), 15000);
video.onloadedmetadata = () => {
window.clearTimeout(timer);
if (video.videoWidth && video.videoHeight) resolve([video.videoWidth, video.videoHeight]);
else reject(new Error("无法读取视频尺寸"));
};
video.onerror = () => { window.clearTimeout(timer); reject(new Error("无法读取视频,请确认格式受浏览器支持")); };
video.preload = "metadata";
video.src = url;
});
} finally {
video.onloadedmetadata = null;
video.onerror = null;
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(url);
}
}
// 不使用 readImageMeta 的默认尺寸兜底:上传占位必须来自实际解码尺寸。
export async function readUploadImageSize(file: File): Promise<{ width: number; height: number }> {
const url = URL.createObjectURL(file);
const image = new Image();
try {
image.src = url;
await image.decode();
if (!image.naturalWidth || !image.naturalHeight) throw new Error("无法读取图片尺寸,请重新选择图片");
return { width: image.naturalWidth, height: image.naturalHeight };
} finally {
URL.revokeObjectURL(url);
}
}
export function createImageUploadPlaceholder(id: string, file: Pick<File, "name" | "size" | "type">, position: Position, naturalSize: { width: number; height: number }): CanvasNodeData {
const size = fitNodeSize(naturalSize.width, naturalSize.height);
return {
id, type: CanvasNodeType.Image, title: file.name,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
...size,
metadata: { fileUpload: "uploading", naturalWidth: naturalSize.width, naturalHeight: naturalSize.height, bytes: file.size, mimeType: file.type },
};
}
export function interruptFileUpload(node: CanvasNodeData): CanvasNodeData {
if (node.metadata?.fileUpload !== "uploading") return node;
return { ...node, metadata: { ...node.metadata, fileUpload: "error", fileUploadProgress: undefined, errorDetails: "上传已中断,请重新选择文件上传" } };
}

View File

@@ -15,7 +15,7 @@ import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type Ca
export function generationTaskInput(task: GenerationTask) {
if (!task.inputJson) return null;
try {
return JSON.parse(task.inputJson) as { mode?: CanvasGenerationMode; metadata?: { nodeId?: string; sourceNodeId?: string }; prompt?: string };
return JSON.parse(task.inputJson) as { mode?: CanvasGenerationMode; metadata?: { nodeId?: string; sourceNodeId?: string; domainProjectId?: string }; prompt?: string };
} catch {
return null;
}
@@ -258,6 +258,9 @@ function applySuccessfulVersionSelection(nodes: CanvasNodeData[], updatedNode: C
export async function syncGenerationTaskToCanvasStore(task: GenerationTask) {
if (task.status !== "succeeded" || !task.projectId) return false;
// 短剧任务使用业务项目 ID,不能拿它请求同名的画布项目。
const domainProjectId = task.clientContext?.domainProjectId || generationTaskInput(task)?.metadata?.domainProjectId;
if (domainProjectId === task.projectId || !generationTaskNodeId(task)) return false;
const { loadCanvasProjectForEditing } = await import("@/services/user-data-sync");
const project = await loadCanvasProjectForEditing(task.projectId);
if (!project) return false;

View File

@@ -0,0 +1,30 @@
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
/** 已有媒体但没有生成配置的图片是输入素材,不提供原地生成入口。 */
export function isCanvasImageSourceNode(node: CanvasNodeData | null | undefined): boolean {
if (node?.type === CanvasNodeType.Image && node.metadata?.fileUpload) return true;
return node?.type === CanvasNodeType.Image
&& Boolean(node.metadata?.content)
&& !node.metadata?.generationType
&& !node.metadata?.taskId
&& !node.metadata?.generationResultPlacement
&& !node.metadata?.copiedFromNodeId
&& !node.metadata?.versionOfNodeId;
}
export function createPortraitTextureNode(source: CanvasNodeData, id: string): CanvasNodeData {
return {
id,
type: CanvasNodeType.Image,
title: `${source.title || "图片"} · 人物质感`,
position: { x: source.position.x + source.width + 96, y: source.position.y },
width: source.width,
height: source.height,
metadata: {
prompt: "@图片1",
composerContent: "@图片1",
generationType: "edit",
portraitTexture: source.metadata?.portraitTexture,
},
};
}

View File

@@ -11,6 +11,7 @@ import { modelCapabilityConfigFor, workflowFieldCurrentValue, workflowFieldHasSt
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import { interruptFileUpload } from "@/lib/canvas/canvas-file-upload";
import { isCanvasWorkflowProvider, resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasVideoEditOperation } from "@/types/canvas";
@@ -552,7 +553,7 @@ export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height;
let changed = false;
const reset = nodes.map((node) => {
const mediaNode = ensureMediaNodeMinimumSize(node);
const mediaNode = ensureMediaNodeMinimumSize(interruptFileUpload(node));
const resizedNode =
mediaNode.type === CanvasNodeType.Config && (mediaNode.width < configWidth || mediaNode.height < configHeight)
? { ...mediaNode, width: Math.max(mediaNode.width, configWidth), height: Math.max(mediaNode.height, configHeight) }

View File

@@ -216,7 +216,7 @@ export function buildCanvasNodeMentionReferenceMap(nodes: CanvasNodeData[], conn
const configTargetId = configTargetBySourceId.get(node.id);
const configInputs = configTargetId ? (resourceInputsByTargetId.get(configTargetId) || []).filter((input) => input.id !== node.id) : [];
const ownInputs = resourceInputsByTargetId.get(node.id) || [];
const inputs = configInputs.length ? configInputs : ownInputs.length ? ownInputs : isResourceNode(node) ? [node] : [];
const inputs = configInputs.length ? configInputs : ownInputs.filter((input) => input.id !== node.id);
referencesByNodeId.set(node.id, labelResourceNodes(inputs, true));
}
return referencesByNodeId;
@@ -231,8 +231,7 @@ export function getMentionResourceNodes(nodeId: string, nodes: CanvasNodeData[],
if (configInputs.length) return configInputs;
const ownInputs = getContextResourceNodes(nodeId, nodes, connections);
if (ownInputs.length) return ownInputs;
const node = nodes.find((item) => item.id === nodeId);
return node && isResourceNode(node) ? [node] : [];
return [];
}
export function getGenerationResourceNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {

View File

@@ -0,0 +1,93 @@
// Only the hovered node owns a decoder; pending hover work shares the same lease.
let stopCurrentPreview: (() => void) | undefined;
export const VIDEO_HOVER_DELAY_MS = 350;
export const VIDEO_HOVER_PREVIEW_MS = 3000;
export function bindCanvasVideoHoverPreview(element: HTMLElement, resolveSource: () => Promise<string>) {
let stop: (() => void) | undefined;
const enter = (event: PointerEvent) => {
if (event.pointerType !== "mouse" || event.buttons || document.hidden
|| window.matchMedia("(prefers-reduced-motion: reduce)").matches
|| element.closest('[data-canvas-viewport-interacting="true"]')) return;
stopCurrentPreview?.();
let disposed = false;
let video: HTMLVideoElement | undefined;
let playbackTimer: ReturnType<typeof setTimeout> | undefined;
const listeners = new AbortController();
const viewport = element.closest("[data-canvas-viewport]");
const visibility = new IntersectionObserver((entries) => {
if (entries.some((entry) => !entry.isIntersecting)) cancel();
});
const observer = new MutationObserver(() => {
if (viewport?.getAttribute("data-canvas-viewport-interacting") === "true") cancel();
});
const cancel = () => {
if (disposed) return;
disposed = true;
clearTimeout(delay);
clearTimeout(deadline);
clearTimeout(playbackTimer);
listeners.abort();
observer.disconnect();
visibility.disconnect();
if (video) {
video.pause();
video.removeAttribute("src");
video.load();
video.remove();
}
if (stopCurrentPreview === cancel) stopCurrentPreview = undefined;
if (stop === cancel) stop = undefined;
};
const delay = setTimeout(() => {
// Explicit playback wins over an incidental hover.
if (Array.from(document.querySelectorAll("video, audio")).some((media) => !(media as HTMLMediaElement).paused)) {
cancel();
return;
}
void resolveSource().then((src) => {
if (disposed) return;
if (!src || !element.isConnected || document.hidden) { cancel(); return; }
video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
video.className = "pointer-events-none absolute inset-0 size-full rounded-[var(--node-radius)] bg-black object-contain";
video.setAttribute("aria-hidden", "true");
video.dataset.canvasHoverPreview = "true";
video.addEventListener("ended", cancel, { signal: listeners.signal });
video.addEventListener("error", cancel, { signal: listeners.signal });
video.addEventListener("timeupdate", () => {
if (video && video.currentTime >= VIDEO_HOVER_PREVIEW_MS / 1000) cancel();
}, { signal: listeners.signal });
video.addEventListener("playing", () => {
if (!playbackTimer) playbackTimer = setTimeout(cancel, VIDEO_HOVER_PREVIEW_MS);
}, { signal: listeners.signal });
video.src = src;
element.appendChild(video);
void video.play().catch(cancel);
}).catch(cancel);
}, VIDEO_HOVER_DELAY_MS);
// Bound stalled network/decoder sessions as well as actual playback.
const deadline = setTimeout(cancel, 8000);
stop = cancel;
stopCurrentPreview = cancel;
visibility.observe(element);
if (viewport) observer.observe(viewport, { attributes: true, attributeFilter: ["data-canvas-viewport-interacting"] });
for (const type of ["pointerdown", "wheel", "keydown", "blur"] as const) {
window.addEventListener(type, cancel, { capture: true, passive: true, signal: listeners.signal });
}
document.addEventListener("visibilitychange", cancel, { signal: listeners.signal });
document.addEventListener("play", (event) => { if (event.target !== video) cancel(); }, { capture: true, signal: listeners.signal });
};
const leave = () => stop?.();
element.addEventListener("pointerenter", enter);
element.addEventListener("pointerleave", leave);
element.addEventListener("pointercancel", leave);
return () => {
leave();
element.removeEventListener("pointerenter", enter);
element.removeEventListener("pointerleave", leave);
element.removeEventListener("pointercancel", leave);
};
}

View File

@@ -0,0 +1,27 @@
import { extractCharacterBreakdownJson, parseCharacterBreakdown, type CharacterBreakdown } from "./canvas-character-reference";
export type ChapterAssetDefinition = { name: string; description: string; prompt: string };
export type ChapterAssetBreakdown = { characters: CharacterBreakdown[]; scenes: ChapterAssetDefinition[]; props: ChapterAssetDefinition[] };
export function parseChapterAssetBreakdown(raw: string): ChapterAssetBreakdown {
const text = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
const value: unknown = extractCharacterBreakdownJson(text, true);
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("章节资产结果必须是 JSON 对象");
const payload = value as Record<string, unknown>;
if (!Array.isArray(payload.characters) || !Array.isArray(payload.scenes) || !Array.isArray(payload.props)) {
throw new Error("章节资产结果缺少角色、场景或道具数组,请重新提取");
}
const parseAssets = (items: unknown[], label: string): ChapterAssetDefinition[] => items.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`${label}资产格式无效`);
const asset = item as Record<string, unknown>;
for (const key of ["name", "description", "prompt"]) {
if (typeof asset[key] !== "string" || !asset[key].trim()) throw new Error(`${label}资产缺少有效的 ${key}`);
}
return { name: (asset.name as string).trim(), description: (asset.description as string).trim(), prompt: (asset.prompt as string).trim() };
});
return {
characters: payload.characters.length ? parseCharacterBreakdown(JSON.stringify({ characters: payload.characters })) : [],
scenes: parseAssets(payload.scenes, "场景"),
props: parseAssets(payload.props, "道具"),
};
}

View File

@@ -3,6 +3,7 @@ import { AudioLines, Captions, Clapperboard, Download, FolderPlus, Images, Image
import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/generation-error";
import { registerToolbarTools, type ToolContext, type ToolDefinition } from "@/lib/canvas/tool-registry";
import { CanvasNodeType } from "@/types/canvas";
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
// 节点状态判定辅助函数——从 ToolContext 派生
function isImage(ctx: ToolContext) { return ctx.node?.type === CanvasNodeType.Image; }
@@ -15,10 +16,11 @@ function hasVideo(ctx: ToolContext) { return isVideo(ctx) && Boolean(ctx.nodeMet
function hasAudio(ctx: ToolContext) { return isAudio(ctx) && Boolean(ctx.nodeMetadata?.content); }
function isCharacterReference(ctx: ToolContext) { return isText(ctx) && ctx.nodeMetadata?.workflowKind === "character" && Boolean(ctx.nodeMetadata?.characterAssetId); }
function isEditableText(ctx: ToolContext) { return isText(ctx) && !isCharacterReference(ctx); }
function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || isImage(ctx) || isVideo(ctx); }
function canOpenDialog(ctx: ToolContext) { return isEditableText(ctx) || (isImage(ctx) && !isCanvasImageSourceNode(ctx.node)) || isVideo(ctx); }
function simpleMode(ctx: ToolContext) { return ctx.workspaceMode === "simple"; }
function isImageBatchRoot(ctx: ToolContext) { return isImage(ctx) && Boolean(ctx.nodeMetadata?.isBatchRoot && ctx.nodeMetadata.batchChildIds?.length); }
function canRetry(ctx: ToolContext) {
if (ctx.nodeMetadata?.fileUpload) return false;
const requiresPromptChange = ctx.nodeMetadata?.generationErrorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(ctx.nodeMetadata?.errorDetails);
const batchHasFailures = isImageBatchRoot(ctx) && (ctx.nodeMetadata?.batchFailedCount || (ctx.nodeMetadata?.status === "error" ? 1 : 0)) > 0;
return (ctx.nodeMetadata?.status === "error" || (batchHasFailures && ctx.nodeMetadata?.status !== "loading")) && !requiresPromptChange;

View File

@@ -20,6 +20,7 @@ export default function LogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const keyword = searchParams.get("filter") || "";
const status = normalizeStatus(searchParams.get("status"));
const recordType = searchParams.get("recordType") === "download" ? "download" : searchParams.get("recordType") === "all" ? "all" : "request";
const page = positiveInt(searchParams.get("page"), 1);
const pageSize = normalizePageSize(searchParams.get("pageSize"));
const debouncedKeyword = useDebouncedValue(keyword);
@@ -30,7 +31,7 @@ export default function LogsPage() {
const [detailLogId, setDetailLogId] = useState<string | null>(null);
const [mediaPreview, setMediaPreview] = useState<{ url: string; kind: "image" | "video"; title: string } | null>(null);
const requestSequence = useRef(0);
const hasFilters = Boolean(keyword || status !== "all");
const hasFilters = Boolean(keyword || status !== "all" || recordType !== "request");
const updateUrl = (patch: Record<string, string | number>, replace = false) => {
const next = new URLSearchParams(searchParams);
@@ -45,7 +46,7 @@ export default function LogsPage() {
useEffect(() => {
const sequence = ++requestSequence.current;
setLoading(true);
void listAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
void listAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status, page, limit: pageSize })
.then((result) => {
if (sequence !== requestSequence.current) return;
setLogs(result.logs);
@@ -55,7 +56,7 @@ export default function LogsPage() {
})
.catch((error) => sequence === requestSequence.current && message.error(error instanceof Error ? error.message : "读取请求明细失败"))
.finally(() => sequence === requestSequence.current && setLoading(false));
}, [debouncedKeyword, status, page, pageSize]);
}, [debouncedKeyword, status, recordType, page, pageSize]);
const columns: ColumnsType<ApiCallLog> = [
{ title: "时间", width: 168, render: (_, log) => formatTime(log.startedAt || log.createdAt) },
@@ -129,7 +130,7 @@ export default function LogsPage() {
description="模型生成、状态查询与结果下载;仅计费调用扣除积分"
actions={
<AdminExportButton
exportFile={() => exportAdminApiLogs({ keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
exportFile={() => exportAdminApiLogs({ recordType, keyword: debouncedKeyword || undefined, status: status === "all" ? undefined : status })}
fileName={() => `请求明细-${new Date().toISOString().slice(0, 10)}.csv`}
label="导出当前筛选"
successMessage="已按当前筛选导出请求明细"
@@ -155,6 +156,8 @@ export default function LogsPage() {
</>
}
toolbarFilters={
<>
<Select aria-label="明细类型" className="w-32" value={recordType} onChange={(value) => updateUrl({ recordType: value, page: 1 })} options={[{ label: "仅请求", value: "request" }, { label: "仅下载", value: "download" }, { label: "全部明细", value: "all" }]} />
<Select
className="w-32"
value={status}
@@ -165,15 +168,16 @@ export default function LogsPage() {
{ label: "失败", value: "failed" },
]}
/>
</>
}
toolbarActive={hasFilters}
onReset={() => updateUrl({ filter: "", status: "all", page: 1 })}
onReset={() => updateUrl({ filter: "", status: "all", recordType: "request", page: 1 })}
batchActions={
<AdminBatchBar count={selectedIds.length} onClear={() => setSelectedIds([])}>
<AdminExportButton
type="primary"
size="small"
exportFile={() => exportAdminApiLogs({ ids: selectedIds })}
exportFile={() => exportAdminApiLogs({ recordType, ids: selectedIds })}
fileName={() => `请求明细-已选${selectedIds.length}条.csv`}
label="导出已选"
successMessage={`已导出选中的 ${selectedIds.length} 条请求明细`}

View File

@@ -20,7 +20,6 @@ export type CanvasUploadStatus = {
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploadStatus; theme: CanvasTheme }) {
const progress = Math.round((Math.min(status.step, status.total) / Math.max(status.total, 1)) * 100);
const accent = status.error ? theme.accent.danger : status.done ? "#22c55e" : theme.node.activeStroke;
return (
<motion.div
@@ -67,7 +66,7 @@ export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploa
/>
))}
</div>
<span className="sr-only">{progress}%</span>
<span className="sr-only">处理步骤 {status.step}/{status.total}</span>
</motion.div>
);
}

View File

@@ -4,6 +4,7 @@ import { Link2 } from "lucide-react";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -24,6 +25,7 @@ type CanvasProjectWorldLayersProps = {
connectingParams: ConnectionHandle | null;
mouseWorld: Position;
connectionTargetNodeId: string | null;
connectionApproach: CanvasConnectionApproach;
nodeById: Map<string, CanvasNodeData>;
visibleNodes: CanvasNodeData[];
nodeStackOrder: CanvasNodeStackOrder;
@@ -159,6 +161,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
connectionApproach={props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined}
forceInputVisible={Boolean(props.batchConnectionPreview)}
batchCount={props.batchChildCountById.get(node.id) || 0}
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}

View File

@@ -12,6 +12,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { resourceFileUrl, resourceIdFromStorageKey, syncResourceToArkPrivateAsset } from "@/services/api/resources";
import { uploadImage } from "@/services/image-storage";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { isCanvasImageSourceNode } from "@/lib/canvas/canvas-image-source";
import copyToClipboard from "copy-to-clipboard";
import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
@@ -983,6 +984,7 @@ function InfiniteCanvasPage() {
closeConnectionCreateMenu,
connectionTargetAnchorRatio,
connectionTargetNodeId,
connectionApproach,
connectingParams,
createConnectedNode,
getConnectionCreateDisabledReason,
@@ -1460,6 +1462,7 @@ function InfiniteCanvasPage() {
updateNodeFromContent(nodeId, (node) => ({ ...node, metadata: { ...node.metadata, ...patch } }));
}, [updateNodeFromContent]);
const canvasNodeActions = useMemo<CanvasNodeActionContextValue>(() => ({
upload: replaceCanvasNodeMedia,
download: downloadNodeImage,
duplicate: duplicateNodeFromContent,
deleteNode: deleteNodeFromContent,
@@ -1468,7 +1471,7 @@ function InfiniteCanvasPage() {
updateMediaNode: updateMediaNodeFromContent,
openPortraitClearance,
openArtCritique,
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
}), [deleteNodeFromContent, downloadNodeImage, duplicateNodeFromContent, openArtCritique, openPortraitClearance, replaceCanvasNodeMedia, updateMediaNodeFromContent, updateNodeFromContent, updateNodeMetadataFromContent]);
const { agentSnapshot, agentUndoCount, applyAgentOps, canUndoAgentOps, dismissLastAgentChange, lastAgentChange, undoAgentOps, viewLastAgentChange } = useCanvasAgentOperations({
projectId,
domainProjectId: currentProject?.projectId,
@@ -2295,6 +2298,7 @@ function InfiniteCanvasPage() {
<CanvasNodeActionContext.Provider value={canvasNodeActions}>
<CanvasNodeGraphContext.Provider value={nodeGraphContext}>
<CanvasProjectWorldLayers
connectionApproach={connectionApproach}
projectId={projectId}
viewportScale={viewport.k}
connectionLayerBounds={connectionLayerBounds}
@@ -2497,7 +2501,7 @@ function InfiniteCanvasPage() {
/>
) : null}
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
{dialogNode && !isCanvasImageSourceNode(dialogNode) && !dialogNode.metadata?.fileUpload && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
<CanvasNodePanelOverlay
node={dialogNode}
viewport={viewport}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
import { App } from "antd";
import { nanoid } from "nanoid";
import { latchCanvasConnectionApproach, type CanvasConnectionApproach } from "@/lib/canvas/canvas-connection-tilt";
import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays";
import { getNodeSpec } from "@/constant/canvas";
@@ -86,6 +87,7 @@ export function useCanvasConnectionController({
const runtimeStatuses = usePluginStore((state) => state.runtimeStatuses);
const [connectingParams, setConnectingParams] = useState<ConnectionHandle | null>(null);
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
const [connectionApproach, setConnectionApproach] = useState<CanvasConnectionApproach>(null);
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
const [batchConnectionPreview, setBatchConnectionPreview] = useState<CanvasBatchConnectionPreview | null>(null);
@@ -108,6 +110,7 @@ export function useCanvasConnectionController({
const updateBatchConnectionPreview = useCallback((next: CanvasBatchConnectionPreview | null) => {
batchConnectionPreviewRef.current = next;
setBatchConnectionPreview(next);
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, next && next.status !== "invalid" ? next.targetNodeId : null, next?.mouseWorld || { x: 0, y: 0 }));
}, []);
const clearBatchConnection = useCallback(() => {
@@ -120,6 +123,7 @@ export function useCanvasConnectionController({
connectingParamsRef.current = next;
setConnectingParams(next);
if (!next) {
setConnectionApproach(null);
connectingPointerIdRef.current = null;
connectingPointerStartRef.current = null;
setConnectionTargetNodeId(null);
@@ -596,9 +600,11 @@ export function useCanvasConnectionController({
const current = connectingParamsRef.current;
if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return;
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current);
const point = screenToCanvas(event.clientX, event.clientY);
setConnectionApproach((previous) => latchCanvasConnectionApproach(previous, dropTarget.nodeId, point));
setConnectionTargetNodeId(dropTarget.nodeId);
setConnectionTargetAnchorRatio(dropTarget.anchorRatio);
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
setMouseWorld(point);
};
const handlePointerMove = (event: PointerEvent) => {
// Pointer events can arrive faster than the canvas can paint. Keep
@@ -677,6 +683,7 @@ export function useCanvasConnectionController({
cancelPendingConnectionCreate,
closeConnectionCreateMenu,
connectionTargetNodeId,
connectionApproach,
connectionTargetAnchorRatio,
connectingParams,
createConnectedNode,

View File

@@ -26,6 +26,7 @@ import {
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { compositeEmotionImage, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion";
import { DEFAULT_PORTRAIT_TEXTURE_SETTINGS } from "@/lib/canvas/canvas-portrait-texture";
import { createPortraitTextureNode } from "@/lib/canvas/canvas-image-source";
import { captureVideoFrames } from "@/lib/canvas/canvas-video-frame";
import { buildVideoFrameNodes } from "@/lib/canvas/canvas-video-frame-nodes";
import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video-merge";
@@ -182,14 +183,16 @@ export function useCanvasMediaTools({
return;
}
const portraitTextureSettings = { ...DEFAULT_PORTRAIT_TEXTURE_SETTINGS, ...node.metadata?.portraitTexture };
const composerContent = node.metadata?.composerContent?.trim() || node.metadata?.prompt?.trim() || "@图片1";
const child = createPortraitTextureNode(node, nanoid());
child.metadata = { ...child.metadata, portraitTexture: portraitTextureSettings };
setHoveredNodeId(null);
setToolbarNodeId(null);
setNodes((current) => current.map((item) => item.id === node.id ? { ...item, metadata: { ...item.metadata, prompt: composerContent, composerContent, portraitTexture: portraitTextureSettings } } : item));
setSelectedNodeIds(new Set([node.id]));
setNodes((current) => [...current, child]);
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: child.id }]);
setSelectedNodeIds(new Set([child.id]));
setSelectedConnectionId(null);
setDialogNodeId(node.id);
}, [message, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
setDialogNodeId(child.id);
}, [message, setConnections, setDialogNodeId, setHoveredNodeId, setNodes, setSelectedConnectionId, setSelectedNodeIds, setToolbarNodeId]);
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
if (!node.metadata?.content) return;

View File

@@ -11,6 +11,8 @@ import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas
import { createCanvasNode } from "@/lib/canvas/canvas-project-domain";
import { isAudioFile } from "@/lib/canvas/canvas-project-generation";
import { fitNodeSize, VIDEO_NODE_MAX_SIZE } from "@/lib/canvas/canvas-node-size";
import { CANVAS_UPLOAD_ACCEPT, createFileUploadPlaceholder, uploadNodeType, uploadPercent } from "@/lib/canvas/canvas-file-upload";
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
import { uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { getProjectUnit } from "@/services/api/projects";
@@ -141,36 +143,83 @@ export function useCanvasUpload({
return result.assetId;
}, [canvasId, domainProjectId, queryClient]);
const createImageFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传图片", "读取图片文件", domainProjectId ? 4 : 3);
const activeUploadsRef = useRef(new Set<string>());
const createFileNode = useCallback(async (file: File, position: Position, replaceId?: string) => {
const original = replaceId ? nodesRef.current.find((node) => node.id === replaceId) : undefined;
if (replaceId && (!original || activeUploadsRef.current.has(replaceId))) return null;
const id = replaceId || `upload-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
activeUploadsRef.current.add(id);
const progress = startUploadStatus(replaceId ? "替换文件" : "上传文件", "读取文件信息", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
progress.update("更新画布节点", 3);
const size = fitNodeSize(image.width, image.height);
const id = `image-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node: CanvasNodeData = {
id,
type: CanvasNodeType.Image,
title: file.name,
position: { x: position.x - size.width / 2, y: position.y - size.height / 2 },
width: size.width,
height: size.height,
metadata: imageMetadata(image),
const placeholder = await createFileUploadPlaceholder(id, file, position);
setNodes((current) => replaceId ? current.map((item) => item.id === id ? {
...item, width: placeholder.width, height: placeholder.height,
metadata: { ...item.metadata, fileUpload: "uploading", fileUploadProgress: undefined, status: undefined, size: undefined, errorDetails: undefined },
} : item) : [...current, placeholder]);
selectInsertedNode(id, "close");
let lastPercent: number | undefined;
const onProgress = (loaded: number, total: number) => {
const percent = uploadPercent(loaded, total);
if (percent === undefined || percent === lastPercent) return;
lastPercent = percent;
setNodes((current) => current.map((item) => item.id === id ? { ...item, metadata: { ...item.metadata, fileUploadProgress: percent } } : item));
progress.update(percent === 100 ? "文件已传输,正在保存与处理" : `已上传 ${percent}%`, 2);
};
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
progress.update("上传文件并同步资源", 2);
let metadata: CanvasNodeData["metadata"];
if (placeholder.type === CanvasNodeType.Image) {
metadata = imageMetadata(await uploadImage(file, onProgress));
} else if (placeholder.type === CanvasNodeType.Text) {
const content = await file.text();
const resource = await uploadResourceFile(file, "file", { fileName: file.name }, onProgress);
metadata = { content, prompt: content, storageKey: resourceStorageKey(resource.id), mimeType: file.type || "text/plain", bytes: file.size, status: "success" };
} else {
const media = await uploadMediaFile(file, placeholder.type === CanvasNodeType.Video ? "video" : "audio", onProgress);
metadata = placeholder.type === CanvasNodeType.Video ? videoMetadata(media) : audioMetadata(media);
}
progress.update("更新画布节点", 3);
const currentNode = nodesRef.current.find((item) => item.id === id);
if (!currentNode) {
progress.done("上传完成,画布占位已移除");
return null;
}
const node: CanvasNodeData = {
...currentNode, type: placeholder.type,
metadata: {
...currentNode.metadata, ...metadata,
fileUpload: undefined, fileUploadProgress: undefined, errorDetails: undefined,
...(replaceId ? {
assetId: undefined, taskId: undefined, freeResize: false,
isBatchRoot: undefined, batchRootId: undefined, batchChildIds: undefined,
batchFailedCount: undefined, batchUsesReferenceImages: undefined,
generationType: undefined, generationResultPlacement: undefined,
copiedFromNodeId: undefined, versionOfNodeId: undefined,
model: undefined, size: undefined, quality: undefined,
transparentBackground: undefined, count: undefined, references: undefined,
primaryImageId: undefined, imageBatchExpanded: undefined,
richText: undefined, composerContent: undefined,
} : {}),
},
};
setNodes((current) => current.map((item) => item.id === id ? { ...item, type: node.type, metadata: node.metadata } : item));
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已添加到画布" : "图片已添加,项目资产待重试");
const localOnly = metadata.storageKey && !resourceIdFromStorageKey(metadata.storageKey);
progress.done(localOnly ? "已保存在本机,尚未上传到服务器" : persisted ? "文件已添加到画布" : "文件已添加,项目资产待重试");
if (localOnly) message.warning("文件已保存在本机,尚未上传到服务器");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "图片上传失败";
const details = error instanceof Error ? error.message : "文件上传失败";
setNodes((current) => current.map((item) => item.id !== id ? item : original?.metadata?.content ? {
...item, width: original.width, height: original.height, metadata: original.metadata,
} : { ...item, metadata: { ...item.metadata, fileUpload: "error", fileUploadProgress: undefined, errorDetails: details } }));
progress.fail(details);
message.error(details);
return null;
} finally {
activeUploadsRef.current.delete(id);
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
}, [domainProjectId, message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createImageAssetNode = useCallback(async (asset: ImageAsset, position?: Position) => {
try {
@@ -209,52 +258,6 @@ export function useCanvasUpload({
}
}, [getCanvasCenter, message, selectInsertedNode, setNodes]);
const createVideoFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传视频", "读取视频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
const size = fitNodeSize(video.width || 1280, video.height || 720, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node = { id, type: CanvasNodeType.Video, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: videoMetadata(video) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "open");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已添加到画布" : "视频已添加,项目资产待重试");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "视频上传失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createAudioFileNode = useCallback(async (file: File, position: Position) => {
const progress = startUploadStatus("上传音频", "读取音频文件", domainProjectId ? 4 : 3);
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
const size = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
const id = `audio-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const node = { id, type: CanvasNodeType.Audio, title: file.name, position: { x: position.x - size.width / 2, y: position.y - size.height / 2 }, width: size.width, height: size.height, metadata: audioMetadata(audio) } satisfies CanvasNodeData;
setNodes((current) => [...current, node]);
selectInsertedNode(id, "preserve");
if (domainProjectId) progress.update("写入项目资产", 4);
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已添加到画布" : "音频已添加,项目资产待重试");
return id;
} catch (error) {
const details = error instanceof Error ? error.message : "音频上传失败";
progress.fail(details);
message.error(details);
return null;
}
}, [domainProjectId, message, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
const createTextNodeFromClipboard = useCallback((text: string, position?: Position) => {
const trimmed = text.trim();
if (!trimmed) return false;
@@ -312,8 +315,8 @@ export function useCanvasUpload({
: target?.type === CanvasNodeType.Video
? "video/*"
: target?.type === CanvasNodeType.Audio
? "audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav"
: "image/*,video/*,audio/mpeg,audio/wav,audio/x-wav,.mp3,.wav";
? "audio/*,.mp3,.wav"
: target?.type === CanvasNodeType.Text ? "text/plain,text/markdown,.txt,.md,.markdown" : CANVAS_UPLOAD_ACCEPT;
}
imageInputRef.current?.click();
}, [nodesRef]);
@@ -324,9 +327,9 @@ export function useCanvasUpload({
}, []);
const handleUploadFiles = useCallback(async (files: File[]) => {
const supportedFiles = files.filter((file) => file.type.startsWith("image/") || file.type.startsWith("video/") || isAudioFile(file));
const supportedFiles = files.filter((file) => uploadNodeType(file));
if (!supportedFiles.length) {
message.warning("请选择图片、视频、MP3 或 WAV 文件");
message.warning("请选择图片、视频、音频或 TXT / Markdown 文件");
return false;
}
const center = uploadTargetRef.current?.position || getCanvasCenter();
@@ -339,11 +342,7 @@ export function useCanvasUpload({
x: originX + (index % columns) * BATCH_UPLOAD_COLUMN_GAP,
y: center.y + Math.floor(index / columns) * BATCH_UPLOAD_ROW_GAP,
};
const createdId = await (isAudioFile(file)
? createAudioFileNode(file, position)
: file.type.startsWith("video/")
? createVideoFileNode(file, position)
: createImageFileNode(file, position));
const createdId = await createFileNode(file, position);
if (createdId) createdIds.push(createdId);
}
if (!createdIds.length) return false;
@@ -354,7 +353,7 @@ export function useCanvasUpload({
if (failedCount) message.warning(`已添加 ${createdIds.length} 个文件,${failedCount} 个上传失败`);
else message.success(`已添加 ${createdIds.length} 个文件到画布`);
return true;
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
}, [createFileNode, getCanvasCenter, message, setDialogNodeId, setSelectedConnectionId, setSelectedNodeIds]);
// 时间线专用:把本地音视频文件上传为直连媒体(仅时间线作用域,不创建画布节点),返回媒体描述数组。
const uploadTimelineMedia = useCallback(async (files: File[]): Promise<TimelineDirectMedia[]> => {
@@ -441,89 +440,11 @@ export function useCanvasUpload({
const replaceNodeMedia = useCallback(async (nodeId: string, file: File) => {
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
if (!currentNode) return false;
if (isAudioFile(file)) {
const progress = startUploadStatus("替换音频", "读取音频文件");
try {
progress.update("上传到服务器并同步资源", 2);
const audio = await uploadMediaFile(file, "audio");
progress.update("更新画布节点", 3);
const node = { ...currentNode, type: CanvasNodeType.Audio, title: file.name, metadata: { ...currentNode.metadata, ...audioMetadata(audio), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "preserve");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "音频已替换,可撤销恢复" : "音频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "音频替换失败";
progress.fail(details);
message.error(details);
return false;
}
}
if (file.type.startsWith("video/")) {
const progress = startUploadStatus("替换视频", "读取视频文件");
try {
progress.update("上传到服务器并同步资源", 2);
const video = await uploadMediaFile(file, "video");
progress.update("更新画布节点", 3);
const node = { ...currentNode, type: CanvasNodeType.Video, title: file.name, metadata: { ...currentNode.metadata, ...videoMetadata(video), assetId: undefined, taskId: undefined, errorDetails: undefined } } satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "视频已替换,可撤销恢复" : "视频已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "视频替换失败";
progress.fail(details);
message.error(details);
return false;
}
}
const progress = startUploadStatus("替换图片", "读取图片文件");
try {
progress.update("上传到服务器并同步资源", 2);
const image = await uploadImage(file);
progress.update("更新画布节点", 3);
const node = {
...currentNode,
type: CanvasNodeType.Image,
title: file.name,
metadata: {
...currentNode.metadata,
...imageMetadata(image),
assetId: undefined,
taskId: undefined,
errorDetails: undefined,
freeResize: false,
isBatchRoot: undefined,
batchRootId: undefined,
batchChildIds: undefined,
batchFailedCount: undefined,
batchUsesReferenceImages: undefined,
generationType: undefined,
model: undefined,
size: undefined,
quality: undefined,
transparentBackground: undefined,
count: undefined,
references: undefined,
primaryImageId: undefined,
imageBatchExpanded: undefined,
},
} satisfies CanvasNodeData;
setNodes((current) => current.map((item) => item.id === nodeId ? node : item));
selectInsertedNode(nodeId, "open");
const persisted = await persistMediaNode(node);
progress.done(persisted ? "图片已替换,可撤销恢复" : "图片已替换,项目资产待重试");
return true;
} catch (error) {
const details = error instanceof Error ? error.message : "图片替换失败";
progress.fail(details);
message.error(details);
return false;
}
}, [message, nodesRef, persistMediaNode, selectInsertedNode, setNodes, startUploadStatus]);
if (!currentNode || !uploadNodeType(file)) return false;
const typedNode = [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(currentNode.type as CanvasNodeType);
if (typedNode && uploadNodeType(file) !== currentNode.type) return false;
return Boolean(await createFileNode(file, currentNode.position, nodeId));
}, [createFileNode, nodesRef]);
const pasteSystemClipboard = useCallback(async (position?: Position, clipboardEvent?: ClipboardEvent | null) => {
const isNodeMarker = (value: string) => {
@@ -536,7 +457,7 @@ export function useCanvasUpload({
if (await replaceNodeMedia(selected[0].id, file)) message.success("已用剪切板图片替换,可撤销恢复");
return true;
}
const inserted = await createImageFileNode(file, position || getCanvasCenter());
const inserted = await createFileNode(file, position || getCanvasCenter());
if (inserted) message.success("已从剪切板添加图片");
return Boolean(inserted);
};
@@ -587,20 +508,17 @@ export function useCanvasUpload({
// ignore
}
return false;
}, [createImageFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
}, [createFileNode, createTextNodeFromClipboard, getCanvasCenter, message, nodesRef, replaceNodeMedia, selectedNodeIdsRef]);
const handleImageInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
const target = uploadTargetRef.current;
try {
if (!file || (!file.type.startsWith("image/") && !file.type.startsWith("video/") && !isAudioFile(file))) return;
if (!file || !uploadNodeType(file)) return;
if (target?.nodeId) {
const targetNode = nodesRef.current.find((node) => node.id === target.nodeId);
const compatible = !targetNode
|| (targetNode.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|| (targetNode.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|| (targetNode.type === CanvasNodeType.Audio && isAudioFile(file))
|| (targetNode.type !== CanvasNodeType.Image && targetNode.type !== CanvasNodeType.Video && targetNode.type !== CanvasNodeType.Audio);
const compatible = targetNode && (targetNode.type === uploadNodeType(file)
|| ![CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text].includes(targetNode.type as CanvasNodeType));
if (!compatible) {
message.warning("请选择与当前节点相同类型的媒体文件");
return;
@@ -609,12 +527,12 @@ export function useCanvasUpload({
return;
}
const position = target?.position || getCanvasCenter();
await (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
await createFileNode(file, position);
} finally {
uploadTargetRef.current = null;
event.target.value = "";
}
}, [createAudioFileNode, createImageFileNode, createVideoFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
}, [createFileNode, getCanvasCenter, message, nodesRef, replaceNodeMedia]);
const handleDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
@@ -635,7 +553,7 @@ export function useCanvasUpload({
void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY));
return;
}
const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
const files = Array.from(event.dataTransfer.files).filter((item) => uploadNodeType(item));
if (!files.length) return;
if (files.length > 1) {
void handleUploadFiles(files);
@@ -644,9 +562,7 @@ export function useCanvasUpload({
const file = files[0];
const position = screenToCanvas(event.clientX, event.clientY);
const target = [...nodesRef.current].reverse().find((node) => {
const compatible = (node.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|| (node.type === CanvasNodeType.Video && file.type.startsWith("video/"))
|| (node.type === CanvasNodeType.Audio && isAudioFile(file));
const compatible = node.type === uploadNodeType(file);
return compatible && position.x >= node.position.x && position.x <= node.position.x + node.width && position.y >= node.position.y && position.y <= node.position.y + node.height;
});
if (target) {
@@ -655,8 +571,8 @@ export function useCanvasUpload({
});
return;
}
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
void createFileNode(file, position);
}, [createFileNode, createImageAssetNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
if (!hasDraggedFiles(event)) return;
@@ -678,10 +594,10 @@ export function useCanvasUpload({
}, []);
const pasteAssistantImage = useCallback((file: File) => {
void createImageFileNode(file, getCanvasCenter()).then((inserted) => {
void createFileNode(file, getCanvasCenter()).then((inserted) => {
if (inserted) message.success("已从剪切板添加图片");
});
}, [createImageFileNode, getCanvasCenter, message]);
}, [createFileNode, getCanvasCenter, message]);
const openAssetsAtPosition = useCallback((position?: Position) => {
assetInsertPositionRef.current = position || null;

View File

@@ -157,10 +157,11 @@ export default function PluginsPage() {
}, [backendPluginById, categoryFilter, features.systemPluginsVisibleToUsers, installations, pluginStates, registeredPlugins, search, statusFilter, trustFilter, user?.role]);
const pluginSections = useMemo(
() => [
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
() =>
[
...protocolSectionMeta.map((section) => ({ ...section, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, section.key)) })),
{ key: "other", label: "应用插件", description: "画布、素材与工作流扩展", icon: PlugZap, plugins: filteredPlugins.filter((plugin) => pluginMatchesCategory(plugin.manifest, "other")) },
].filter((section) => categoryFilter === "all" || section.key === categoryFilter),
[categoryFilter, filteredPlugins],
);

View File

@@ -54,7 +54,6 @@ import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/
import { ModelPicker } from "@/components/model-picker";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
import { navigateToSettings } from "@/lib/settings-navigation";
import {
@@ -73,7 +72,7 @@ import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
import { chapterStoryboardAssets, chapterStoryboardCharacters, chapterStoryboardReplaceImpact, storyboardRowsToProjectShots } from "./chapter-storyboard-production";
import { chapterCharactersFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
import { chapterAssetsFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterAssets, generateChapterStoryboard } from "./project-chapter-ai";
const CHAPTER_ROW_HEIGHT = 62;
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
@@ -349,14 +348,15 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
const markChapterOperationCompleted = (unitId: string, kind: ChapterOperationKind) => {
setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true }));
};
const storeExtractedCharacters = async (unitId: string, characters: Awaited<ReturnType<typeof extractChapterCharacters>>) => {
const knownNames = new Set([
...detail.assetCandidates.filter((candidate) => candidate.category === "character").map((candidate) => normalizeCharacterName(candidate.name)),
...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)),
]);
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
const created = fresh.length
? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract")
const storeExtractedAssets = async (unitId: string, assets: Awaited<ReturnType<typeof extractChapterAssets>>) => {
// 去重由服务端按分类、名称及别名统一完成,避免使用页面分页快照漏判。
const candidates = [
...assets.characters.map((asset) => ({ unitId, name: asset.name, category: "character" as const, details: { ...asset } })),
...assets.scenes.map((asset) => ({ unitId, name: asset.name, category: "environment" as const, details: { ...asset } })),
...assets.props.map((asset) => ({ unitId, name: asset.name, category: "prop" as const, details: { ...asset } })),
];
const created = candidates.length
? await createProjectAssetCandidates(detail.project.id, candidates, "chapter_character_extract")
: { candidates: [] };
markChapterOperationCompleted(unitId, "characters");
refreshProject();
@@ -380,16 +380,16 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
operationUnitId = input.chapterId;
setCharacterExtractOpen(false);
beginChapterOperation(operationUnitId, "characters");
message.info("角色提取任务已开始,可继续编辑或切换章节");
const characters = await extractChapterCharacters(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
const freshCount = await storeExtractedCharacters(operationUnitId, characters);
message.info("角色、场景与道具提取任务已开始,可继续编辑或切换章节");
const assets = await extractChapterAssets(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
const freshCount = await storeExtractedAssets(operationUnitId, assets);
if (!freshCount) {
message.info("本章角色已存在于待确认列表中");
message.info("没有新增资产:提取为空或资产已存在");
return;
}
message.success(`已提取 ${freshCount} 个角色,请到项目资产确认`);
message.success(`已提取 ${freshCount} 个角色、场景或道具,请到项目资产确认`);
} catch (error) {
message.error(error instanceof Error ? error.message : "角色提取失败");
message.error(error instanceof Error ? error.message : "章节资产提取失败");
} finally {
if (operationUnitId) finishChapterOperation(operationUnitId, "characters");
}
@@ -477,8 +477,8 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
recoveringTaskIdsRef.current.add(task.id);
void queryGenerationTask(task.id).then(async (completedTask) => {
if (kind === "characters") {
await storeExtractedCharacters(taskChapterId, chapterCharactersFromGenerationTask(completedTask));
message.success("已恢复刷新前完成的角色提取结果");
await storeExtractedAssets(taskChapterId, chapterAssetsFromGenerationTask(completedTask));
message.success("已恢复刷新前完成的章节资产提取结果");
} else {
await storeGeneratedStoryboard(taskChapterId, chapterStoryboardFromGenerationTask(completedTask).rows);
message.success("已恢复刷新前完成的章节分镜");
@@ -599,7 +599,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色"}>{characterOperation ? `提取角色(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取角色(已生成)" : "提取角色"}</Button>
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色、场景与道具,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色、场景与道具"}>{characterOperation ? `提取资产(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取资产(已生成)" : "提取角色、场景与道具"}</Button>
<Button size="small" type="primary" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(storyboardOperation)} loading={Boolean(storyboardOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setSelectedSkillIds([]); setStoryboardOpen(true); }} aria-label={storyboardOperation ? `生成到分镜制作,已运行 ${formatOperationElapsed(storyboardOperation.startedAt, operationNow)}` : "生成到分镜制作"}>{storyboardOperation ? `生成到分镜制作(已运行${formatOperationElapsed(storyboardOperation.startedAt, operationNow)})` : storyboardGenerated ? "生成到分镜制作(已生成)" : "生成到分镜制作"}</Button>
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
</div>
@@ -613,7 +613,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
</section>
<CreateChapterModal open={createOpen} onClose={() => setCreateOpen(false)} loading={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
<ImportNovelModal open={importOpen} loading={importMutation.isPending} onClose={() => setImportOpen(false)} onImport={(chapters) => importMutation.mutate(chapters)} />
<Modal title="提取章节角色" open={characterExtractOpen} width={500} okText="开始提取" cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
<Modal title="提取章节角色、场景与道具" open={characterExtractOpen} width={500} okText="开始提取" cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
<div className="grid gap-4">
<div className="rounded-lg border border-border/70 bg-foreground/[.018] px-3 py-2.5">
<div className="text-[var(--fs-tiny)] text-foreground/42">当前章节</div>
@@ -622,7 +622,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground/68">文本模型</span>
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色、场景与道具的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
</label>
</div>
</Modal>

View File

@@ -1,6 +1,7 @@
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown";
import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
import { backendProviderConfig, parseBackendGenerationResult } from "@/services/api/generation-task";
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import type { Skill } from "@/services/api/skills";
@@ -38,24 +39,28 @@ export function chapterTaskIdentity(task: GenerationTask): { chapterId: string;
return null;
}
export function chapterCharactersFromGenerationTask(task: GenerationTask) {
export function chapterAssetsFromGenerationTask(task: GenerationTask) {
const result = parseBackendGenerationResult(task);
if (!result.text?.trim()) throw new Error("模型没有返回可用的角色提取结果");
return parseCharacterBreakdown(result.text);
if (!result.text?.trim()) throw new Error("模型没有返回可用的章节资产提取结果");
// 历史角色任务的输出契约只有角色;仅按明确的模板操作恢复,不能给新任务缺字段兜底。
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === "character_extract") {
return { characters: parseCharacterBreakdown(result.text), scenes: [], props: [] };
}
return parseChapterAssetBreakdown(result.text);
}
export function chapterStoryboardFromGenerationTask(task: GenerationTask) {
return storyboardRowsFromTask(task);
}
export async function extractChapterCharacters(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<CharacterBreakdown[]> {
export async function extractChapterAssets(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<ChapterAssetBreakdown> {
const result = await runProjectTextTask(input, "chapter_character_breakdown", {
项目名称: input.projectName,
章节名称: input.chapterTitle,
项目画风: input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。",
章节正文: input.sourceText,
}, options);
return parseCharacterBreakdown(result);
return parseChapterAssetBreakdown(result);
}
type ChapterStoryboardGenerationInput = {
@@ -118,9 +123,9 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
projectId: input.projectId,
nodeId: `${operation}:${input.chapterId}`,
mode: "text",
prompt: "使用当前启用的角色卡提取模板。",
prompt: "使用当前启用的章节角色、场景与道具提取模板。",
config: { ...input.config, model },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "character_extract", promptTemplateVariables },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "chapter_assets_extract", promptTemplateVariables },
onTaskCreated: options?.onTaskUpdate,
});
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");

View File

@@ -251,7 +251,6 @@ export default function TasksPage() {
const [detail, logs] = await Promise.all([queryGenerationTask(task.id), listTaskLogs(task.id)]);
setDetailTask(detail);
setTaskLogs(logs);
if (await syncGenerationTaskToCanvasStore(detail)) message.success("已同步到画布");
} catch (error) {
message.error(error instanceof Error ? error.message : "任务详情加载失败");
} finally {

View File

@@ -8,6 +8,7 @@ import { CONTENT_MODERATION_ERROR_CODE, isContentModerationError } from "@/lib/g
import { statusLabel } from "@/lib/generation-task-display";
import type { GenerationTask } from "@/services/api/task-center";
import { isTaskFailed, statusDotClassName, TaskDate } from "./task-shared";
import { TaskVideoThumbnail } from "./task-video-thumbnail";
export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: GenerationTask; actingId: string; onOpen: () => void; onRetry: () => void }) {
const isActive = task.status === "queued" || task.status === "running";
@@ -22,7 +23,7 @@ export function TaskGridCard({ task, actingId, onOpen, onRetry }: { task: Genera
{thumbnailUrl ? (
<MediaPreview src={thumbnailUrl} kind="image" loading="lazy" className="h-full w-full object-cover" />
) : isVideo && task.previewUrl ? (
<span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>
<TaskVideoThumbnail src={task.previewUrl} />
) : (
<Icon />
)}

View File

@@ -10,6 +10,7 @@ import { formatTaskKind, statusLabel } 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";
import { TaskVideoThumbnail } from "./task-video-thumbnail";
export function TaskListRow({
task,
@@ -122,7 +123,7 @@ function TaskPreviewThumbnail({ task, onOpen }: { task: GenerationTask; onOpen:
aria-label={previewUnavailable ? "预览不可用,素材可能已删除" : isVideo ? "放大预览生成视频" : "放大预览生成图片"}
title={previewUnavailable ? "预览不可用,素材可能已删除" : undefined}
>
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : <span className="task-video-poster-placeholder"><Video className="size-4" /><small>视频</small></span>}
{thumbnailUrl ? <MediaPreview src={thumbnailUrl} kind="image" width={68} height={48} loading="lazy" className="h-full w-full object-cover" fallbackLabel="预览不可用" onUnavailable={() => setUnavailableUrl(thumbnailUrl)} /> : isVideo ? <TaskVideoThumbnail src={task.previewUrl} /> : <ImageIcon className="size-4" />}
{!previewUnavailable ? (
<span className="absolute inset-0 grid place-items-center bg-black/0 text-white opacity-0 transition-[background-color,opacity] duration-150 group-hover:bg-black/30 group-hover:opacity-100 group-focus-visible:bg-black/30 group-focus-visible:opacity-100">
{isVideo ? <Play className="size-4 fill-current" /> : <Eye className="size-4" />}

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";
import { Video } from "lucide-react";
import { MediaPreview } from "@/components/media-preview";
import { captureVideoPoster } from "@/lib/video-poster";
/** 只解码进入视口的视频,复用串行解码队列;缩略图随组件释放,不写入用户资产。 */
export function TaskVideoThumbnail({ src }: { src: string }) {
const container = useRef<HTMLSpanElement>(null);
const [poster, setPoster] = useState<{ source: string; url: string }>();
useEffect(() => {
const controller = new AbortController();
let objectUrl = "";
let started = false;
const capture = () => {
if (started || controller.signal.aborted) return;
started = true;
void captureVideoPoster(src, { signal: controller.signal, maxWidth: 400 }).then((result) => {
if (controller.signal.aborted || !result.poster) return;
objectUrl = URL.createObjectURL(result.poster);
setPoster({ source: src, url: objectUrl });
}).catch((error) => {
if (!controller.signal.aborted) console.warn("任务视频首帧提取失败", error instanceof Error ? error.name : "UnknownError");
});
};
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
capture();
}
});
if (container.current) observer.observe(container.current);
return () => {
controller.abort();
observer.disconnect();
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [src]);
return <span ref={container} className="block h-full w-full">
{poster?.source === src ? <MediaPreview src={poster.url} kind="image" className="h-full w-full object-cover" /> : <span className="task-video-poster-placeholder"><Video /><small>视频预览</small></span>}
</span>;
}

View File

@@ -567,7 +567,9 @@ export function updateAdminDrawingEngineSetting(input: Pick<CanvasDrawingEngineS
return request<{ setting: CanvasDrawingEngineSetting }>(api.patch("/admin/settings/drawing-engine", input));
}
export function listAdminApiLogs(params: AdminListParams = {}) {
export type AdminApiLogParams = AdminListParams & { recordType?: "request" | "download" | "all" };
export function listAdminApiLogs(params: AdminApiLogParams = {}) {
return request<{ logs: ApiCallLog[]; total: number; page: number; limit: number }>(api.get("/admin/api-logs", { params }));
}
@@ -579,7 +581,7 @@ export function queryAdminApiLogTask(id: string) {
return request<AdminProviderTaskQueryResult>(api.post(`/admin/api-logs/${encodeURIComponent(id)}/query-task`));
}
export async function exportAdminApiLogs(params: AdminListParams & { ids?: string[] } = {}) {
export async function exportAdminApiLogs(params: AdminApiLogParams & { ids?: string[] } = {}) {
const response = await api.get<Blob>("/admin/api-logs-export.csv", { params: { ...params, ids: params.ids?.join(",") }, responseType: "blob" });
return response.data;
}

View File

@@ -137,7 +137,12 @@ export async function uploadResourceFile(
if (meta?.height) formData.append("height", String(Math.round(meta.height)));
if (meta?.durationMs) formData.append("durationMs", String(Math.round(meta.durationMs)));
try {
const data = await request<{ resource: RemoteResource }>(api.post("/resources", formData, uploadRequestConfig(meta?.idempotencyKey)));
const data = await request<{ resource: RemoteResource }>(api.post("/resources", formData, {
...uploadRequestConfig(meta?.idempotencyKey),
onUploadProgress: onProgress ? ({ loaded, total }) => {
if (total && total > 0) onProgress(Math.min(file.size, file.size * loaded / total), file.size);
} : undefined,
}));
resourceCache.set(resourceCacheKey(data.resource.id), data.resource);
return data.resource;
} catch (error) {
@@ -170,7 +175,10 @@ async function runChunkedUpload(file: Blob, name: string, kind: "image" | "video
const data = new FormData();
data.append("chunk", blob);
// raw 二进制直传,与后端按裸 body 逐片落盘对齐(勿设手动 Content-Type,让 axios 处理)。
await request<{ index: number }>(api.put(`/resources/uploads/${encodeURIComponent(session.uploadId)}/chunks/${index}`, blob, { headers: { "Content-Type": "application/octet-stream" } }));
await request<{ index: number }>(api.put(`/resources/uploads/${encodeURIComponent(session.uploadId)}/chunks/${index}`, blob, {
headers: { "Content-Type": "application/octet-stream" },
onUploadProgress: onProgress ? ({ loaded }) => onProgress(start + Math.min(loaded, blob.size), file.size) : undefined,
}));
onProgress?.(Math.min(end, file.size), file.size);
}
const complete = await request<{ resource: RemoteResource }>(api.post(`/resources/uploads/${encodeURIComponent(session.uploadId)}/complete`));

View File

@@ -12,7 +12,7 @@ export type UploadedFile = { url: string; storageKey: string; bytes: number; mim
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "media_files" });
const objectUrls = new Map<string, string>();
export async function uploadMediaFile(input: string | Blob, prefix = "file"): Promise<UploadedFile> {
export async function uploadMediaFile(input: string | Blob, prefix = "file", onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise<UploadedFile> {
// 直传和失败后的本地同步必须复用同一上传身份,避免响应丢失后创建第二个对象。
const storageKey = `${prefix}:${getActiveUserScope()}:${nanoid()}`;
const blob = typeof input === "string" ? await (await fetch(input)).blob() : input;
@@ -31,7 +31,7 @@ export async function uploadMediaFile(input: string | Blob, prefix = "file"): Pr
const poster = captured?.poster ? await uploadImage(captured.poster).catch(() => undefined) : undefined;
try {
const kind = blob.type.startsWith("video/") ? "video" : blob.type.startsWith("audio/") ? "audio" : "file";
const resource = await uploadResourceFile(blob, kind, { ...meta, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey });
const resource = await uploadResourceFile(blob, kind, { ...meta, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey }, onProgress);
await primeResourceBlobCache(resourceStorageKey(resource.id), blob).catch(() => "");
URL.revokeObjectURL(previewUrl);
return { url: resource.publicUrl || resourceFileUrl(resource.id), storageKey: resourceStorageKey(resource.id), bytes: resource.size || blob.size, mimeType: resource.mimeType || blob.type || "application/octet-stream", width: resource.width || meta.width, height: resource.height || meta.height, durationMs: resource.durationMs || meta.durationMs, hasAudio: meta.hasAudio, preview: poster };

View File

@@ -18,7 +18,7 @@ export type UploadedImage = {
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "image_files" });
const objectUrls = new Map<string, string>();
export async function uploadImage(input: string | Blob): Promise<UploadedImage> {
export async function uploadImage(input: string | Blob, onProgress?: (uploadedBytes: number, totalBytes: number) => void): Promise<UploadedImage> {
// 同一个逻辑上传在直传失败后会退回 IndexedDB,并由云端数据同步再次提交。
// 提前生成本地 key,确保两条路径向后端发送相同的幂等标识。
const storageKey = `image:${getActiveUserScope()}:${nanoid()}`;
@@ -41,7 +41,7 @@ export async function uploadImage(input: string | Blob): Promise<UploadedImage>
const previewUrl = URL.createObjectURL(blob);
const meta = await readImageMeta(previewUrl);
try {
const resource = await uploadResourceFile(blob, "image", { width: meta.width, height: meta.height, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey });
const resource = await uploadResourceFile(blob, "image", { width: meta.width, height: meta.height, fileName: input instanceof File ? input.name : undefined, idempotencyKey: storageKey }, onProgress);
await primeResourceBlobCache(resourceStorageKey(resource.id), blob).catch(() => "");
URL.revokeObjectURL(previewUrl);
return {

View File

@@ -8040,6 +8040,18 @@
.canvas-node-shell {
box-shadow: none;
transition: transform 160ms ease-out;
}
/* Only a valid connection approach tilts; selection and ordinary hover stay flat. */
.canvas-node-shell[data-connection-tilt="true"] {
transform: perspective(900px) rotateX(var(--connection-tilt-x)) rotateY(var(--connection-tilt-y));
will-change: transform;
}
.no-motion .canvas-node-shell[data-connection-tilt="true"] {
transform: none;
transition: none;
}
.canvas-node-inline-action {
@@ -16125,3 +16137,67 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
letter-spacing: .04em;
}
.creation-history-group-head h4::after { content: ""; flex: 1; height: 1px; background: var(--creation-history-line); transform: translateY(-2px); }
/* 图片上传:浅磨砂与低饱和流光,和模型生成状态分离。 */
.canvas-file-upload {
--upload-glow-blue: color-mix(in srgb, var(--status-loading) 22%, transparent);
--upload-glow-mint: color-mix(in srgb, var(--status-success) 14%, transparent);
--upload-glow-warm: color-mix(in srgb, var(--status-warning) 12%, transparent);
position: relative;
isolation: isolate;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--upload-surface);
color: var(--upload-text);
}
.canvas-file-upload-glow {
transition: opacity 180ms ease-out;
position: absolute;
inset: -45%;
background: radial-gradient(ellipse at 28% 32%, var(--upload-glow-blue), transparent 48%), radial-gradient(ellipse at 75% 64%, var(--upload-glow-mint), transparent 46%), radial-gradient(ellipse at 55% 78%, var(--upload-glow-warm), transparent 42%);
animation: canvas-file-upload-drift 5s ease-in-out infinite alternate;
pointer-events: none;
}
.canvas-file-upload-frost {
transition: opacity 180ms ease-out;
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--upload-surface) 24%, transparent);
backdrop-filter: blur(20px) saturate(0.8);
pointer-events: none;
}
.canvas-file-upload-frost::after {
content: "";
position: absolute;
inset: -60%;
background: linear-gradient(110deg, transparent 35%, color-mix(in srgb, var(--upload-text) 4%, transparent) 47%, color-mix(in srgb, var(--upload-surface) 40%, transparent) 51%, transparent 65%);
animation: canvas-file-upload-sheen 4s ease-in-out infinite;
}
.canvas-file-upload-copy {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
height: 100%;
padding: var(--space-5);
text-align: center;
}
.canvas-file-upload-copy > svg { color: var(--upload-muted); }
.canvas-file-upload-label { font-size: var(--fs-caption); font-weight: 500; }
.canvas-file-upload-detail { max-width: 100%; font-size: var(--fs-tiny); color: var(--upload-muted); overflow-wrap: anywhere; }
.canvas-file-upload-retry { margin-top: var(--space-2); padding: var(--space-2) var(--space-3); border-radius: var(--r-md); background: color-mix(in srgb, var(--upload-text) 8%, transparent); font-size: var(--fs-caption); cursor: pointer; }
.canvas-file-upload-retry:hover { background: color-mix(in srgb, var(--upload-text) 12%, transparent); }
.canvas-file-upload-retry:focus-visible { outline: 2px solid var(--control-focus-ring); outline-offset: 2px; }
@keyframes canvas-file-upload-drift { from { transform: translate(-8%, -4%) rotate(-8deg); } to { transform: translate(8%, 4%) rotate(12deg); } }
@keyframes canvas-file-upload-sheen { from { transform: translateX(-35%); } to { transform: translateX(35%); } }
.canvas-file-upload[data-static] .canvas-file-upload-glow,
.canvas-file-upload[data-static] .canvas-file-upload-frost::after { animation: none; }
.canvas-file-upload[data-static] .canvas-file-upload-glow,
.canvas-file-upload[data-static] .canvas-file-upload-frost { transition: none; }
@media (prefers-reduced-motion: reduce) {
.canvas-file-upload-glow, .canvas-file-upload-frost::after { animation: none; }
.canvas-file-upload-glow, .canvas-file-upload-frost { transition: none; }
}

View File

@@ -210,6 +210,9 @@ export type CanvasNodeMetadata = {
promptTemplateOperation?: string;
promptTemplateVariables?: Record<string, string>;
status?: CanvasNodeStatus;
/** 浏览器文件上传,与模型生成任务状态独立。 */
fileUpload?: "uploading" | "error";
fileUploadProgress?: number;
locked?: boolean;
errorDetails?: string;
generationErrorCode?: string;

View File

@@ -0,0 +1,47 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { canvasConnectionTilt, latchCanvasConnectionApproach } from "../src/lib/canvas/canvas-connection-tilt";
const node = { position: { x: 100, y: 200 }, width: 400, height: 200 };
test("connection approach determines tilt direction and pivot", () => {
expect(canvasConnectionTilt(node, { x: 100, y: 300 })).toEqual({ rotateX: 0, rotateY: -5, origin: "0% 50%" });
expect(canvasConnectionTilt(node, { x: 500, y: 300 })).toEqual({ rotateX: 0, rotateY: 5, origin: "100% 50%" });
expect(canvasConnectionTilt(node, { x: 300, y: 200 })?.rotateX).toBe(5);
expect(canvasConnectionTilt(node, { x: 300, y: 400 })?.rotateX).toBe(-5);
});
test("snap padding is clamped, absent or invalid approach stays flat", () => {
expect(canvasConnectionTilt(node, { x: -100, y: 700 })).toEqual({ rotateX: -5, rotateY: -5, origin: "0% 100%" });
expect(canvasConnectionTilt(node)).toBeUndefined();
expect(canvasConnectionTilt(node, { x: NaN, y: 0 })).toBeUndefined();
expect(canvasConnectionTilt({ ...node, width: 0 }, { x: 0, y: 0 })).toBeUndefined();
});
test("world-space approach is independent of viewport scaling", () => {
const point = { x: 150, y: 250 };
const result = canvasConnectionTilt(node, point);
for (const scale of [0.25, 1, 4]) {
const screen = { x: point.x * scale + 30, y: point.y * scale - 70 };
expect(canvasConnectionTilt(node, { x: (screen.x - 30) / scale, y: (screen.y + 70) / scale })).toEqual(result);
}
});
test("only connection targets receive approach data; memo tracks it and selection does not activate tilt", () => {
const source = readFileSync(new URL("../src/components/canvas/canvas-node.tsx", import.meta.url), "utf8");
const layers = readFileSync(new URL("../src/pages/canvas/canvas-project-world-layers.tsx", import.meta.url), "utf8");
expect(source).toContain("isConnectionTarget && !reduceMediaEffects && !dragOffset");
expect(source).toContain("previous.connectionApproach?.x === next.connectionApproach?.x");
expect(source).toContain('data-connection-tilt={connectionTilt ? "true" : undefined}');
expect(layers).toContain("props.connectionApproach?.nodeId === node.id ? props.connectionApproach.point : undefined");
expect(layers).not.toMatch(/connectionApproach=\{[^\n]*mouseWorld/);
});
test("600 pointer frames inside one target retain identical entry data", () => {
const entry = latchCanvasConnectionApproach(null, "target", { x: 10, y: 20 });
for (let frame = 0; frame < 600; frame++) {
expect(latchCanvasConnectionApproach(entry, "target", { x: frame, y: frame })).toBe(entry);
}
expect(entry?.point).toEqual({ x: 10, y: 20 });
const next = latchCanvasConnectionApproach(entry, "other", { x: 100, y: 200 });
expect(next).not.toBe(entry);
expect(next?.point).toEqual({ x: 100, y: 200 });
expect(latchCanvasConnectionApproach(next, null, { x: 0, y: 0 })).toBeNull();
expect(latchCanvasConnectionApproach(null, "target", { x: 30, y: 40 })?.point).toEqual({ x: 30, y: 40 });
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createPortraitTextureNode, isCanvasImageSourceNode } from "../src/lib/canvas/canvas-image-source";
import { canGenerateImageInPlace } from "../src/lib/canvas/canvas-generation-layout";
import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
const source: CanvasNodeData = {
id: "uploaded", type: CanvasNodeType.Image, title: "原图", position: { x: 10, y: 20 }, width: 300, height: 200,
metadata: { content: "original-image", storageKey: "original-key", status: "success" },
};
const read = (path: string) => readFileSync(resolve(import.meta.dir, "../src", path), "utf8");
describe("上传图片是输入素材", () => {
test("上传与导入图片没有生成框,空节点与生成结果仍可配置", () => {
expect(isCanvasImageSourceNode(source)).toBe(true);
expect(isCanvasImageSourceNode({ ...source, metadata: {} })).toBe(false);
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, generationType: "edit" } })).toBe(false);
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, taskId: "task" } })).toBe(false);
expect(isCanvasImageSourceNode({ ...source, type: CanvasNodeType.Video })).toBe(false);
expect(isCanvasImageSourceNode(null)).toBe(false);
});
test("显式复制/版本生成入口保持可用", () => {
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, generationResultPlacement: "replace-node" } })).toBe(false);
expect(isCanvasImageSourceNode({ ...source, metadata: { ...source.metadata, copiedFromNodeId: "original" } })).toBe(false);
});
test("人物质感创建独立编辑节点,不复制原图输出和任务状态", () => {
const before = structuredClone(source);
const child = createPortraitTextureNode(source, "texture");
expect(source).toEqual(before);
expect(child.id).toBe("texture");
expect(child.position).toEqual({ x: 406, y: 20 });
expect(child.metadata?.composerContent).toBe("@图片1");
expect(child.metadata?.generationType).toBe("edit");
expect(child.metadata?.content).toBeUndefined();
expect(child.metadata?.storageKey).toBeUndefined();
expect(child.metadata?.taskId).toBeUndefined();
expect(isCanvasImageSourceNode(child)).toBe(false);
expect(canGenerateImageInPlace(child)).toBe(true);
expect(canGenerateImageInPlace(source)).toBe(false);
});
test("页面和工具入口共享素材判定,人物质感接入原图连线", () => {
expect(read("pages/canvas/project.tsx")).toContain("dialogNode && !isCanvasImageSourceNode(dialogNode)");
expect(read("lib/canvas/tool-registry/definitions/node-hover-tools.tsx")).toContain("!isCanvasImageSourceNode(ctx.node)");
const mediaTools = read("pages/canvas/use-canvas-media-tools.ts");
const portrait = mediaTools.slice(mediaTools.indexOf("const openPortraitTextureEditor"), mediaTools.indexOf("const cropImageNode"));
expect(portrait).toContain("fromNodeId: node.id, toNodeId: child.id");
expect(portrait).toContain("setDialogNodeId(child.id)");
expect(portrait).not.toContain("current.map");
});
});
test("底部菜单使用画布浮层管理并响应打开、鼠标和键盘交互", () => {
const toolbar = read("components/canvas/canvas-toolbar.tsx");
expect(toolbar).toContain('useCanvasOverlayLayer("main-toolbar", "var(--z-toolbar)")');
expect(toolbar).toContain("if (addOpen || appearanceOpen) bringToFront()");
expect(toolbar).toContain("style={{ zIndex }} onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}");
});

View File

@@ -0,0 +1,140 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createImageUploadPlaceholder, createFileUploadPlaceholder, interruptFileUpload, readUploadImageSize, uploadNodeType, uploadPercent, uploadVeilOpacity } from "../src/lib/canvas/canvas-file-upload";
import { CanvasNodeType } from "../src/types/canvas";
import { isCanvasImageSourceNode } from "../src/lib/canvas/canvas-image-source";
import { CanvasFileUploadContent } from "../src/components/canvas/canvas-file-upload-content";
import { CanvasNodeActionContext } from "../src/components/canvas/canvas-node-action-context";
import { canvasThemes } from "../src/lib/canvas-theme";
const file = { name: "photo.png", size: 1234, type: "image/png" };
const placeholder = () => createImageUploadPlaceholder("upload", file, { x: 100, y: 200 }, { width: 1920, height: 1080 });
const read = (path: string) => readFileSync(resolve(import.meta.dir, "../src", path), "utf8");
describe("图片上传占位", () => {
for (const [width, height] of [[1920, 1080], [900, 1600], [800, 800], [4000, 500]]) {
test(`按真实尺寸保持比例并围绕落点居中 ${width}x${height}`, () => {
const node = createImageUploadPlaceholder("upload", file, { x: 100, y: 200 }, { width, height });
expect(node.width / node.height).toBeCloseTo(width / height);
expect(node.position.x + node.width / 2).toBe(100);
expect(node.position.y + node.height / 2).toBe(200);
expect(node.metadata?.naturalWidth).toBe(width);
expect(node.metadata?.naturalHeight).toBe(height);
expect(node.metadata?.fileUpload).toBe("uploading");
expect(node.metadata?.content).toBeUndefined();
expect(node.metadata?.status).toBeUndefined();
expect(isCanvasImageSourceNode(node)).toBe(true);
});
}
test("重新打开画布将中断上传转为可重试错误,不修改其他节点", () => {
const node = placeholder();
const interrupted = interruptFileUpload(node);
expect(interrupted.metadata?.fileUpload).toBe("error");
expect(interrupted.metadata?.errorDetails).toContain("中断");
expect(interrupted.position).toEqual(node.position);
expect(node.metadata?.fileUpload).toBe("uploading");
expect(interruptFileUpload(interrupted)).toBe(interrupted);
const ready = { ...node, metadata: { content: "image" } };
expect(interruptFileUpload(ready)).toBe(ready);
});
test("图片解码成功和失败均释放临时 URL,不伪造尺寸", async () => {
const originalImage = globalThis.Image;
const create = URL.createObjectURL;
const revoke = URL.revokeObjectURL;
const revoked: string[] = [];
let fail = false;
try {
URL.createObjectURL = () => "blob:upload-test";
URL.revokeObjectURL = (url) => { revoked.push(url); };
globalThis.Image = class {
src = "";
naturalWidth = 1200;
naturalHeight = 800;
async decode() { if (fail) throw new Error("decode failed"); }
} as unknown as typeof Image;
const input = new File(["test"], "test.png", { type: "image/png" });
expect(await readUploadImageSize(input)).toEqual({ width: 1200, height: 800 });
fail = true;
await expect(readUploadImageSize(input)).rejects.toThrow("decode failed");
expect(revoked).toEqual(["blob:upload-test", "blob:upload-test"]);
} finally {
globalThis.Image = originalImage;
URL.createObjectURL = create;
URL.revokeObjectURL = revoke;
}
});
for (const mode of ["light", "dark"] as const) {
test(`${mode}主题包含可访问的上传状态、减弱动效和失败重试`, () => {
const html = renderToStaticMarkup(<CanvasFileUploadContent node={placeholder()} theme={canvasThemes[mode]} reduceMotion />);
expect(html).toContain("正在上传图片");
expect(html).toContain('aria-busy="true"');
expect(html).toContain('data-static="true"');
expect(html).toContain(canvasThemes[mode].node.fill);
const error = renderToStaticMarkup(<CanvasNodeActionContext.Provider value={{ upload: () => {} }}><CanvasFileUploadContent node={interruptFileUpload(placeholder())} theme={canvasThemes[mode]} /></CanvasNodeActionContext.Provider>);
expect(error).toContain("重新选择文件");
expect(error).toContain('aria-busy="false"');
expect(error).toContain('data-static="true"');
});
}
test("上传先占位再传输,完成替换同一节点,弹窗不等待传输完成", () => {
const hook = read("pages/canvas/use-canvas-upload.ts");
const create = hook.slice(hook.indexOf("const createFileNode"), hook.indexOf("const createImageAssetNode"));
expect(create.indexOf("createFileUploadPlaceholder")).toBeLessThan(create.indexOf("await uploadImage(file,"));
expect(create).toContain("if (!currentNode)");
expect(create).toContain("fileUpload: undefined");
const replace = hook.slice(hook.indexOf('const replaceNodeMedia'), hook.indexOf("const pasteSystemClipboard"));
expect(replace).toContain("await createFileNode(file, currentNode.position, nodeId)");
expect(create).toContain("original?.metadata?.content");
expect(read("components/canvas/canvas-upload-modal.tsx")).toContain("onClose();\n await pendingUpload;");
expect(read("styles/globals.css")).toContain("prefers-reduced-motion: reduce");
});
});
test("真实进度与遮罩浓度反向关联,不生成未知进度", () => {
expect(uploadPercent(20, 100)).toBe(20);
expect(uploadPercent(150, 100)).toBe(100);
expect(uploadPercent(0, 0)).toBeUndefined();
expect(uploadPercent(NaN, 100)).toBeUndefined();
expect(uploadVeilOpacity(0)).toBe(1);
expect(uploadVeilOpacity(50)).toBeCloseTo(0.55);
expect(uploadVeilOpacity(100)).toBeCloseTo(0.1);
});
test("统一识别图片、视频、音频、纯文本文件,不误收 PDF 或 Word", () => {
for (const [name, type, expected] of [
["image.png", "image/png", CanvasNodeType.Image], ["video.mp4", "video/mp4", CanvasNodeType.Video],
["sound.ogg", "audio/ogg", CanvasNodeType.Audio], ["sound.wav", "", CanvasNodeType.Audio],
["note.md", "", CanvasNodeType.Text], ["note.txt", "text/plain", CanvasNodeType.Text],
["document.pdf", "application/pdf", null], ["document.docx", "", null],
] as const) expect(uploadNodeType({ name, type })).toBe(expected);
});
test("音频与文本在传输前就有独立占位,可重试且不带模型生成状态", async () => {
for (const [name, type, expected] of [["voice.mp3", "audio/mpeg", CanvasNodeType.Audio], ["note.md", "text/markdown", CanvasNodeType.Text]] as const) {
const node = await createFileUploadPlaceholder("file", new File(["content"], name, { type }), { x: 0, y: 0 });
expect(node.type).toBe(expected);
expect(node.metadata?.fileUpload).toBe("uploading");
expect(node.metadata?.content).toBeUndefined();
expect(node.metadata?.status).toBeUndefined();
expect(interruptFileUpload(node).metadata?.fileUpload).toBe("error");
}
});
test("所有资源渲染真实百分比,100% 仍等待保存,磨砂渐淡但文案不淡出", () => {
for (const type of [CanvasNodeType.Image, CanvasNodeType.Video, CanvasNodeType.Audio, CanvasNodeType.Text]) {
const node = { ...placeholder(), type, metadata: { fileUpload: "uploading" as const, fileUploadProgress: 50 } };
const html = renderToStaticMarkup(<CanvasFileUploadContent node={node} theme={canvasThemes.light} />);
expect(html).toContain("已上传 50%");
expect(html).toContain('opacity:0.55');
expect(html).toContain('aria-valuenow="50"');
const complete = renderToStaticMarkup(<CanvasFileUploadContent node={{ ...node, metadata: { ...node.metadata, fileUploadProgress: 100 } }} theme={canvasThemes.dark} />);
expect(complete).toContain("文件已传输,正在保存与处理");
expect(complete).toContain('aria-busy="true"');
}
});

View File

@@ -190,7 +190,8 @@ describe("canvas resource mention slots", () => {
expect(references.get(target.id)?.map((reference) => reference.nodeId)).toEqual([audio.id]);
expect(references.get(config.id)?.map((reference) => reference.nodeId)).toEqual([target.id, audio.id]);
expect(references.get(image.id)?.map((reference) => reference.nodeId)).toEqual([image.id]);
expect(references.get(image.id)?.map((reference) => reference.nodeId)).toEqual([]);
expect(buildNodeMentionReferences(image, nodes, connections)).toEqual([]);
});
test("素材库身份 token 保持稳定", () => {
@@ -259,4 +260,3 @@ describe("remove canvas resource mention tokens", () => {
expect(nextTarget.metadata?.composerContent).toBe("比较 和 @图片1");
});
});

View File

@@ -0,0 +1,90 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { bindCanvasVideoHoverPreview, VIDEO_HOVER_DELAY_MS } from "../src/lib/canvas/canvas-video-hover-preview";
const wait = (ms = VIDEO_HOVER_DELAY_MS + 30) => new Promise((resolve) => setTimeout(resolve, ms));
const originals = new Map<string, PropertyDescriptor | undefined>();
let videos: FakeVideo[];
let reduced = false;
let interacting = false;
let mutation: () => void;
let visibility: (entries: { isIntersecting: boolean }[]) => void;
const cleanups: (() => void)[] = [];
class FakeVideo extends EventTarget {
paused = true; currentTime = 0; src = ""; muted = false; playsInline = false; preload = "";
className = ""; dataset: Record<string, string> = {}; removed = false; loads = 0;
setAttribute() {}
removeAttribute() { this.src = ""; }
load() { this.loads++; }
remove() { this.removed = true; }
pause() { this.paused = true; }
async play() { this.paused = false; this.dispatchEvent(new Event("playing")); }
}
class FakeElement extends EventTarget {
isConnected = true;
closest(selector: string) { return selector.includes('="true"') ? (interacting ? this : null) : this; }
getAttribute() { return interacting ? "true" : null; }
appendChild(video: FakeVideo) { videos.push(video); }
}
function stub(key: string, value: unknown) {
originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
function setup(resolve: () => Promise<string> = async () => "test.mp4") {
const element = new FakeElement();
cleanups.push(bindCanvasVideoHoverPreview(element as unknown as HTMLElement, resolve));
return element;
}
function enter(element: FakeElement, pointerType = "mouse", buttons = 0) {
element.dispatchEvent(Object.assign(new Event("pointerenter"), { pointerType, buttons }));
}
beforeEach(() => {
videos = []; reduced = false; interacting = false;
stub("window", Object.assign(new EventTarget(), { matchMedia: () => ({ matches: reduced }) }));
stub("document", Object.assign(new EventTarget(), { hidden: false, querySelectorAll: () => videos.filter((v) => !v.removed), createElement: () => new FakeVideo() }));
stub("MutationObserver", class { constructor(callback: () => void) { mutation = callback; } observe() {} disconnect() {} });
stub("IntersectionObserver", class { constructor(callback: typeof visibility) { visibility = callback; } observe() {} disconnect() {} });
});
afterEach(() => {
cleanups.splice(0).forEach((cleanup) => cleanup());
for (const [key, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
originals.clear();
});
test("quick pass, touch, dragging and reduced motion never resolve media", async () => {
let calls = 0;
const element = setup(async () => { calls++; return "test.mp4"; });
enter(element); element.dispatchEvent(new Event("pointerleave"));
enter(element, "touch"); enter(element, "mouse", 1);
reduced = true; enter(element);
await wait();
expect(calls).toBe(0);
});
test("hover is muted and capped at first three seconds; resources are released", async () => {
const element = setup(); enter(element); await wait();
const video = videos[0];
expect(video.muted).toBe(true); expect(video.playsInline).toBe(true);
video.currentTime = 3; video.dispatchEvent(new Event("timeupdate"));
expect(video.paused).toBe(true); expect(video.src).toBe(""); expect(video.loads).toBe(1); expect(video.removed).toBe(true);
});
test("new hover cancels previous decoder and ignores stale URL resolution", async () => {
let resolve!: (value: string) => void;
const slow = setup(() => new Promise<string>((done) => { resolve = done; }));
enter(slow); await wait();
const next = setup(); enter(next); resolve("stale.mp4"); await wait();
expect(videos.length).toBe(1); expect(videos[0].src).toBe("test.mp4");
enter(slow); expect(videos[0].removed).toBe(true);
});
test("wheel, viewport interaction and leaving viewport cancel playback", async () => {
const element = setup(); enter(element); await wait();
window.dispatchEvent(new Event("wheel")); expect(videos[0].removed).toBe(true);
enter(element); await wait(); interacting = true; mutation(); expect(videos[1].removed).toBe(true);
interacting = false; enter(element); await wait(); visibility([{ isIntersecting: false }]); expect(videos[2].removed).toBe(true);
});
test("manual playback is never competing with hover", async () => {
const manual = new FakeVideo(); manual.paused = false; videos.push(manual);
let calls = 0; const element = setup(async () => { calls++; return "test.mp4"; });
enter(element); await wait(); expect(calls).toBe(0); expect(manual.paused).toBe(false);
});

View File

@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test";
import { parseChapterAssetBreakdown } from "../src/lib/canvas/chapter-asset-breakdown";
describe("章节三类资产解析", () => {
test("无角色章节仍可提取场景和道具", () => {
const result = parseChapterAssetBreakdown(JSON.stringify({ characters: [], scenes: [{ name: "车站", description: "空旷站台", prompt: "夜间站台" }], props: [{ name: "信封", description: "牛皮纸信封", prompt: "褐色信封特写" }] }));
expect(result.characters).toEqual([]);
expect(result.scenes[0].name).toBe("车站");
expect(result.props[0].name).toBe("信封");
});
test("缺失数组或缺失描述不能静默跳过", () => {
expect(() => parseChapterAssetBreakdown('{"characters":[]}')).toThrow("缺少");
expect(() => parseChapterAssetBreakdown('{"characters":[],"scenes":[{"name":"车站"}],"props":[]}')).toThrow("description");
});
test("支持围栏 JSON 和无资产章节", () => {
expect(parseChapterAssetBreakdown('```json\n{"characters":[],"scenes":[],"props":[]}\n```')).toEqual({ characters: [], scenes: [], props: [] });
});
test("忽略正文解释但仍校验完整资产对象", () => {
expect(parseChapterAssetBreakdown('提取结果如下:\n{"characters":[],"scenes":[],"props":[]}\n以上为全部结果。')).toEqual({ characters: [], scenes: [], props: [] });
});
});

View File

@@ -11,7 +11,14 @@ import { CanvasNodeType, type CanvasNodeData } from "../src/types/canvas";
import { onlineToolToOps } from "../src/components/canvas/canvas-assistant-panel";
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel } from "../src/lib/generation-task-display";
import { generationErrorMessage } from "../src/lib/generation-error";
import { generationTaskNodeId } from "../src/lib/canvas/canvas-generation-task-sync";
import { generationTaskNodeId, syncGenerationTaskToCanvasStore } from "../src/lib/canvas/canvas-generation-task-sync";
test("业务项目任务和无节点任务不会读取画布项目", async () => {
const task = { id: "chapter-task", type: "canvas_text", status: "succeeded", projectId: "domain-project", clientContext: { domainProjectId: "domain-project", nodeId: "chapter_character_breakdown:chapter" } } as GenerationTask;
expect(await syncGenerationTaskToCanvasStore(task)).toBe(false);
expect(await syncGenerationTaskToCanvasStore({ ...task, clientContext: undefined, inputJson: JSON.stringify({ metadata: { domainProjectId: "domain-project", nodeId: "chapter-node" } }) })).toBe(false);
expect(await syncGenerationTaskToCanvasStore({ ...task, clientContext: undefined })).toBe(false);
});
test("canvas recovery reads node identity from task summaries without full task input", () => {
const task: GenerationTask = { id: "task-summary", type: "canvas_image", status: "running", prompt: "", attempts: 1, createdAt: "", updatedAt: "", clientContext: { nodeId: "node-summary" } };

View File

@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
import type { AxiosProgressEvent } from "axios";
import { apiClient } from "../src/services/api/request";
import { uploadResourceFile } from "../src/services/api/resources";
test("普通 multipart 将传输进度换算为文件字节,总量未知不伪造百分比", async () => {
const adapter = apiClient.defaults.adapter;
const progress: number[][] = [];
try {
apiClient.defaults.adapter = async (config) => {
config.onUploadProgress?.({ loaded: 100, total: undefined } as AxiosProgressEvent);
config.onUploadProgress?.({ loaded: 250, total: 1000 } as AxiosProgressEvent);
config.onUploadProgress?.({ loaded: 1000, total: 1000 } as AxiosProgressEvent);
return { config, status: 200, statusText: "OK", headers: {}, data: { code: 0, data: { resource: { id: "upload-test" } } } };
};
await uploadResourceFile(new Blob([new Uint8Array(400)]), "file", undefined, (loaded, total) => progress.push([loaded, total]));
expect(progress).toEqual([[100, 400], [400, 400]]);
} finally {
apiClient.defaults.adapter = adapter;
}
});
test("分片上传累加片内真实进度,传输 100% 后仍须等待合并响应", async () => {
const adapter = apiClient.defaults.adapter;
const size = 51 * 1024 * 1024;
const chunkSize = 32 * 1024 * 1024;
const progress: number[][] = [];
let merged = false;
try {
apiClient.defaults.adapter = async (config) => {
let data: unknown;
if (config.url === "/resources/uploads") data = { uploadId: "session", chunkSize, chunkCount: 2 };
else if (config.url?.endsWith("/complete")) {
expect(progress.at(-1)).toEqual([size, size]);
merged = true;
data = { resource: { id: "chunk-test" } };
} else {
const index = Number(config.url?.split("/").at(-1));
const bytes = index === 0 ? chunkSize : size - chunkSize;
config.onUploadProgress?.({ loaded: bytes / 2, total: bytes } as AxiosProgressEvent);
data = { index };
}
return { config, status: 200, statusText: "OK", headers: {}, data: { code: 0, data } };
};
await uploadResourceFile(new Blob([new Uint8Array(size)]), "video", undefined, (loaded, total) => progress.push([loaded, total]));
expect(progress).toEqual([[chunkSize / 2, size], [chunkSize, size], [chunkSize + (size - chunkSize) / 2, size], [size, size]]);
expect(merged).toBe(true);
} finally {
apiClient.defaults.adapter = adapter;
}
});
test("传输结束不吞掉服务端保存错误", async () => {
const adapter = apiClient.defaults.adapter;
try {
apiClient.defaults.adapter = async (config) => {
config.onUploadProgress?.({ loaded: 100, total: 100 } as AxiosProgressEvent);
return { config, status: 200, statusText: "OK", headers: {}, data: { code: 9, msg: "保存失败" } };
};
await expect(uploadResourceFile(new Blob(["text"]), "file", undefined, () => {})).rejects.toThrow("保存失败");
} finally {
apiClient.defaults.adapter = adapter;
}
});