feat(canvas): 媒体产出模型与选用模型分离 (#597)

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
VicoHu
2026-09-24 09:28:16 +08:00
committed by ddcat666
parent a89bd4aead
commit 357450e52c
28 changed files with 367 additions and 52 deletions

View File

@@ -12,6 +12,7 @@ import { getCachedResourceBlob } from "@/services/resource-blob-cache";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useAssetStore } from "@/stores/use-asset-store";
import { applyGenerationConsumerEffect, generationEffectApplied } from "@/services/generation-consumer-dedupe";
import { commitProducedModel } from "@/lib/canvas/produced-model";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
export function generationTaskInput(task: GenerationTask) {
@@ -110,15 +111,15 @@ function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
// 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。
// 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。
export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial<CanvasNodeMetadata> = {}): CanvasNodeMetadata {
return {
export function applyGeneratedMediaResultMetadata(node: CanvasNodeData, media: CanvasNodeMetadata, extra: Partial<CanvasNodeMetadata> = {}, fallbackModel?: string): CanvasNodeMetadata {
return commitProducedModel({
...node.metadata,
...workflowMetadataForResultNode(),
...media,
...extra,
errorDetails: undefined,
assetId: undefined,
};
}, fallbackModel);
}
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node]): Promise<CanvasNodeData> {
@@ -158,7 +159,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
width: imageSize.width,
height: imageSize.height,
position: { x: node.position.x + node.width / 2 - imageSize.width / 2, y: node.position.y + node.height / 2 - imageSize.height / 2 },
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task) }),
metadata: applyGeneratedMediaResultMetadata(node, imageMetadata(normalizedImage), { prompt, ...completedTaskMetadata(task) }, task.model),
};
}
@@ -179,7 +180,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
...node,
type: CanvasNodeType.Video,
...geometry,
metadata: applyGeneratedMediaResultMetadata(node, videoMetadata(video), { prompt, ...completedTaskMetadata(task) }),
metadata: applyGeneratedMediaResultMetadata(node, videoMetadata(video), { prompt, ...completedTaskMetadata(task) }, task.model),
};
}
@@ -188,7 +189,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
const audio = result.audio.storageKey
? { url: await resolveMediaUrl(result.audio.storageKey, result.audio.dataUrl), storageKey: result.audio.storageKey, durationMs: result.audio.durationMs, bytes: result.audio.bytes || 0, mimeType: result.audio.mimeType || "audio/mpeg" }
: await storeGeneratedAudio(await (await fetch(result.audio.dataUrl)).blob(), result.audio.format || "mp3");
return { ...node, type: CanvasNodeType.Audio, metadata: applyGeneratedMediaResultMetadata(node, audioMetadata(audio), { prompt, ...completedTaskMetadata(task) }) };
return { ...node, type: CanvasNodeType.Audio, metadata: applyGeneratedMediaResultMetadata(node, audioMetadata(audio), { prompt, ...completedTaskMetadata(task) }, task.model) };
}
if (!result.text) throw new Error("后端任务没有返回文本");

View File

@@ -171,12 +171,15 @@ export function reconcileImageBatchRoot(root: CanvasNodeData, nodes: CanvasNodeD
metadata.naturalWidth = primary.metadata?.naturalWidth;
metadata.naturalHeight = primary.metadata?.naturalHeight;
metadata.primaryImageId = primary.id;
if (primary.metadata?.producedModel) metadata.producedModel = primary.metadata.producedModel;
else delete metadata.producedModel;
metadata.status = "success";
delete metadata.errorDetails;
delete metadata.generationErrorCode;
delete metadata.failedPromptFingerprint;
} else {
delete metadata.content;
delete metadata.producedModel;
delete metadata.storageKey;
delete metadata.assetId;
delete metadata.mimeType;

View File

@@ -1,4 +1,6 @@
import { getNodeListLabel } from "@/lib/canvas/node-registry";
import { producedModelLabel, producedModelSearchTerms } from "@/lib/canvas/produced-model";
import type { AiConfig } from "@/stores/use-config-store";
import { canvasNodeCreatedAt, canvasNodeUpdatedAt } from "@/lib/canvas/canvas-node-timestamps";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
@@ -10,10 +12,10 @@ const searchTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
hour12: false,
});
export function searchCanvasNodes(nodes: CanvasNodeData[], query: string, limit = 80) {
export function searchCanvasNodes(nodes: CanvasNodeData[], query: string, limit = 80, config?: AiConfig) {
const keyword = query.trim().toLocaleLowerCase();
return nodes
.filter((node) => !keyword || canvasNodeSearchTerms(node).some((value) => value.toLocaleLowerCase().includes(keyword)))
.filter((node) => !keyword || canvasNodeSearchTerms(node, config).some((value) => value.toLocaleLowerCase().includes(keyword)))
.toSorted((left, right) => timestampValue(canvasNodeUpdatedAt(right)) - timestampValue(canvasNodeUpdatedAt(left)))
.slice(0, keyword ? limit : Math.min(limit, 40));
}
@@ -26,14 +28,15 @@ export function canvasNodeSearchContext(node: CanvasNodeData) {
return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || textContent || getNodeListLabel(node.type);
}
export function canvasNodeMaterialSummary(node: CanvasNodeData) {
export function canvasNodeMaterialSummary(node: CanvasNodeData, config?: AiConfig) {
const details = [getNodeListLabel(node.type)];
const width = node.metadata?.naturalWidth;
const height = node.metadata?.naturalHeight;
if (width && height) details.push(`${width}×${height}`);
if (node.metadata?.durationMs) details.push(formatDuration(node.metadata.durationMs));
if (node.metadata?.bytes) details.push(formatBytes(node.metadata.bytes));
if (node.metadata?.model) details.push(node.metadata.model);
const producedModel = node.metadata?.producedModel;
if (producedModel) details.splice(1, 0, config ? producedModelLabel(config, producedModel) : producedModel);
return details.slice(0, 3).join(" · ");
}
@@ -48,7 +51,7 @@ export function canvasNodeSearchTimes(node: CanvasNodeData) {
};
}
function canvasNodeSearchTerms(node: CanvasNodeData) {
function canvasNodeSearchTerms(node: CanvasNodeData, config?: AiConfig) {
return [
node.title,
node.type,
@@ -56,6 +59,7 @@ function canvasNodeSearchTerms(node: CanvasNodeData) {
node.metadata?.prompt,
node.metadata?.composerContent,
node.metadata?.model,
...producedModelSearchTerms(node.metadata, config),
node.metadata?.chapterTitle,
node.metadata?.workflowTitle,
node.metadata?.workflowDescription,

View File

@@ -10,6 +10,7 @@ import { isSeedanceVideoConfig } from "@/lib/seedance-video";
import { modelCapabilityConfigFor, workflowFieldCurrentValue, workflowFieldHasStoredValue, workflowFieldKey, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowOutputSizeValue, workflowVideoFieldsFromJson } from "@/lib/model-capabilities";
import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, resolveVideoOperation, type ModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
import { imageMetadata } from "@/lib/canvas/canvas-generation-task-sync";
import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model";
import { ensureMediaNodeMinimumSize } from "@/lib/canvas/canvas-node-size";
import { interruptFileUpload } from "@/lib/canvas/canvas-file-upload";
import { isCanvasWorkflowProvider, resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
@@ -199,6 +200,7 @@ export function buildImageGenerationMetadata(type: CanvasImageGenerationType, co
...generationWorkflowMetadata(config),
generationType: type,
model: config.model,
producedModelCandidate: producedModelCandidateForGeneration(config),
size: config.size,
quality: config.quality,
transparentBackground: config.transparentBackground,
@@ -235,6 +237,7 @@ export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetada
return {
...generationWorkflowMetadata(config),
model: config.model,
producedModelCandidate: producedModelCandidateForGeneration(config),
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,

View File

@@ -0,0 +1,49 @@
import { decodeChannelModel, modelOptionName, type AiConfig } from "@/stores/use-config-store";
import type { CanvasNodeMetadata } from "@/types/canvas";
/** 生成提交时记下的模型身份。RunningHub 任务提交的就是「RunningHub · 工作流名」。 */
export function submittedProducedModel(model: string | undefined): string | undefined {
const value = model?.trim();
return value || undefined;
}
export function producedModelCandidateForGeneration(config: { model?: string; taskWorkflowProvider?: string }): string | undefined {
if (config.taskWorkflowProvider && config.taskWorkflowProvider !== "model") return undefined;
return submittedProducedModel(config.model);
}
/**
* 成功写入新媒体时冻结产出模型。优先用本次提交记下的候选,避免下拉框里的选用模型覆盖它。
* 没有可用模型时清掉旧值,避免新内容继续显示上一次的模型。
*/
export function commitProducedModel(metadata: CanvasNodeMetadata, fallbackModel?: string): CanvasNodeMetadata {
const producedModel = submittedProducedModel(metadata.producedModelCandidate) || submittedProducedModel(fallbackModel);
const { producedModelCandidate: _candidate, producedModel: _previous, ...rest } = metadata;
return producedModel ? { ...rest, producedModel } : rest;
}
/** 按当前模型目录解析角标。有展示名用展示名,否则用模型 ID,不把系统渠道收成「系统模型」。 */
export function producedModelLabel(config: Pick<AiConfig, "channels">, stored: string | undefined): string {
const value = stored?.trim();
if (!value) return "";
const decoded = decodeChannelModel(value);
const modelId = decoded?.model || value;
if (decoded) {
const channel = config.channels.find((item) => item.id === decoded.channelId);
if (!channel) return modelId;
return channel.modelCosts?.find((item) => item.model === modelId)?.displayName?.trim() || modelId;
}
for (const item of config.channels) {
const named = item.modelCosts?.find((entry) => entry.model === modelId || entry.logicalModelId === modelId)?.displayName?.trim();
if (named) return named;
}
return modelId;
}
export function producedModelSearchTerms(metadata: CanvasNodeMetadata | undefined, config?: AiConfig): string[] {
const stored = metadata?.producedModel?.trim();
if (!stored) return [];
const modelId = modelOptionName(stored);
const label = config ? producedModelLabel(config, stored) : modelId;
return [stored, modelId, label].filter((value, index, all) => Boolean(value) && all.indexOf(value) === index);
}