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

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