291724a984
同步主分支的角色动作序列、精选展示与外部编辑器契约更新 保留画布生成输入 V2 契约并合并视频、音效时长元数据 修复精选视频对数值型 V2 时长元数据的兼容
1041 lines
28 KiB
TypeScript
1041 lines
28 KiB
TypeScript
import type {
|
||
EditorAssetGenerationInputReference,
|
||
EditorAssetSnapshot,
|
||
EditorImageSequenceFrameResult,
|
||
EditorProjectResourceSnapshot,
|
||
EditorShowcaseCampaignSnapshot,
|
||
} from '../../services/image-editor/editorProjectClient';
|
||
import {
|
||
calculateCharacterAnimationPrice,
|
||
calculateEditorBackgroundMusicPrice,
|
||
calculateEditorImageGenerationPrice,
|
||
calculateEditorSoundEffectPrice,
|
||
calculateEditorVideoPrice,
|
||
CHARACTER_ANIMATION_MODEL,
|
||
DEFAULT_BACKGROUND_MUSIC_MODEL,
|
||
DEFAULT_SOUND_EFFECT_MODEL,
|
||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||
DEFAULT_VIDEO_MODEL,
|
||
} from '../image-editor/ImageCanvasGenerationModel';
|
||
|
||
export type ShowcaseTabId = 'all' | 'characters' | 'ui' | 'music' | 'marketing';
|
||
|
||
export type ShowcaseAssetMediaType =
|
||
'image' | 'video' | 'audio' | 'image-sequence';
|
||
|
||
export type ShowcaseAssetPreview = {
|
||
id: string;
|
||
label: string;
|
||
src: string;
|
||
coverSrc?: string | null;
|
||
objectKey?: string | null;
|
||
mediaType: ShowcaseAssetMediaType;
|
||
width?: number;
|
||
height?: number;
|
||
imageSequenceFrames?: EditorImageSequenceFrameResult[];
|
||
imageSequenceDurationMs?: number;
|
||
};
|
||
|
||
export type ShowcaseAssetItem = {
|
||
id: string;
|
||
label: string;
|
||
previews: ShowcaseAssetPreview[];
|
||
prompt: string;
|
||
author: string;
|
||
cost: string;
|
||
likeCount?: number | null;
|
||
showcaseId?: string | null;
|
||
campaign?: boolean;
|
||
};
|
||
|
||
type ShowcaseAssetGroup = {
|
||
id: string;
|
||
label?: string;
|
||
assets: EditorAssetSnapshot[];
|
||
};
|
||
|
||
const UNKNOWN_META_VALUE = '-';
|
||
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
|
||
|
||
const SHOWCASE_COST_KEYS = [
|
||
'priceMudPoints',
|
||
'costMudPoints',
|
||
'generationCostMudPoints',
|
||
'mudPointCost',
|
||
] as const;
|
||
|
||
const SHOWCASE_AUTHOR_KEYS = [
|
||
'authorDisplayName',
|
||
'authorPublicUserCode',
|
||
] as const;
|
||
|
||
const USER_PROMPT_INPUT_TITLES = new Set(
|
||
[
|
||
'生成提示词',
|
||
'视频描述',
|
||
'prompt',
|
||
'sound',
|
||
'gpt_description_prompt',
|
||
'音效提示词',
|
||
'背景音乐提示词',
|
||
'角色设定',
|
||
'用户输入',
|
||
'素材描述',
|
||
'自定义规范提示词',
|
||
'修改要求',
|
||
'快速编辑提示词',
|
||
'重绘提示词',
|
||
'动作描述',
|
||
].map((title) => title.toLowerCase()),
|
||
);
|
||
|
||
const STRUCTURED_USER_INPUT_TITLES = new Set(
|
||
[
|
||
'玩法设定',
|
||
'美术风格',
|
||
'头身比',
|
||
'角色视角',
|
||
'游戏名',
|
||
'游戏分类',
|
||
'一句话描述游戏',
|
||
].map((title) => title.toLowerCase()),
|
||
);
|
||
|
||
export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [
|
||
{ id: 'all', label: '全部' },
|
||
{ id: 'characters', label: '角色' },
|
||
{ id: 'ui', label: 'UI' },
|
||
{ id: 'music', label: '音乐' },
|
||
{ id: 'marketing', label: '美宣' },
|
||
];
|
||
|
||
const SHOWCASE_CATEGORY_IDS = new Set(
|
||
SHOWCASE_TABS.filter((tab) => tab.id !== 'all').map((tab) => tab.id),
|
||
);
|
||
|
||
function assetRecord(asset: EditorAssetSnapshot) {
|
||
return asset as Record<string, unknown>;
|
||
}
|
||
|
||
function projectResourceRecord(resource: EditorProjectResourceSnapshot) {
|
||
return resource as unknown as Record<string, unknown>;
|
||
}
|
||
|
||
function normalizeText(value: unknown) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||
}
|
||
|
||
function normalizeGenerationInputTitle(value: string) {
|
||
return value.trim().toLowerCase();
|
||
}
|
||
|
||
function isBuiltInPromptText(value: string) {
|
||
const trimmedValue = value.trim();
|
||
return (
|
||
trimmedValue.startsWith('【系统内置】') ||
|
||
trimmedValue.startsWith('【宣发素材类型】') ||
|
||
trimmedValue.startsWith('参考图1的图标素材规范') ||
|
||
trimmedValue.startsWith('生成玩法UI原型图') ||
|
||
trimmedValue.includes('仅提取被红色框框选的素材') ||
|
||
trimmedValue.includes('图集背景必须使用所选单一纯色抠图背景')
|
||
);
|
||
}
|
||
|
||
function normalizeNumber(value: unknown) {
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return value;
|
||
}
|
||
if (typeof value === 'string' && value.trim()) {
|
||
const numericValue = Number(value.trim());
|
||
return Number.isFinite(numericValue) ? numericValue : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function normalizeInt(
|
||
value: unknown,
|
||
fallback: number,
|
||
minValue = 1,
|
||
maxValue = Number.MAX_SAFE_INTEGER,
|
||
) {
|
||
const numericValue = normalizeNumber(value);
|
||
if (numericValue === null) {
|
||
return fallback;
|
||
}
|
||
return Math.min(maxValue, Math.max(minValue, Math.round(numericValue)));
|
||
}
|
||
|
||
function normalizeAssetKind(asset: EditorAssetSnapshot) {
|
||
return asset.assetKind?.trim() ?? '';
|
||
}
|
||
|
||
function getAssetHaystack(asset: EditorAssetSnapshot) {
|
||
return [
|
||
asset.label,
|
||
asset.prompt,
|
||
asset.actualPrompt,
|
||
asset.model,
|
||
asset.sourceType,
|
||
asset.assetKind,
|
||
...(asset.generationInputs?.fields ?? []).flatMap((field) => [
|
||
field.title,
|
||
field.value,
|
||
]),
|
||
...(asset.generationInputs?.references ?? []).flatMap((reference) => [
|
||
reference.title,
|
||
reference.label,
|
||
reference.refType,
|
||
reference.refId,
|
||
]),
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
.toLowerCase();
|
||
}
|
||
|
||
function getAssetReferences(asset: EditorAssetSnapshot) {
|
||
return asset.generationInputs?.references ?? [];
|
||
}
|
||
|
||
function getReferenceKey(reference: EditorAssetGenerationInputReference) {
|
||
return `${reference.refType}:${reference.refId}`;
|
||
}
|
||
|
||
function getSelfAssetKey(asset: EditorAssetSnapshot) {
|
||
return `asset:${asset.assetId}`;
|
||
}
|
||
|
||
function getAssetReferenceKeys(asset: EditorAssetSnapshot) {
|
||
const keys = [getSelfAssetKey(asset)];
|
||
if (asset.assetId?.trim()) {
|
||
keys.push(`project-resource:${asset.assetId.trim()}`);
|
||
}
|
||
if (asset.sourceResourceId?.trim()) {
|
||
keys.push(`project-resource:${asset.sourceResourceId.trim()}`);
|
||
}
|
||
if (asset.assetObjectId?.trim()) {
|
||
keys.push(`asset-object:${asset.assetObjectId.trim()}`);
|
||
}
|
||
if (asset.objectKey?.trim()) {
|
||
keys.push(`object:${asset.objectKey.trim()}`);
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
function projectResourceToShowcaseAsset(
|
||
resource: EditorProjectResourceSnapshot,
|
||
): EditorAssetSnapshot {
|
||
const record = projectResourceRecord(resource);
|
||
const fallbackLabel =
|
||
normalizeText(record.label) ||
|
||
normalizeText(record.title) ||
|
||
resource.assetKind?.trim() ||
|
||
'项目素材';
|
||
|
||
return {
|
||
...record,
|
||
assetId:
|
||
resource.assetId?.trim() ||
|
||
resource.showcaseId?.trim() ||
|
||
resource.resourceId,
|
||
folderId: `project:${resource.projectId}`,
|
||
label: fallbackLabel,
|
||
imageSrc: resource.imageSrc,
|
||
objectKey: resource.objectKey,
|
||
assetObjectId: resource.assetObjectId,
|
||
width: resource.width,
|
||
height: resource.height,
|
||
sourceType: resource.sourceType,
|
||
prompt: resource.prompt ?? null,
|
||
actualPrompt: resource.actualPrompt ?? null,
|
||
model: resource.model ?? null,
|
||
taskId: resource.taskId ?? null,
|
||
imageSequenceFrames: resource.imageSequenceFrames,
|
||
imageSequenceDurationMs: resource.imageSequenceDurationMs,
|
||
sourceResourceId: resource.sourceResourceId,
|
||
assetKind: resource.assetKind,
|
||
showcaseCategory: normalizeShowcaseCategory(
|
||
normalizeText(record.showcaseCategory),
|
||
),
|
||
generationInputs: resource.generationInputs,
|
||
createdAt: resource.createdAt,
|
||
updatedAt: resource.updatedAt,
|
||
} as EditorAssetSnapshot;
|
||
}
|
||
|
||
function normalizeShowcaseCategory(value: string): ShowcaseTabId | null {
|
||
return SHOWCASE_CATEGORY_IDS.has(value as Exclude<ShowcaseTabId, 'all'>)
|
||
? (value as ShowcaseTabId)
|
||
: null;
|
||
}
|
||
|
||
function resolveAssetShowcaseCategory(asset: EditorAssetSnapshot) {
|
||
return normalizeShowcaseCategory(
|
||
normalizeText(assetRecord(asset).showcaseCategory),
|
||
);
|
||
}
|
||
|
||
function normalizeShowcaseMediaRef(value: string | null | undefined) {
|
||
const trimmedValue = value?.trim().split(/[?#]/u)[0]?.trim() ?? '';
|
||
if (!trimmedValue) {
|
||
return '';
|
||
}
|
||
const pathValue = /^https?:\/\//iu.test(trimmedValue)
|
||
? (() => {
|
||
try {
|
||
return new URL(trimmedValue).pathname;
|
||
} catch {
|
||
return trimmedValue;
|
||
}
|
||
})()
|
||
: trimmedValue;
|
||
return pathValue.replace(/^\/+/u, '');
|
||
}
|
||
|
||
function getProjectResourceMediaKey(resource: EditorProjectResourceSnapshot) {
|
||
const assetObjectId = resource.assetObjectId?.trim();
|
||
if (assetObjectId) {
|
||
return `asset-object:${assetObjectId}`;
|
||
}
|
||
const objectKey = normalizeShowcaseMediaRef(resource.objectKey);
|
||
if (objectKey) {
|
||
return `object:${objectKey}`;
|
||
}
|
||
const imageSrc = normalizeShowcaseMediaRef(resource.imageSrc);
|
||
return imageSrc ? `src:${imageSrc}` : '';
|
||
}
|
||
|
||
function isCopiedProjectResource(
|
||
resource: EditorProjectResourceSnapshot,
|
||
resourceById: Map<string, EditorProjectResourceSnapshot>,
|
||
) {
|
||
const sourceResourceId = resource.sourceResourceId?.trim();
|
||
if (!sourceResourceId) {
|
||
return false;
|
||
}
|
||
const sourceResource = resourceById.get(sourceResourceId);
|
||
if (!sourceResource) {
|
||
return false;
|
||
}
|
||
return (
|
||
getProjectResourceMediaKey(sourceResource) ===
|
||
getProjectResourceMediaKey(resource)
|
||
);
|
||
}
|
||
|
||
function buildGeneratedProjectResourceAssets(
|
||
resources: EditorProjectResourceSnapshot[],
|
||
) {
|
||
const seenResourceIds = new Set<string>();
|
||
const resourceById = new Map(
|
||
resources
|
||
.filter((resource) => resource.resourceId.trim())
|
||
.map((resource) => [resource.resourceId, resource]),
|
||
);
|
||
|
||
return resources
|
||
.filter(
|
||
(resource) =>
|
||
resource.publicShowcaseEnabled !== false &&
|
||
resource.sourceType === 'generated' &&
|
||
resource.imageSrc.trim(),
|
||
)
|
||
.filter((resource) => {
|
||
if (seenResourceIds.has(resource.resourceId)) {
|
||
return false;
|
||
}
|
||
seenResourceIds.add(resource.resourceId);
|
||
return true;
|
||
})
|
||
.filter((resource) => !isCopiedProjectResource(resource, resourceById))
|
||
.map(projectResourceToShowcaseAsset);
|
||
}
|
||
|
||
function findReference(
|
||
asset: EditorAssetSnapshot,
|
||
matcher: (reference: EditorAssetGenerationInputReference) => boolean,
|
||
) {
|
||
return getAssetReferences(asset).find(matcher) ?? null;
|
||
}
|
||
|
||
function findSpecReference(asset: EditorAssetSnapshot) {
|
||
return findReference(asset, (reference) => /规范/u.test(reference.title));
|
||
}
|
||
|
||
function findCharacterReference(asset: EditorAssetSnapshot) {
|
||
return findReference(asset, (reference) =>
|
||
/角色图片|角色图/u.test(reference.title),
|
||
);
|
||
}
|
||
|
||
function findUiDesignReference(asset: EditorAssetSnapshot) {
|
||
return findReference(asset, (reference) =>
|
||
/UI设计图|UI 图|UI图片/u.test(reference.title),
|
||
);
|
||
}
|
||
|
||
function isAssetKind(asset: EditorAssetSnapshot, kinds: readonly string[]) {
|
||
return kinds.includes(normalizeAssetKind(asset));
|
||
}
|
||
|
||
function isAudioAsset(asset: EditorAssetSnapshot) {
|
||
const mediaType = normalizeText(assetRecord(asset).mediaType);
|
||
return (
|
||
mediaType === 'audio' ||
|
||
isAssetKind(asset, ['sound-effect', 'background-music']) ||
|
||
/\.(mp3|wav|m4a|aac|ogg)(?:$|\?)/iu.test(asset.imageSrc)
|
||
);
|
||
}
|
||
|
||
function isVideoAsset(asset: EditorAssetSnapshot) {
|
||
const mediaType = normalizeText(assetRecord(asset).mediaType);
|
||
return (
|
||
mediaType === 'video' ||
|
||
isAssetKind(asset, ['video']) ||
|
||
/\.(mp4|webm|mov|m4v)(?:$|\?)/iu.test(asset.imageSrc)
|
||
);
|
||
}
|
||
|
||
function resolveAssetMediaType(
|
||
asset: EditorAssetSnapshot,
|
||
): ShowcaseAssetMediaType {
|
||
if (isCharacterAnimationAsset(asset)) {
|
||
return 'image-sequence';
|
||
}
|
||
if (isAudioAsset(asset)) {
|
||
return 'audio';
|
||
}
|
||
if (isVideoAsset(asset)) {
|
||
return 'video';
|
||
}
|
||
return 'image';
|
||
}
|
||
|
||
function isSpecAsset(asset: EditorAssetSnapshot) {
|
||
return isAssetKind(asset, ['spec', 'icon-spec']);
|
||
}
|
||
|
||
function isCharacterAsset(asset: EditorAssetSnapshot) {
|
||
return isAssetKind(asset, ['character']);
|
||
}
|
||
|
||
function isCharacterAnimationAsset(asset: EditorAssetSnapshot) {
|
||
return isAssetKind(asset, ['character-animation']);
|
||
}
|
||
|
||
function isUiAsset(asset: EditorAssetSnapshot) {
|
||
return isAssetKind(asset, ['ui-design', 'icon', 'icon-spritesheet']);
|
||
}
|
||
|
||
function isMarketingAsset(asset: EditorAssetSnapshot) {
|
||
const haystack = getAssetHaystack(asset);
|
||
return (
|
||
isAssetKind(asset, ['publication-material']) ||
|
||
/美宣|宣发|宣传|poster|promo|marketing/u.test(haystack)
|
||
);
|
||
}
|
||
|
||
function addAssetToGroup(
|
||
groups: Map<string, ShowcaseAssetGroup>,
|
||
groupId: string,
|
||
asset: EditorAssetSnapshot,
|
||
label?: string,
|
||
) {
|
||
const group = groups.get(groupId);
|
||
if (group) {
|
||
if (label && !group.label) {
|
||
group.label = label;
|
||
}
|
||
group.assets.push(asset);
|
||
return;
|
||
}
|
||
|
||
groups.set(groupId, {
|
||
id: groupId,
|
||
label,
|
||
assets: [asset],
|
||
});
|
||
}
|
||
|
||
function mergeAssetAliasGroups(
|
||
groups: Map<string, ShowcaseAssetGroup>,
|
||
asset: EditorAssetSnapshot,
|
||
label?: string,
|
||
) {
|
||
const aliasGroups = getAssetReferenceKeys(asset)
|
||
.map((key) => groups.get(key))
|
||
.filter((group): group is ShowcaseAssetGroup => Boolean(group))
|
||
.filter(
|
||
(group, index, list) =>
|
||
list.findIndex((candidate) => candidate.id === group.id) === index,
|
||
);
|
||
|
||
const targetGroup = aliasGroups[0] ?? null;
|
||
if (!targetGroup) {
|
||
return null;
|
||
}
|
||
|
||
if (label && !targetGroup.label) {
|
||
targetGroup.label = label;
|
||
}
|
||
|
||
aliasGroups.slice(1).forEach((group) => {
|
||
if (!targetGroup.label && group.label) {
|
||
targetGroup.label = group.label;
|
||
}
|
||
group.assets.forEach((assetInGroup) => {
|
||
if (
|
||
!targetGroup.assets.some(
|
||
(existingAsset) => existingAsset.assetId === assetInGroup.assetId,
|
||
)
|
||
) {
|
||
targetGroup.assets.push(assetInGroup);
|
||
}
|
||
});
|
||
groups.delete(group.id);
|
||
});
|
||
|
||
return targetGroup;
|
||
}
|
||
|
||
function sortGroupAssets(assets: EditorAssetSnapshot[]) {
|
||
return [...assets].sort((left, right) => {
|
||
const leftRank =
|
||
isSpecAsset(left) ||
|
||
isCharacterAsset(left) ||
|
||
isAssetKind(left, ['ui-design'])
|
||
? 0
|
||
: 1;
|
||
const rightRank =
|
||
isSpecAsset(right) ||
|
||
isCharacterAsset(right) ||
|
||
isAssetKind(right, ['ui-design'])
|
||
? 0
|
||
: 1;
|
||
if (leftRank !== rightRank) {
|
||
return leftRank - rightRank;
|
||
}
|
||
return (left.createdAt ?? '').localeCompare(right.createdAt ?? '');
|
||
});
|
||
}
|
||
|
||
function groupCharacterAssets(assets: EditorAssetSnapshot[]) {
|
||
const groups = new Map<string, ShowcaseAssetGroup>();
|
||
assets
|
||
.filter(
|
||
(asset) =>
|
||
isCharacterAsset(asset) ||
|
||
isCharacterAnimationAsset(asset) ||
|
||
/角色|character/u.test(getAssetHaystack(asset)),
|
||
)
|
||
.forEach((asset) => {
|
||
if (isCharacterAsset(asset)) {
|
||
addAssetToGroup(groups, getSelfAssetKey(asset), asset, asset.label);
|
||
return;
|
||
}
|
||
const characterReference = findCharacterReference(asset);
|
||
if (characterReference) {
|
||
addAssetToGroup(
|
||
groups,
|
||
getReferenceKey(characterReference),
|
||
asset,
|
||
characterReference.label,
|
||
);
|
||
return;
|
||
}
|
||
addAssetToGroup(
|
||
groups,
|
||
`standalone:${asset.assetId}`,
|
||
asset,
|
||
asset.label,
|
||
);
|
||
});
|
||
const characterAssets = assets.filter(isCharacterAsset);
|
||
characterAssets.forEach((asset) => {
|
||
const group = mergeAssetAliasGroups(groups, asset, asset.label);
|
||
if (
|
||
group &&
|
||
!group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId)
|
||
) {
|
||
group.assets.unshift(asset);
|
||
}
|
||
});
|
||
return [...groups.values()].map((group) => ({
|
||
...group,
|
||
assets: sortGroupAssets(group.assets),
|
||
}));
|
||
}
|
||
|
||
function groupUiAssets(assets: EditorAssetSnapshot[]) {
|
||
const groups = new Map<string, ShowcaseAssetGroup>();
|
||
assets
|
||
.filter(
|
||
(asset) =>
|
||
isUiAsset(asset) ||
|
||
/\bui\b|界面|图标|icon/u.test(getAssetHaystack(asset)),
|
||
)
|
||
.forEach((asset) => {
|
||
if (isAssetKind(asset, ['ui-design'])) {
|
||
addAssetToGroup(groups, getSelfAssetKey(asset), asset, asset.label);
|
||
return;
|
||
}
|
||
const uiReference =
|
||
findUiDesignReference(asset) ?? findSpecReference(asset);
|
||
if (uiReference) {
|
||
addAssetToGroup(
|
||
groups,
|
||
getReferenceKey(uiReference),
|
||
asset,
|
||
uiReference.label,
|
||
);
|
||
return;
|
||
}
|
||
addAssetToGroup(
|
||
groups,
|
||
`standalone:${asset.assetId}`,
|
||
asset,
|
||
asset.label,
|
||
);
|
||
});
|
||
const uiDesignAssets = assets.filter((asset) =>
|
||
isAssetKind(asset, ['ui-design']),
|
||
);
|
||
uiDesignAssets.forEach((asset) => {
|
||
const group = mergeAssetAliasGroups(groups, asset, asset.label);
|
||
if (
|
||
group &&
|
||
!group.assets.some((groupAsset) => groupAsset.assetId === asset.assetId)
|
||
) {
|
||
group.assets.unshift(asset);
|
||
}
|
||
});
|
||
return [...groups.values()].map((group) => ({
|
||
...group,
|
||
assets: sortGroupAssets(group.assets),
|
||
}));
|
||
}
|
||
|
||
function groupSingleAssets(
|
||
assets: EditorAssetSnapshot[],
|
||
predicate: (asset: EditorAssetSnapshot) => boolean,
|
||
) {
|
||
return assets.filter(predicate).map((asset) => ({
|
||
id: `asset:${asset.assetId}`,
|
||
label: asset.label,
|
||
assets: [asset],
|
||
}));
|
||
}
|
||
|
||
function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) {
|
||
if (tab === 'all') {
|
||
return groupSingleAssets(assets, () => true);
|
||
}
|
||
if (assets.some((asset) => resolveAssetShowcaseCategory(asset))) {
|
||
return groupSingleAssets(
|
||
assets,
|
||
(asset) => resolveAssetShowcaseCategory(asset) === tab,
|
||
);
|
||
}
|
||
if (tab === 'characters') {
|
||
return groupCharacterAssets(assets);
|
||
}
|
||
if (tab === 'ui') {
|
||
return groupUiAssets(assets);
|
||
}
|
||
if (tab === 'music') {
|
||
return groupSingleAssets(assets, isAudioAsset);
|
||
}
|
||
if (tab === 'marketing') {
|
||
return groupSingleAssets(assets, isMarketingAsset);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
|
||
const fields = asset.generationInputs?.fields ?? [];
|
||
const promptField = fields.find((field) => {
|
||
const value = String(field.value).trim();
|
||
return (
|
||
value &&
|
||
USER_PROMPT_INPUT_TITLES.has(
|
||
normalizeGenerationInputTitle(field.title),
|
||
) &&
|
||
!isBuiltInPromptText(value)
|
||
);
|
||
});
|
||
const promptValue = promptField ? String(promptField.value).trim() : '';
|
||
if (promptValue) {
|
||
return promptValue;
|
||
}
|
||
|
||
const structuredLines = fields.flatMap((field) => {
|
||
if (
|
||
!STRUCTURED_USER_INPUT_TITLES.has(
|
||
normalizeGenerationInputTitle(field.title),
|
||
)
|
||
) {
|
||
return [];
|
||
}
|
||
const value = String(field.value).trim();
|
||
return value && !isBuiltInPromptText(value)
|
||
? [`${field.title.trim()}:${value}`]
|
||
: [];
|
||
});
|
||
return structuredLines.join('\n');
|
||
}
|
||
|
||
function extractUserPromptFromAssembledPrompt(value: string) {
|
||
const trimmedValue = value.trim();
|
||
const sectionMatch =
|
||
trimmedValue.match(/【用户输入】\s*([\s\S]*?)(?=\n+【|$)/u) ??
|
||
trimmedValue.match(/【游戏输入】\s*([\s\S]*?)(?=\n+【|$)/u);
|
||
const sectionValue = sectionMatch?.[1]?.trim();
|
||
if (sectionValue && !isBuiltInPromptText(sectionValue)) {
|
||
return sectionValue;
|
||
}
|
||
|
||
const roleSettingMatch = trimmedValue.match(
|
||
/(?:^|\n)角色设定[::]\s*([^\n]+)/u,
|
||
);
|
||
const roleSettingValue = roleSettingMatch?.[1]?.trim();
|
||
return roleSettingValue && !isBuiltInPromptText(roleSettingValue)
|
||
? roleSettingValue
|
||
: '';
|
||
}
|
||
|
||
function resolveAssetPrompt(asset: EditorAssetSnapshot) {
|
||
const userPrompt = resolveUserGenerationPrompt(asset);
|
||
if (userPrompt) {
|
||
return userPrompt;
|
||
}
|
||
|
||
for (const value of [asset.prompt, asset.actualPrompt]) {
|
||
const directPrompt = value?.trim() ?? '';
|
||
if (!directPrompt) {
|
||
continue;
|
||
}
|
||
|
||
const extractedPrompt = extractUserPromptFromAssembledPrompt(directPrompt);
|
||
if (extractedPrompt) {
|
||
return extractedPrompt;
|
||
}
|
||
if (!isBuiltInPromptText(directPrompt)) {
|
||
return directPrompt;
|
||
}
|
||
}
|
||
|
||
return UNKNOWN_META_VALUE;
|
||
}
|
||
|
||
function resolveGroupPrompt(assets: EditorAssetSnapshot[]) {
|
||
return (
|
||
assets
|
||
.map(resolveAssetPrompt)
|
||
.find((value) => value !== UNKNOWN_META_VALUE) ?? UNKNOWN_META_VALUE
|
||
);
|
||
}
|
||
|
||
function resolveAssetAuthor(
|
||
asset: EditorAssetSnapshot,
|
||
fallbackAuthorName?: string | null,
|
||
) {
|
||
const record = assetRecord(asset);
|
||
for (const key of SHOWCASE_AUTHOR_KEYS) {
|
||
const value = normalizeText(record[key]);
|
||
if (value) {
|
||
return value;
|
||
}
|
||
}
|
||
return fallbackAuthorName?.trim() || UNKNOWN_META_VALUE;
|
||
}
|
||
|
||
function resolveGroupAuthor(
|
||
assets: EditorAssetSnapshot[],
|
||
fallbackAuthorName?: string | null,
|
||
) {
|
||
return (
|
||
assets
|
||
.map((asset) => resolveAssetAuthor(asset, fallbackAuthorName))
|
||
.find((value) => value !== UNKNOWN_META_VALUE) ?? UNKNOWN_META_VALUE
|
||
);
|
||
}
|
||
|
||
function resolveAssetCost(asset: EditorAssetSnapshot) {
|
||
const record = assetRecord(asset);
|
||
for (const key of SHOWCASE_COST_KEYS) {
|
||
const value = normalizeNumber(record[key]);
|
||
if (value !== null && value >= 0) {
|
||
return value;
|
||
}
|
||
}
|
||
const inferredValue = inferAssetCost(asset);
|
||
if (inferredValue !== null && inferredValue >= 0) {
|
||
return inferredValue;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getAssetCostKey(asset: EditorAssetSnapshot) {
|
||
if (asset.taskId?.trim()) {
|
||
return `task:${asset.taskId.trim()}`;
|
||
}
|
||
if (asset.objectKey?.trim()) {
|
||
return `object:${asset.objectKey.trim()}`;
|
||
}
|
||
return `asset:${asset.assetId}`;
|
||
}
|
||
|
||
function inferAssetCost(asset: EditorAssetSnapshot) {
|
||
const kind = normalizeAssetKind(asset);
|
||
const model = asset.model;
|
||
if (kind === 'sound-effect') {
|
||
return calculateEditorSoundEffectPrice(model ?? DEFAULT_SOUND_EFFECT_MODEL);
|
||
}
|
||
if (kind === 'background-music') {
|
||
return calculateEditorBackgroundMusicPrice(
|
||
model ?? DEFAULT_BACKGROUND_MUSIC_MODEL,
|
||
);
|
||
}
|
||
if (kind === 'character-animation') {
|
||
return calculateCharacterAnimationPrice(
|
||
model ?? CHARACTER_ANIMATION_MODEL,
|
||
inferVideoResolution(asset, ['480p', '720p'], '480p') as '480p' | '720p',
|
||
normalizeInt(
|
||
asset.imageSequenceDurationMs
|
||
? asset.imageSequenceDurationMs / 1_000
|
||
: undefined,
|
||
4,
|
||
4,
|
||
6,
|
||
),
|
||
);
|
||
}
|
||
if (kind === 'video' || isVideoAsset(asset)) {
|
||
return calculateEditorVideoPrice(
|
||
model ?? DEFAULT_VIDEO_MODEL,
|
||
inferVideoResolution(asset, ['480p', '720p', '1080p'], '480p') as
|
||
'480p' | '720p' | '1080p',
|
||
normalizeInt(
|
||
inferMediaDurationSeconds(asset),
|
||
DEFAULT_VIDEO_DURATION_SECONDS,
|
||
4,
|
||
15,
|
||
),
|
||
);
|
||
}
|
||
if (asset.sourceType === 'generated' || kind) {
|
||
return calculateEditorImageGenerationPrice({ kind, model });
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function inferMediaDurationSeconds(asset: EditorAssetSnapshot) {
|
||
const durationField = asset.generationInputs?.fields.find((field) => {
|
||
const title = field.title.trim().toLowerCase();
|
||
return title === '时长' || title === 'duration';
|
||
});
|
||
const durationValue = durationField?.value;
|
||
if (typeof durationValue === 'number') {
|
||
return Number.isFinite(durationValue) ? durationValue : undefined;
|
||
}
|
||
if (typeof durationValue !== 'string') {
|
||
return undefined;
|
||
}
|
||
const matchedValue = durationValue.match(/\d+(?:\.\d+)?/u)?.[0];
|
||
return matchedValue ? Number(matchedValue) : undefined;
|
||
}
|
||
|
||
function inferVideoResolution(
|
||
asset: EditorAssetSnapshot,
|
||
allowedValues: string[],
|
||
fallback: string,
|
||
) {
|
||
const fields = asset.generationInputs?.fields ?? [];
|
||
const fieldValue = fields
|
||
.flatMap((field) => [field.title, field.value])
|
||
.map((value) => String(value).trim())
|
||
.find((value) => /\b(?:480p|720p|1080p)\b/iu.test(value));
|
||
const matchedValue = fieldValue?.match(/\b(480p|720p|1080p)\b/iu)?.[1];
|
||
if (matchedValue && allowedValues.includes(matchedValue)) {
|
||
return matchedValue;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function resolveGroupCost(assets: EditorAssetSnapshot[]) {
|
||
const resolvedCosts = new Map<string, number>();
|
||
assets.forEach((asset) => {
|
||
const cost = resolveAssetCost(asset);
|
||
if (cost !== null) {
|
||
resolvedCosts.set(getAssetCostKey(asset), cost);
|
||
}
|
||
});
|
||
const costs = [...resolvedCosts.values()];
|
||
if (!costs.length) {
|
||
return UNKNOWN_META_VALUE;
|
||
}
|
||
const totalCost = costs.reduce((sum, value) => sum + value, 0);
|
||
return `${totalCost}泥点`;
|
||
}
|
||
|
||
function normalizeShowcaseImageSequence(asset: EditorAssetSnapshot) {
|
||
const frames = asset.imageSequenceFrames;
|
||
const durationMs = asset.imageSequenceDurationMs;
|
||
if (
|
||
!Array.isArray(frames) ||
|
||
frames.length < 2 ||
|
||
typeof durationMs !== 'number' ||
|
||
!Number.isFinite(durationMs) ||
|
||
durationMs <= 0
|
||
) {
|
||
return null;
|
||
}
|
||
const normalizedFrames = frames.flatMap((frame) => {
|
||
const imageSrc = frame.imageSrc?.trim() ?? '';
|
||
if (
|
||
!imageSrc ||
|
||
!Number.isFinite(frame.width) ||
|
||
frame.width <= 0 ||
|
||
!Number.isFinite(frame.height) ||
|
||
frame.height <= 0
|
||
) {
|
||
return [];
|
||
}
|
||
return [
|
||
{
|
||
...frame,
|
||
imageSrc,
|
||
objectKey: frame.objectKey?.trim() || null,
|
||
assetObjectId: frame.assetObjectId?.trim() || null,
|
||
},
|
||
];
|
||
});
|
||
if (normalizedFrames.length !== frames.length) {
|
||
return null;
|
||
}
|
||
return { frames: normalizedFrames, durationMs };
|
||
}
|
||
|
||
function toAssetPreview(
|
||
asset: EditorAssetSnapshot,
|
||
): ShowcaseAssetPreview | null {
|
||
const mediaType = resolveAssetMediaType(asset);
|
||
if (mediaType === 'image-sequence') {
|
||
const sequence = normalizeShowcaseImageSequence(asset);
|
||
if (!sequence) {
|
||
return null;
|
||
}
|
||
const firstFrame = sequence.frames[0];
|
||
if (!firstFrame) {
|
||
return null;
|
||
}
|
||
return {
|
||
id: asset.assetId,
|
||
label: asset.label,
|
||
src: firstFrame.imageSrc,
|
||
coverSrc: null,
|
||
objectKey: firstFrame.objectKey,
|
||
mediaType,
|
||
width: firstFrame.width,
|
||
height: firstFrame.height,
|
||
imageSequenceFrames: sequence.frames,
|
||
imageSequenceDurationMs: sequence.durationMs,
|
||
};
|
||
}
|
||
return {
|
||
id: asset.assetId,
|
||
label: asset.label,
|
||
src: asset.imageSrc,
|
||
coverSrc: mediaType === 'audio' ? AUDIO_ASSET_COVER_SRC : null,
|
||
objectKey: asset.objectKey,
|
||
mediaType,
|
||
width: asset.width,
|
||
height: asset.height,
|
||
};
|
||
}
|
||
|
||
function toLibraryShowcaseItem(
|
||
group: ShowcaseAssetGroup,
|
||
fallbackAuthorName?: string | null,
|
||
): ShowcaseAssetItem | null {
|
||
const sortedAssets = sortGroupAssets(group.assets);
|
||
const previews = sortedAssets
|
||
.map(toAssetPreview)
|
||
.filter((preview): preview is ShowcaseAssetPreview =>
|
||
Boolean(preview?.src.trim()),
|
||
);
|
||
if (!previews.length) {
|
||
return null;
|
||
}
|
||
const primaryAsset = sortedAssets[0];
|
||
if (!primaryAsset) {
|
||
return null;
|
||
}
|
||
const label =
|
||
group.label?.trim() ||
|
||
primaryAsset?.label?.trim() ||
|
||
`${previews[0]?.label ?? '素材'}组合`;
|
||
return {
|
||
id: group.id,
|
||
label,
|
||
previews,
|
||
prompt: resolveGroupPrompt(sortedAssets),
|
||
author: resolveGroupAuthor(sortedAssets, fallbackAuthorName),
|
||
cost: resolveGroupCost(sortedAssets),
|
||
likeCount: normalizeNumber(assetRecord(primaryAsset).likeCount),
|
||
showcaseId: normalizeText(assetRecord(primaryAsset).showcaseId),
|
||
};
|
||
}
|
||
|
||
function campaignToShowcaseItem(
|
||
campaign?: EditorShowcaseCampaignSnapshot | null,
|
||
): ShowcaseAssetItem | null {
|
||
const objectKey = campaign?.imageObjectKey?.trim().replace(/^\/+/u, '') ?? '';
|
||
const imageSrc =
|
||
campaign?.imageSrc.trim() || (objectKey ? `/${objectKey}` : '');
|
||
if (!campaign?.enabled || !imageSrc) {
|
||
return null;
|
||
}
|
||
return {
|
||
id: 'campaign:global',
|
||
label: campaign.title.trim() || '陶泥儿精选',
|
||
previews: [
|
||
{
|
||
id: 'campaign:global:image',
|
||
label: campaign.title.trim() || '陶泥儿精选',
|
||
src: imageSrc,
|
||
objectKey: objectKey || null,
|
||
mediaType: 'image',
|
||
width: campaign.imageWidth ?? undefined,
|
||
height: campaign.imageHeight ?? undefined,
|
||
},
|
||
],
|
||
prompt: campaign.prompt.trim() || UNKNOWN_META_VALUE,
|
||
author: campaign.author.trim() || UNKNOWN_META_VALUE,
|
||
cost: campaign.costText.trim() || UNKNOWN_META_VALUE,
|
||
campaign: true,
|
||
};
|
||
}
|
||
|
||
export function buildCreationShowcaseItems({
|
||
activeTab,
|
||
projectResources,
|
||
campaign,
|
||
fallbackAuthorName,
|
||
}: {
|
||
activeTab: ShowcaseTabId;
|
||
projectResources: EditorProjectResourceSnapshot[];
|
||
campaign?: EditorShowcaseCampaignSnapshot | null;
|
||
fallbackAuthorName?: string | null;
|
||
}) {
|
||
const projectItems = getGroupsForTab(
|
||
buildGeneratedProjectResourceAssets(projectResources),
|
||
activeTab,
|
||
)
|
||
.map((group) => toLibraryShowcaseItem(group, fallbackAuthorName))
|
||
.filter((item): item is ShowcaseAssetItem => item !== null);
|
||
|
||
const campaignItem = campaignToShowcaseItem(campaign);
|
||
return campaignItem ? [campaignItem, ...projectItems] : projectItems;
|
||
}
|