Files
ttstd-infinite-canvas/web/src/services/project-asset-sync.ts
2026-09-14 01:11:56 +08:00

550 lines
24 KiB
TypeScript

import { canvasNodeToAsset, declaredCanvasNodeAssetCategory, findCanvasNodeAsset, type CanvasAssetSource } from "@/lib/canvas/canvas-node-asset";
import { canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import { readImageMeta } from "@/lib/image-utils";
import { parseBackendGenerationResult, type BackendGenerationResult } from "@/services/api/generation-task";
import { ApiError } from "@/services/api/request";
import { linkProjectAsset, moveProjectAsset, updateProjectAssetCategory } from "@/services/api/projects";
import type { GenerationTask, GenerationTaskOutput } from "@/services/api/task-center";
import { getMediaBlob, resolveMediaUrl, setMediaBlob } from "@/services/file-storage";
import { createGenerationTaskMaterializer, createIdempotentMaterializeOutput, type MaterializeGenerationTaskOutput } from "@/services/generation-task-materializer";
import { withGenerationArtifactCommitLock } from "@/services/generation-asset-repository";
import { uploadGeneratedAssetToConfiguredSources } from "@/services/external-asset-sources";
import { getImageBlob, resolveImageUrl, setImageBlob } from "@/services/image-storage";
import { generationArtifactStorageKey, loadOrStoreGenerationArtifact } from "@/services/generation-artifact-sink";
import { createProviderNeutralGenerationTaskEffectStore } from "@/services/provider-neutral-generation-effects";
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { getActiveUserScope } from "@/lib/user-scope";
import { normalizeAssetCategory } from "@/lib/asset-category";
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
import { useAssetStore, type AssetCategory, type AssetStatus, type NewAsset } from "@/stores/use-asset-store";
import type { CanvasNodeData } from "@/types/canvas";
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw new DOMException("The operation was aborted", "AbortError");
}
type EnsureCanvasNodeAssetOptions = {
canvasId: string;
domainProjectId?: string;
node: CanvasNodeData;
source: CanvasAssetSource;
taskId?: string;
category?: AssetCategory;
folderId?: string;
signal?: AbortSignal;
};
export type CanvasNodeAssetResult = {
assetId: string;
created: boolean;
linkedToProject: boolean;
};
const pendingAssetSyncs = new Map<string, Promise<CanvasNodeAssetResult>>();
const DEFAULT_RATE_LIMIT_RETRY_MS = 60_000;
const MAX_RATE_LIMIT_RETRY_MS = 5 * 60_000;
type CanvasAssetSyncRetryOptions = {
signal?: AbortSignal;
maxRetries?: number;
wait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
};
function waitForCanvasAssetSyncRetry(delayMs: number, signal?: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("The operation was aborted", "AbortError"));
return;
}
const timer = globalThis.setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, delayMs);
const onAbort = () => {
globalThis.clearTimeout(timer);
reject(new DOMException("The operation was aborted", "AbortError"));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
export async function retryCanvasAssetSyncAfterRateLimit<T>(operation: () => Promise<T>, options: CanvasAssetSyncRetryOptions = {}): Promise<T> {
const maxRetries = Math.max(0, options.maxRetries ?? 2);
const wait = options.wait ?? waitForCanvasAssetSyncRetry;
for (let attempt = 0; ; attempt += 1) {
throwIfAborted(options.signal);
try {
return await operation();
} catch (error) {
if (!(error instanceof ApiError) || error.status !== 429 || attempt >= maxRetries) throw error;
const delayMs = Math.min(MAX_RATE_LIMIT_RETRY_MS, Math.max(0, error.retryAfterMs ?? DEFAULT_RATE_LIMIT_RETRY_MS));
await wait(delayMs, options.signal);
}
}
}
export function ensureCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions) {
const scope = getActiveUserScope();
const identity = options.taskId || options.node.metadata?.taskId || options.node.metadata?.storageKey || options.node.id;
const key = [scope, options.domainProjectId || "personal", options.canvasId, options.node.id, identity].join(":");
const pending = pendingAssetSyncs.get(key);
if (pending) return pending;
const request = runGenerationConsumer(options.signal, (signal) => persistCanvasNodeAsset({ ...options, signal })).finally(() => pendingAssetSyncs.delete(key));
pendingAssetSyncs.set(key, request);
return request;
}
async function persistCanvasNodeAsset(options: EnsureCanvasNodeAssetOptions): Promise<CanvasNodeAssetResult> {
throwIfAborted(options.signal);
const store = useAssetStore.getState();
let asset = findCanvasNodeAsset(store.assets, options.node, options.canvasId, options.taskId);
const declaredCategory = options.category || declaredCanvasNodeAssetCategory(options.node);
let created = false;
if (!asset) {
const input = canvasNodeToAsset(options.node, { canvasId: options.canvasId, source: options.source, taskId: options.taskId });
if (!input) throw new Error("当前节点没有可保存的素材内容");
const assetId = store.addAsset(options.category ? { ...input, category: options.category } : input);
asset = useAssetStore.getState().assets.find((item) => item.id === assetId);
created = true;
}
if (!asset) throw new Error("素材写入本地失败");
if (declaredCategory && asset.category !== declaredCategory) {
store.updateAsset(asset.id, { category: declaredCategory });
asset = useAssetStore.getState().assets.find((item) => item.id === asset?.id) || asset;
}
if (!options.domainProjectId) {
// 个人画布也必须在返回成功前把素材提交到服务端,不能只依赖延迟自动同步。
await saveRemoteUserDataNow();
throwIfAborted(options.signal);
return { assetId: asset.id, created, linkedToProject: false };
}
await syncAssetToProject(asset.id, options.domainProjectId, declaredCategory, options.folderId, options.signal);
return { assetId: asset.id, created, linkedToProject: true };
}
async function syncAssetToProject(assetId: string, domainProjectId: string, category?: AssetCategory, folderId?: string, signal?: AbortSignal) {
throwIfAborted(signal);
const asset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId);
if (!asset) throw new Error("素材写入本地失败");
const linkedProjectIds = Array.isArray(asset.metadata?.projectIds) ? asset.metadata.projectIds.filter((id): id is string => typeof id === "string") : [];
if (linkedProjectIds.includes(domainProjectId)) {
if (folderId !== undefined) await moveProjectAsset(domainProjectId, asset.id, folderId, signal);
return;
}
// 项目关联依赖后端 assets 记录,先强制完成素材同步,不能依赖延迟自动同步的时序。
await saveRemoteUserDataNow();
throwIfAborted(signal);
const { asset: linkedAsset } = await linkProjectAsset(
domainProjectId,
{
assetId: asset.id,
category: normalizeAssetCategory(category || asset.category),
folderId,
},
signal,
);
throwIfAborted(signal);
let linked = category && linkedAsset.category !== category ? (await updateProjectAssetCategory(domainProjectId, asset.id, category, signal)).asset : linkedAsset;
if (folderId !== undefined && (linked.folderId || "") !== folderId) linked = (await moveProjectAsset(domainProjectId, asset.id, folderId, signal)).asset;
throwIfAborted(signal);
useAssetStore.getState().updateAsset(asset.id, {
category: normalizeAssetCategory(linked.category),
status: linked.status as AssetStatus,
primaryVersionId: linked.primaryVersionId,
metadata: { ...asset.metadata, projectIds: [...new Set([...linkedProjectIds, domainProjectId])] },
});
await saveRemoteUserDataNow();
throwIfAborted(signal);
}
function generationTaskResult(task: GenerationTask): BackendGenerationResult {
if (task.resultJson) return parseBackendGenerationResult(task);
if (!task.previewUrl) return {};
return task.previewKind === "video" ? { mode: "video", video: { dataUrl: task.previewUrl } } : { mode: "image", images: [{ dataUrl: task.previewUrl }] };
}
export function projectGenerationTaskResult(task: GenerationTask, result?: BackendGenerationResult): GenerationTask {
const projectedResult = result ?? generationTaskResult(task);
const outputs: GenerationTaskOutput[] = projectedResult.images?.length
? projectedResult.images.map((image, outputIndex) => ({
outputIndex,
mediaType: "image" as const,
...(image.storageKey ? { providerArtifactRef: image.storageKey } : {}),
}))
: projectedResult.video
? [
{
outputIndex: 0,
mediaType: "video" as const,
...(projectedResult.video.storageKey ? { providerArtifactRef: projectedResult.video.storageKey } : {}),
},
]
: projectedResult.audio
? [
{
outputIndex: 0,
mediaType: "audio" as const,
...(projectedResult.audio.storageKey ? { providerArtifactRef: projectedResult.audio.storageKey } : {}),
},
]
: (task.outputs?.map((output) => ({ ...output })) ?? []);
return {
...task,
...(result ? { resultJson: JSON.stringify(result) } : {}),
outputs,
...(task.status === "succeeded" && outputs.length && !outputs.every((output) => output.materializedAssetId) ? { resultState: "PENDING_MATERIALIZATION" as const } : {}),
};
}
async function storedGenerationImage(result: NonNullable<BackendGenerationResult["images"]>[number], effectKey: string, scope: string, signal?: AbortSignal) {
throwIfAborted(signal);
if (result.storageKey) {
const url = await resolveImageUrl(result.storageKey, result.dataUrl);
if (!url) throw new Error("图片结果资源不可用");
const meta = result.width && result.height ? undefined : await readImageMeta(url, signal);
throwIfAborted(signal);
return {
url,
storageKey: result.storageKey,
width: result.width || meta?.width || 1024,
height: result.height || meta?.height || 1024,
bytes: result.bytes || 0,
mimeType: result.mimeType || "image/png",
};
}
const storageKey = generationArtifactStorageKey(effectKey, "image", scope);
const blob = await loadOrStoreGenerationArtifact({
effectKey: storageKey,
read: (key) => getImageBlob(key),
materialize: async () => (await fetch(result.dataUrl, { signal })).blob(),
write: async (key, artifact) => {
await setImageBlob(key, artifact);
},
});
throwIfAborted(signal);
const url = await resolveImageUrl(storageKey);
throwIfAborted(signal);
if (!url) throw new Error("图片结果资源不可用");
const meta = result.width && result.height ? undefined : await readImageMeta(url, signal);
throwIfAborted(signal);
return {
url,
storageKey,
width: result.width || meta?.width || 1024,
height: result.height || meta?.height || 1024,
bytes: result.bytes || blob.size,
mimeType: result.mimeType || blob.type || "image/png",
};
}
async function storedGenerationMedia(dataUrl: string, effectKey: string, mediaType: "video" | "audio", metadata: { width?: number; height?: number; durationMs?: number; bytes?: number; mimeType: string }, scope: string, signal?: AbortSignal) {
throwIfAborted(signal);
const storageKey = generationArtifactStorageKey(effectKey, mediaType, scope);
const blob = await loadOrStoreGenerationArtifact({
effectKey: storageKey,
read: (key) => getMediaBlob(key),
materialize: async () => (await fetch(dataUrl, { signal })).blob(),
write: async (key, artifact) => {
await setMediaBlob(key, artifact);
},
});
throwIfAborted(signal);
const url = await resolveMediaUrl(storageKey);
throwIfAborted(signal);
if (!url) throw new Error(`${mediaType === "video" ? "视频" : "音频"}结果资源不可用`);
return {
url,
storageKey,
width: metadata.width,
height: metadata.height,
durationMs: metadata.durationMs,
bytes: metadata.bytes || blob.size,
mimeType: metadata.mimeType || blob.type,
};
}
async function generationOutputAsset(input: Parameters<MaterializeGenerationTaskOutput>[0], scope: string): Promise<NewAsset> {
throwIfAborted(input.signal);
const result = generationTaskResult(input.task);
const metadata = {
source: "generation-task",
generationEffectKey: input.effectKey,
taskId: input.task.id,
outputIndex: input.output.outputIndex,
conversationId: input.task.clientContext?.conversationId,
messageId: input.task.clientContext?.messageId,
batchIndex: input.task.clientContext?.batchIndex,
};
if (input.output.mediaType === "image") {
const image = result.images?.[input.output.outputIndex];
if (!image) throw new Error("生成任务缺少图片输出");
const stored = await storedGenerationImage(image, input.effectKey, scope, input.signal);
return {
kind: "image",
title: "生成图片",
coverUrl: stored.url,
tags: ["生成"],
status: "confirmed",
source: "生成任务",
metadata,
data: {
dataUrl: stored.url,
storageKey: stored.storageKey,
width: stored.width,
height: stored.height,
bytes: stored.bytes,
mimeType: stored.mimeType,
},
};
}
if (input.output.mediaType === "video") {
const video = result.video;
if (!video) throw new Error("生成任务缺少视频输出");
const stored = video.storageKey
? {
url: await resolveMediaUrl(video.storageKey, video.dataUrl),
storageKey: video.storageKey,
width: video.width || 0,
height: video.height || 0,
durationMs: video.durationMs,
bytes: video.bytes || 0,
mimeType: video.mimeType || "video/mp4",
}
: await storedGenerationMedia(
video.dataUrl,
input.effectKey,
"video",
{
width: video.width,
height: video.height,
durationMs: video.durationMs,
bytes: video.bytes,
mimeType: video.mimeType || "video/mp4",
},
scope,
input.signal,
);
if (!stored.url) throw new Error("视频结果资源不可用");
return {
kind: "video",
title: "生成视频",
coverUrl: canvasVideoAssetPreviewUrl(stored.url),
tags: ["生成"],
status: "confirmed",
source: "生成任务",
metadata,
data: {
url: stored.url,
storageKey: stored.storageKey,
width: stored.width || 0,
height: stored.height || 0,
durationMs: stored.durationMs,
bytes: stored.bytes,
mimeType: stored.mimeType || "video/mp4",
},
};
}
const audio = result.audio;
if (!audio) throw new Error("生成任务缺少音频输出");
const stored = audio.storageKey
? {
url: await resolveMediaUrl(audio.storageKey, audio.dataUrl),
storageKey: audio.storageKey,
durationMs: audio.durationMs,
bytes: audio.bytes || 0,
mimeType: audio.mimeType || "audio/mpeg",
}
: await storedGenerationMedia(
audio.dataUrl,
input.effectKey,
"audio",
{
durationMs: audio.durationMs,
bytes: audio.bytes,
mimeType: audio.mimeType || "audio/mpeg",
},
scope,
input.signal,
);
if (!stored.url) throw new Error("音频结果资源不可用");
return {
kind: "audio",
title: "生成音频",
coverUrl: "",
tags: ["生成"],
status: "confirmed",
source: "生成任务",
metadata,
data: {
url: stored.url,
storageKey: stored.storageKey,
durationMs: stored.durationMs,
bytes: stored.bytes,
mimeType: stored.mimeType || "audio/mpeg",
},
};
}
const materializeGenerationOutput: MaterializeGenerationTaskOutput = createIdempotentMaterializeOutput({
async insertOrReturnAsset(input) {
const scope = getActiveUserScope();
const assetId = await withGenerationArtifactCommitLock(
scope,
async () => {
throwIfAborted(input.signal);
const asset = await generationOutputAsset(input, scope);
throwIfAborted(input.signal);
const createdAssetId = await useAssetStore.getState().addGenerationAsset(input.effectKey, asset, input.signal);
throwIfAborted(input.signal);
return createdAssetId;
},
{ requireCrossRealmLock: true },
);
const storedAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId);
if (storedAsset) {
const syncResult = await uploadGeneratedAssetToConfiguredSources(storedAsset, input.signal);
throwIfAborted(input.signal);
if (syncResult.records.length) {
const currentAsset = useAssetStore.getState().assets.find((candidate) => candidate.id === assetId) || storedAsset;
useAssetStore.getState().updateAsset(assetId, {
metadata: { ...currentAsset.metadata, externalSync: syncResult.records },
});
}
}
return assetId;
},
});
function remoteGenerationTaskMaterializer() {
return createGenerationTaskMaterializer({
// 每个页面实例持有自己的租约;共享浏览器 storage + Web Lock 提供跨标签原子权威。
effects: createProviderNeutralGenerationTaskEffectStore(),
materializeOutput: materializeGenerationOutput,
});
}
function generationTaskMaterializer(task: GenerationTask) {
return remoteGenerationTaskMaterializer();
}
export async function materializeGenerationTaskAssets(task: GenerationTask, signal?: AbortSignal): Promise<GenerationTask> {
return generationTaskMaterializer(task).materialize(projectGenerationTaskResult(task), signal);
}
export function attachGenerationTaskNode(task: GenerationTask, nodeId: string, outputIndex: number, consumer: Parameters<ReturnType<typeof generationTaskMaterializer>["attachNode"]>[3], signal?: AbortSignal) {
return generationTaskMaterializer(task).attachNode(task, nodeId, outputIndex, consumer, signal);
}
export function attachGenerationTaskMessage(task: GenerationTask, messageId: string, outputIndex: number, consumer: Parameters<ReturnType<typeof generationTaskMaterializer>["attachMessage"]>[3], signal?: AbortSignal) {
return generationTaskMaterializer(task).attachMessage(task, messageId, outputIndex, consumer, signal);
}
export function resumeGenerationTaskAgent(task: GenerationTask, continuationId: string, consumer: Parameters<ReturnType<typeof generationTaskMaterializer>["resumeAgent"]>[2], signal?: AbortSignal) {
return generationTaskMaterializer(task).resumeAgent(task, continuationId, consumer, signal);
}
export async function consumeGenerationTaskNode(
task: GenerationTask,
nodeId: string,
outputIndex: number,
consumer: (input: { task: GenerationTask; output: GenerationTaskOutput; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
attachNode?: typeof attachGenerationTaskNode;
} = {},
): Promise<GenerationTask> {
if (!dependencies.managed) {
return runGenerationConsumer(dependencies.signal, (signal) => consumeGenerationTaskNode(task, nodeId, outputIndex, consumer, { ...dependencies, signal, managed: true }));
}
const materialized = await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal);
const output = materialized.outputs?.find((candidate) => candidate.outputIndex === outputIndex);
if (!output?.materializedAssetId) throw new Error("生成任务输出尚未物化");
await (dependencies.attachNode ?? attachGenerationTaskNode)(
materialized,
nodeId,
outputIndex,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, output, effectKey, signal });
},
dependencies.signal,
);
return materialized;
}
export async function consumeGenerationTaskAgent(
task: GenerationTask,
continuationId: string,
consumer: (input: { task: GenerationTask; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
resumeAgent?: typeof resumeGenerationTaskAgent;
} = {},
): Promise<GenerationTask> {
if (!dependencies.managed) {
return runGenerationConsumer(dependencies.signal, (signal) => consumeGenerationTaskAgent(task, continuationId, consumer, { ...dependencies, signal, managed: true }));
}
const materialized = task.outputs?.length ? await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal) : task;
await (dependencies.resumeAgent ?? resumeGenerationTaskAgent)(
materialized,
continuationId,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, effectKey, signal });
},
dependencies.signal,
);
return materialized;
}
export async function consumeGenerationTaskMessage(
task: GenerationTask,
messageId: string,
consumer: (input: { task: GenerationTask; resultUrls: string[]; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
materializedUrls?: typeof generationTaskMaterializedUrls;
attachMessage?: typeof attachGenerationTaskMessage;
} = {},
): Promise<GenerationTask> {
if (!dependencies.managed) {
return runGenerationConsumer(dependencies.signal, (signal) => consumeGenerationTaskMessage(task, messageId, consumer, { ...dependencies, signal, managed: true }));
}
const materialized = await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal);
const resultUrls = (dependencies.materializedUrls ?? generationTaskMaterializedUrls)(materialized);
const attach = dependencies.attachMessage ?? attachGenerationTaskMessage;
const outputs = materialized.outputs?.filter((output) => output.materializedAssetId) ?? [];
for (const output of outputs) {
await attach(
materialized,
messageId,
output.outputIndex,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, resultUrls, effectKey, signal });
},
dependencies.signal,
);
}
return materialized;
}
export function generationTaskMaterializedUrls(task: GenerationTask): string[] {
const assets = useAssetStore.getState().assets;
return (task.outputs || []).flatMap((output) => {
const asset = output.materializedAssetId ? assets.find((candidate) => candidate.id === output.materializedAssetId) : undefined;
if (!asset) return [];
if (asset.kind === "image") return [asset.data.dataUrl || asset.coverUrl];
if (asset.kind === "video" || asset.kind === "audio") return [asset.data.url];
return [];
});
}