fix(media): 视频播放 - 资源视频改走 playback 变体,失败后刷新签名并重试
画布预览、生成回填和项目水合都解析浏览器可播地址;播放失败时绕过展示缓存,等待转码完成后强制刷新 playback 地址。
This commit is contained in:
@@ -7,7 +7,7 @@ import { storeGeneratedAudio } from "@/services/api/audio";
|
||||
import { storeGeneratedVideo } from "@/services/api/video";
|
||||
import { parseBackendGenerationResult } from "@/services/api/generation-task";
|
||||
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
|
||||
import { resolveMediaUrl, type UploadedFile } from "@/services/file-storage";
|
||||
import { resolveMediaUrl, resolveVideoMediaUrl, type UploadedFile } from "@/services/file-storage";
|
||||
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
|
||||
import { getCachedResourceBlob } from "@/services/resource-blob-cache";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
@@ -71,14 +71,16 @@ export function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
|
||||
mimeType: video.mimeType || "video/mp4",
|
||||
durationMs: video.durationMs,
|
||||
hasAudio: video.hasAudio,
|
||||
videoPreview: video.preview ? {
|
||||
content: video.preview.url,
|
||||
storageKey: video.preview.storageKey,
|
||||
width: video.preview.width,
|
||||
height: video.preview.height,
|
||||
bytes: video.preview.bytes,
|
||||
mimeType: video.preview.mimeType,
|
||||
} : undefined,
|
||||
videoPreview: video.preview
|
||||
? {
|
||||
content: video.preview.url,
|
||||
storageKey: video.preview.storageKey,
|
||||
width: video.preview.width,
|
||||
height: video.preview.height,
|
||||
bytes: video.preview.bytes,
|
||||
mimeType: video.preview.mimeType,
|
||||
}
|
||||
: undefined,
|
||||
errorDetails: undefined,
|
||||
generationErrorCode: undefined,
|
||||
resourceReloadAvailable: undefined,
|
||||
@@ -113,14 +115,17 @@ function workflowMetadataForResultNode(): Partial<CanvasNodeMetadata> {
|
||||
// 原地重生会换 storageKey 但继承旧 assetId,形成「旧素材 + 新资源」配对,云端校验会永久拒绝。
|
||||
// 新媒体结果必须清掉旧绑定,交给入库/修复路径按新资源重绑。
|
||||
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);
|
||||
return commitProducedModel(
|
||||
{
|
||||
...node.metadata,
|
||||
...workflowMetadataForResultNode(),
|
||||
...media,
|
||||
...extra,
|
||||
errorDetails: undefined,
|
||||
assetId: undefined,
|
||||
},
|
||||
fallbackModel,
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[] = [node], outputIndex = 0): Promise<CanvasNodeData> {
|
||||
@@ -166,9 +171,7 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
|
||||
if (mode === "video") {
|
||||
if (!result.video?.storageKey && !result.video?.dataUrl) throw new Error("后端任务没有返回视频");
|
||||
const video = result.video.storageKey
|
||||
? await cacheGeneratedRemoteVideo({ ...result.video, storageKey: result.video.storageKey })
|
||||
: await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||
const video = result.video.storageKey ? await cacheGeneratedRemoteVideo({ ...result.video, storageKey: result.video.storageKey }) : await storeGeneratedVideo({ url: result.video.dataUrl, mimeType: result.video.mimeType || "video/mp4" });
|
||||
const videoSize = fitNodeSize(video.width || node.width || VIDEO_NODE_MAX_SIZE.width, video.height || node.height || VIDEO_NODE_MAX_SIZE.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
|
||||
const geometry = node.metadata?.locked
|
||||
? {}
|
||||
@@ -197,7 +200,18 @@ export async function buildGenerationTaskNodeResult(node: CanvasNodeData, task:
|
||||
return {
|
||||
...node,
|
||||
type: CanvasNodeType.Text,
|
||||
metadata: { ...node.metadata, content: result.text, richText: undefined, prompt, ...completedTaskMetadata(task), status: "success", errorDetails: undefined, generationErrorCode: undefined, resourceReloadAvailable: undefined, failedPromptFingerprint: undefined },
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
content: result.text,
|
||||
richText: undefined,
|
||||
prompt,
|
||||
...completedTaskMetadata(task),
|
||||
status: "success",
|
||||
errorDetails: undefined,
|
||||
generationErrorCode: undefined,
|
||||
resourceReloadAvailable: undefined,
|
||||
failedPromptFingerprint: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,7 +232,7 @@ type GeneratedVideoResult = {
|
||||
async function cacheGeneratedRemoteVideo(result: GeneratedVideoResult & { storageKey: string }): Promise<UploadedFile> {
|
||||
const blob = await getCachedResourceBlob(result.storageKey);
|
||||
if (!blob) throw new Error("生成视频资源缓存失败,未标记为成功");
|
||||
const url = await resolveMediaUrl(result.storageKey, result.dataUrl || "");
|
||||
const url = await resolveVideoMediaUrl(result.storageKey, result.dataUrl || "");
|
||||
if (!url) throw new Error("生成视频资源地址为空,未标记为成功");
|
||||
return {
|
||||
url,
|
||||
@@ -297,9 +311,15 @@ export function generationTaskOutputsApplied(node: CanvasNodeData, task: Generat
|
||||
}
|
||||
|
||||
export function shouldRecoverCanvasImageOutputs(node: CanvasNodeData) {
|
||||
return node.type === CanvasNodeType.Image && node.metadata?.status === "success" && Boolean(node.metadata.taskId)
|
||||
&& node.metadata.generationOutputCount === undefined && !node.metadata.isBatchRoot && !node.metadata.batchRootId
|
||||
&& /(?:midjourney|(?:^|::)mj-)/i.test(node.metadata.producedModel || node.metadata.model || "");
|
||||
return (
|
||||
node.type === CanvasNodeType.Image &&
|
||||
node.metadata?.status === "success" &&
|
||||
Boolean(node.metadata.taskId) &&
|
||||
node.metadata.generationOutputCount === undefined &&
|
||||
!node.metadata.isBatchRoot &&
|
||||
!node.metadata.batchRootId &&
|
||||
/(?:midjourney|(?:^|::)mj-)/i.test(node.metadata.producedModel || node.metadata.model || "")
|
||||
);
|
||||
}
|
||||
|
||||
async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: GenerationTask, nodes: CanvasNodeData[]) {
|
||||
@@ -310,7 +330,10 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
|
||||
for (let index = 0; index < images.length; index += 1) {
|
||||
const id = `${node.id}:task:${task.id}:image:${index}`;
|
||||
const existing = nodes.find((item) => item.id === id);
|
||||
if (existing) { children.push(existing); continue; }
|
||||
if (existing) {
|
||||
children.push(existing);
|
||||
continue;
|
||||
}
|
||||
const child = await buildGenerationTaskNodeResult({ ...node, id, title: `${node.title} · ${index + 1}`, parentId: undefined }, task, nodes, index);
|
||||
children.push({
|
||||
...child,
|
||||
@@ -318,8 +341,14 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
|
||||
metadata: {
|
||||
...child.metadata,
|
||||
assetId: task.outputs?.find((output) => output.outputIndex === index)?.materializedAssetId,
|
||||
isBatchRoot: undefined, batchChildIds: undefined, primaryImageId: undefined, imageBatchExpanded: undefined,
|
||||
batchRootId: node.id, versionOfNodeId: undefined, versionLabel: undefined, versionPrimary: undefined,
|
||||
isBatchRoot: undefined,
|
||||
batchChildIds: undefined,
|
||||
primaryImageId: undefined,
|
||||
imageBatchExpanded: undefined,
|
||||
batchRootId: node.id,
|
||||
versionOfNodeId: undefined,
|
||||
versionLabel: undefined,
|
||||
versionPrimary: undefined,
|
||||
agentGenerationContinuation: undefined,
|
||||
},
|
||||
});
|
||||
@@ -331,9 +360,12 @@ async function buildGenerationTaskNodeResults(node: CanvasNodeData, task: Genera
|
||||
metadata: {
|
||||
...resultNode.metadata,
|
||||
...imageMetadata({
|
||||
url: primary.metadata!.content!, storageKey: primary.metadata!.storageKey!,
|
||||
width: primary.metadata!.naturalWidth!, height: primary.metadata!.naturalHeight!,
|
||||
bytes: primary.metadata!.bytes || 0, mimeType: primary.metadata!.mimeType || "image/png",
|
||||
url: primary.metadata!.content!,
|
||||
storageKey: primary.metadata!.storageKey!,
|
||||
width: primary.metadata!.naturalWidth!,
|
||||
height: primary.metadata!.naturalHeight!,
|
||||
bytes: primary.metadata!.bytes || 0,
|
||||
mimeType: primary.metadata!.mimeType || "image/png",
|
||||
}),
|
||||
assetId: primary.metadata?.assetId,
|
||||
isBatchRoot: true,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type GenerationTask } from "@/services/api/task-center";
|
||||
import { backendProviderConfig, logicalModelIDForConfig, runBackendGenerationTask, type GenerationTaskDependencies } from "@/services/api/generation-task";
|
||||
import { configuredModelMatchesCapability, defaultConfig, normalizeModelOptionValue, normalizeRunningHubCapability, resolveModelRequestConfig, type AiConfig, type WorkflowFieldMapping } from "@/stores/use-config-store";
|
||||
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
import { resolveMediaUrl, resolveVideoMediaUrl } from "@/services/file-storage";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
@@ -359,10 +359,12 @@ export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
|
||||
return Promise.all(
|
||||
nodes.map(async (node) => {
|
||||
const content = node.metadata?.content;
|
||||
const videoPreview = node.type === CanvasNodeType.Video && node.metadata?.videoPreview?.storageKey
|
||||
? { ...node.metadata.videoPreview, content: await resolveImageUrl(node.metadata.videoPreview.storageKey, node.metadata.videoPreview.content, { cacheMiss: true }) }
|
||||
: node.metadata?.videoPreview;
|
||||
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content), videoPreview } };
|
||||
const videoPreview =
|
||||
node.type === CanvasNodeType.Video && node.metadata?.videoPreview?.storageKey
|
||||
? { ...node.metadata.videoPreview, content: await resolveImageUrl(node.metadata.videoPreview.storageKey, node.metadata.videoPreview.content, { cacheMiss: true }) }
|
||||
: node.metadata?.videoPreview;
|
||||
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey)
|
||||
return { ...node, metadata: { ...node.metadata, content: node.type === CanvasNodeType.Video ? await resolveVideoMediaUrl(node.metadata.storageKey, content) : await resolveMediaUrl(node.metadata.storageKey, content), videoPreview } };
|
||||
if (videoPreview !== node.metadata?.videoPreview) return { ...node, metadata: { ...node.metadata, videoPreview } };
|
||||
if (node.type === CanvasNodeType.Image && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content, { cacheMiss: true }) } };
|
||||
if (node.type !== CanvasNodeType.Image || !content) return node;
|
||||
@@ -423,12 +425,11 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
// 先合并节点上的实时选择,再做兼容性匹配。否则路由只看到全局默认值,节点改过的时长、分辨率或布尔能力无法参与分流。
|
||||
const workflowParameters = node?.metadata?.workflowParameters || {};
|
||||
const runningHubWorkflowId = node?.metadata?.runningHubWorkflowId?.trim() || config.runningHub.workflowId.trim();
|
||||
const selectedRunningHubWorkflow = workflowProvider === "runninghub"
|
||||
? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind))
|
||||
: undefined;
|
||||
const selectedWorkflowFields = workflowProvider === "runninghub"
|
||||
? selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)
|
||||
: [];
|
||||
const selectedRunningHubWorkflow =
|
||||
workflowProvider === "runninghub"
|
||||
? config.runningHub.workflows.find((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind))
|
||||
: undefined;
|
||||
const selectedWorkflowFields = workflowProvider === "runninghub" ? (selectedRunningHubWorkflow?.fields?.length ? selectedRunningHubWorkflow.fields : workflowVideoFieldsFromJson(selectedRunningHubWorkflow?.workflowJson)) : [];
|
||||
const workflowOutputSize = workflowProvider === "model" ? "" : workflowOutputSizeValue(selectedWorkflowFields, workflowParameters);
|
||||
const workflowParameterValue = (source: string) => {
|
||||
const value = workflowParameters[`source:${source}`];
|
||||
@@ -437,12 +438,16 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
const requestedConfig: AiConfig = {
|
||||
...config,
|
||||
quality: node?.metadata?.quality ?? workflowParameterValue("quality") ?? config.quality ?? defaultConfig.quality,
|
||||
size: workflowProvider === "model"
|
||||
? node?.metadata?.size ?? config.size ?? defaultConfig.size
|
||||
: workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
|
||||
size: workflowProvider === "model" ? (node?.metadata?.size ?? config.size ?? defaultConfig.size) : workflowOutputSize || node?.metadata?.size || config.size || defaultConfig.size,
|
||||
transparentBackground: node?.metadata?.transparentBackground ?? config.transparentBackground ?? defaultConfig.transparentBackground,
|
||||
videoSeconds: workflowProvider === "model" ? normalizeVideoDuration(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds) : String(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds),
|
||||
vquality: workflowProvider === "model" ? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality) : String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality),
|
||||
videoSeconds:
|
||||
workflowProvider === "model"
|
||||
? normalizeVideoDuration(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds)
|
||||
: String(node?.metadata?.seconds ?? workflowParameterValue("videoSeconds") ?? config.videoSeconds ?? defaultConfig.videoSeconds),
|
||||
vquality:
|
||||
workflowProvider === "model"
|
||||
? normalizeVideoResolution(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality)
|
||||
: String(node?.metadata?.vquality ?? workflowParameterValue("vquality") ?? config.vquality ?? defaultConfig.vquality),
|
||||
videoGenerateAudio: node?.metadata?.generateAudio ?? config.videoGenerateAudio ?? defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node?.metadata?.watermark ?? config.videoWatermark ?? defaultConfig.videoWatermark,
|
||||
videoArkPrivateAssetUpload: node?.metadata?.arkPrivateAssetUpload ?? config.videoArkPrivateAssetUpload ?? defaultConfig.videoArkPrivateAssetUpload,
|
||||
@@ -458,46 +463,57 @@ export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | u
|
||||
// 无 requirements 的调用(重试、媒体工具等)也按当前能力与尺寸路由到组内最低价兼容模型,
|
||||
// 避免旧 metadata.model 不支持当前尺寸导致生成时被 normalize 回退。
|
||||
const liveOptions = modelRequestOptions(requestedConfig, mode);
|
||||
const baseRequirements = requirements?.capability
|
||||
? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } }
|
||||
: { capability: mode, options: liveOptions };
|
||||
const model = workflowProvider === "model"
|
||||
? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel
|
||||
: preferredModel;
|
||||
const generationDefaults: Partial<ModelGenerationDefaults> = workflowProvider === "model"
|
||||
? resolveModelGenerationDefaults(
|
||||
config,
|
||||
model,
|
||||
mode === "image" ? "image" : mode === "video" ? "video" : undefined,
|
||||
mode === "image"
|
||||
? {
|
||||
size: node?.metadata?.size,
|
||||
quality: node?.metadata?.quality,
|
||||
transparentBackground: node?.metadata?.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
}
|
||||
: {
|
||||
size: node?.metadata?.size,
|
||||
videoSeconds: node?.metadata?.seconds,
|
||||
vquality: node?.metadata?.vquality,
|
||||
videoGenerateAudio: node?.metadata?.generateAudio,
|
||||
videoWatermark: node?.metadata?.watermark,
|
||||
},
|
||||
{
|
||||
size: requestedConfig.size,
|
||||
quality: requestedConfig.quality,
|
||||
transparentBackground: requestedConfig.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
videoSeconds: requestedConfig.videoSeconds,
|
||||
vquality: requestedConfig.vquality,
|
||||
videoGenerateAudio: requestedConfig.videoGenerateAudio,
|
||||
videoWatermark: requestedConfig.videoWatermark,
|
||||
},
|
||||
)
|
||||
: {};
|
||||
const baseRequirements = requirements?.capability ? { ...requirements, options: { ...liveOptions, ...(requirements.options || {}) } } : { capability: mode, options: liveOptions };
|
||||
const model = workflowProvider === "model" ? resolveCompatibleModel(config, preferredModel, imageSize ? { ...baseRequirements, imageSize } : baseRequirements) || preferredModel : preferredModel;
|
||||
const generationDefaults: Partial<ModelGenerationDefaults> =
|
||||
workflowProvider === "model"
|
||||
? resolveModelGenerationDefaults(
|
||||
config,
|
||||
model,
|
||||
mode === "image" ? "image" : mode === "video" ? "video" : undefined,
|
||||
mode === "image"
|
||||
? {
|
||||
size: node?.metadata?.size,
|
||||
quality: node?.metadata?.quality,
|
||||
transparentBackground: node?.metadata?.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
}
|
||||
: {
|
||||
size: node?.metadata?.size,
|
||||
videoSeconds: node?.metadata?.seconds,
|
||||
vquality: node?.metadata?.vquality,
|
||||
videoGenerateAudio: node?.metadata?.generateAudio,
|
||||
videoWatermark: node?.metadata?.watermark,
|
||||
},
|
||||
{
|
||||
size: requestedConfig.size,
|
||||
quality: requestedConfig.quality,
|
||||
transparentBackground: requestedConfig.transparentBackground,
|
||||
count: requestedConfig.count,
|
||||
videoSeconds: requestedConfig.videoSeconds,
|
||||
vquality: requestedConfig.vquality,
|
||||
videoGenerateAudio: requestedConfig.videoGenerateAudio,
|
||||
videoWatermark: requestedConfig.videoWatermark,
|
||||
},
|
||||
)
|
||||
: {};
|
||||
const modeCapability = mode === "video" || mode === "audio" ? mode : "image";
|
||||
const runningHubCapability = normalizeRunningHubCapability(selectedRunningHubWorkflow?.capability, normalizeRunningHubCapability(config.runningHub.capability));
|
||||
const runningHub = { ...config.runningHub, enabled: workflowProvider === "runninghub" && config.runningHub.enabled, selectedKind: selectedRunningHubWorkflow?.kind === "app" ? "app" as const : "workflow" as const, workflowId: runningHubWorkflowId, capability: runningHubCapability, workflows: workflowProvider === "runninghub" ? config.runningHub.workflows.map((item) => item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind) ? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[], workflowParameters) } : item) : config.runningHub.workflows };
|
||||
const runningHub = {
|
||||
...config.runningHub,
|
||||
enabled: workflowProvider === "runninghub" && config.runningHub.enabled,
|
||||
selectedKind: selectedRunningHubWorkflow?.kind === "app" ? ("app" as const) : ("workflow" as const),
|
||||
workflowId: runningHubWorkflowId,
|
||||
capability: runningHubCapability,
|
||||
workflows:
|
||||
workflowProvider === "runninghub"
|
||||
? config.runningHub.workflows.map((item) =>
|
||||
item.workflowId.trim() === runningHubWorkflowId && (!node?.metadata?.runningHubWorkflowKind || (item.kind === "app" ? "app" : "workflow") === node.metadata.runningHubWorkflowKind)
|
||||
? { ...item, fields: applyWorkflowParameterValues(item.fields?.length ? item.fields : (workflowVideoFieldsFromJson(item.workflowJson) as WorkflowFieldMapping[]), workflowParameters) }
|
||||
: item,
|
||||
)
|
||||
: config.runningHub.workflows,
|
||||
};
|
||||
return {
|
||||
...requestedConfig,
|
||||
taskWorkflowProvider: workflowProvider,
|
||||
|
||||
Reference in New Issue
Block a user