Files
ttstd-infinite-canvas/web/src/pages/projects/detail/workflow-shot-references.ts
T

204 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } from "@/services/api/resources";
import type { CharacterRepresentation, ProjectAsset, ProjectDetail, ShotAssetReference } from "@/services/api/projects";
import type { AssetCategory } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio } from "@/types/media";
export type ShotAssetReferenceContext = {
mentionReferences: CanvasResourceReference[];
referenceImages: ReferenceImage[];
referenceAudios: ReferenceAudio[];
assetReferences: ShotPromptAssetReference[];
resolvedCharacterVersions: Array<{ assetId: string; versionId: string }>;
};
export type ShotPromptAssetReference = {
assetId: string;
title: string;
category: string;
imageIndex: number;
audioIndex?: number;
voiceDescription?: string;
dialogue?: string;
};
const assetCategories = new Set<AssetCategory>(["character", "environment", "wardrobe", "prop", "weapon", "style", "other"]);
export function buildShotAssetReferenceContext(detail: ProjectDetail, shotId: string): ShotAssetReferenceContext {
const assetByVersionId = new Map(detail.assets.filter((asset) => asset.primaryVersionId).map((asset) => [asset.primaryVersionId as string, asset]));
const seenAssetIds = new Set<string>();
const entries = (detail.shotReferences || []).flatMap((reference) => {
if (reference.shotId !== shotId || reference.status !== "linked") return [];
const asset = reference.asset || assetByVersionId.get(reference.assetVersionId);
if (!asset || seenAssetIds.has(asset.id)) return [];
const image = projectAssetReferenceImage(asset, reference);
if (!image) return [];
seenAssetIds.add(asset.id);
return [{ asset, image, reference }];
});
const referenceAudios: ReferenceAudio[] = [];
const audioIndexByResourceId = new Map<string, number>();
const assetReferences = entries.map(({ asset }, index) => {
const sampleResourceId = stringValue(asset.character?.voice?.profile.sampleResourceId);
let audioIndex: number | undefined;
if (sampleResourceId) {
audioIndex = audioIndexByResourceId.get(sampleResourceId);
if (!audioIndex) {
audioIndex = referenceAudios.length + 1;
audioIndexByResourceId.set(sampleResourceId, audioIndex);
referenceAudios.push({
id: `project-character-voice:${asset.id}`,
name: `${asset.title}-声音样本`,
type: "audio/*",
url: resourceFileUrl(sampleResourceId),
storageKey: resourceStorageKey(sampleResourceId),
});
}
}
return {
assetId: asset.id,
title: asset.title,
category: asset.category,
imageIndex: index + 1,
...(audioIndex ? { audioIndex } : {}),
...(asset.character ? { voiceDescription: characterVoiceDescription(asset) } : {}),
} satisfies ShotPromptAssetReference;
});
return {
mentionReferences: entries.map(({ asset, image }) => ({
id: `project-asset:${asset.id}`,
nodeId: "",
assetId: asset.id,
kind: asset.character ? "character" : "image",
label: asset.title,
title: asset.title,
previewUrl: image.url,
storageKey: image.storageKey,
active: true,
category: projectAssetCategory(asset.category),
})),
referenceImages: entries.map(({ image }) => image),
referenceAudios,
assetReferences,
resolvedCharacterVersions: entries.flatMap(({ asset, reference }) => asset.character ? [{ assetId: asset.id, versionId: reference.referencedVersion?.id || reference.assetVersionId || asset.character.versionId }] : []),
};
}
export function resolveShotAssetMentionPrompt(prompt: string, context: ShotAssetReferenceContext, options: { dialogue?: string } = {}) {
const imageLabelByAssetId = new Map(context.mentionReferences.map((reference, index) => [reference.assetId, `图片${index + 1}`]));
const unresolved = new Set<string>();
const resolved = prompt.replace(/@\[asset:([^\]]+)\]/g, (token, assetId: string) => {
const label = imageLabelByAssetId.get(assetId);
if (!label) {
unresolved.add(token);
return token;
}
return label;
});
if (unresolved.size) throw new Error(`提示词中的 ${Array.from(unresolved).join("、")} 未绑定到当前镜头,请重新选择资产或删除引用`);
const dialogueContext = withShotDialogue(context.assetReferences, options.dialogue);
const assetBlock = compileShotAssetReferencePrompt(dialogueContext.references);
const dialogueBlock = dialogueContext.unassignedDialogue ? `【镜头台词】\n\n${dialogueContext.unassignedDialogue}` : "";
return [resolved.trim(), assetBlock, dialogueBlock].filter(Boolean).join("\n\n");
}
export function compileShotAssetReferencePrompt(references: ShotPromptAssetReference[]) {
if (!references.length) return "";
const lines = references.flatMap((reference) => {
const visualLabel = reference.category === "character" ? "人物参考" : `${projectAssetCategoryLabel(reference.category)}参考`;
const media = [`${visualLabel}:图片${reference.imageIndex}`, reference.audioIndex ? `声音参考:音频${reference.audioIndex}` : ""].filter(Boolean).join(";");
return [
`- ${reference.title}:${media}`,
reference.voiceDescription ? ` 声音画像:${reference.voiceDescription}` : "",
reference.dialogue ? ` 镜头台词:${reference.dialogue}` : "",
].filter(Boolean);
});
return ["【资产参考】", "", ...lines].join("\n");
}
function projectAssetReferenceImage(asset: ProjectAsset, reference?: ShotAssetReference): ReferenceImage | undefined {
const boundRepresentations = reference?.referencedVersion?.representations || [];
const representation = preferredVisualRepresentation(boundRepresentations)
|| (asset.character ? preferredVisualRepresentation(asset.character.representations) : undefined);
if (representation) {
return {
id: asset.id,
name: asset.title,
type: "image/*",
dataUrl: "",
url: resourceFileUrl(representation.resourceId),
storageKey: resourceStorageKey(representation.resourceId),
};
}
if (asset.mediaType !== "image" || !asset.storageKey) return undefined;
const resourceId = resourceIdFromStorageKey(asset.storageKey);
return {
id: asset.id,
name: asset.title,
type: "image/*",
dataUrl: "",
...(resourceId ? { url: resourceFileUrl(resourceId) } : {}),
storageKey: asset.storageKey,
};
}
function preferredVisualRepresentation(representations: CharacterRepresentation[]) {
return representations.find((item) => item.role === "turnaround_sheet")
|| representations.find((item) => item.role === "primary")
|| representations.find((item) => item.role === "front")
|| representations.find((item) => item.mediaType.startsWith("image"));
}
function withShotDialogue(references: ShotPromptAssetReference[], dialogue?: string) {
const value = stringValue(dialogue);
if (!value) return { references, unassignedDialogue: "" };
const characters = references.filter((reference) => reference.category === "character");
if (characters.length === 1) {
return {
references: references.map((reference) => reference.assetId === characters[0]?.assetId ? { ...reference, dialogue: value } : reference),
unassignedDialogue: "",
};
}
const resolved = references.map((reference) => {
if (reference.category !== "character") return reference;
const matched = dialogueForCharacter(value, reference.title);
return matched ? { ...reference, dialogue: matched } : reference;
});
return {
references: resolved,
unassignedDialogue: resolved.some((reference) => reference.dialogue) ? "" : value,
};
}
function dialogueForCharacter(dialogue: string, characterName: string) {
const escaped = characterName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`(?:^|\\n)\\s*${escaped}\\s*[::]\\s*([^\\n]+)`, "g");
return Array.from(dialogue.matchAll(pattern), (match) => match[1]?.trim()).filter(Boolean).join(";");
}
function projectAssetCategory(value: string): AssetCategory {
return assetCategories.has(value as AssetCategory) ? value as AssetCategory : "other";
}
function projectAssetCategoryLabel(value: string) {
return ({ environment: "场景", wardrobe: "服装", prop: "道具", weapon: "武器", style: "风格", other: "资产" } as Record<string, string>)[value] || "资产";
}
function characterVoiceDescription(asset: ProjectAsset) {
const definition = asset.character?.definition || {};
const voice = asset.character?.voice;
return [
stringValue(definition.voiceLanguage) || stringValue(voice?.profile.language),
stringValue(definition.voiceAge),
stringValue(definition.voiceTimbre) || stringValue(voice?.profile.timbre),
stringValue(voice?.instructions),
].filter(Boolean).join(";");
}
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}