feat(canvas): 媒体产出模型与选用模型分离 (#597)
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -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("后端任务没有返回文本");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
49
web/src/lib/canvas/produced-model.ts
Normal file
49
web/src/lib/canvas/produced-model.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user