Files
Genarrative/src/components/creation-home/creationShowcaseModel.ts
T
k88936 a03f13d0a5
Project CI / Repository checks (push) Successful in 1m28s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Backend tests (push) Successful in 4m3s
Project CI / Native shell tests (push) Successful in 13m58s
Fix/动作 前端展示下载+后端数据结构+精选 的问题 (#117)
原问题

```
历史角色动作是一个兼容例外:
后端允许它没有 editor_project_resource 资源行,只要布局自身保存了完整图片序列帧。
```

- 把生成动作的原视频asstetkind改为 video, 只把图片序列作为action
- db asset新增字段image_sequence_frames_json image_seq_duration_ms等, (原来动作的这些数据存在generation-input中,并不合适)
修改了externaljob,把这部分数据正确填写到数据库中。
- 为避免到处fallback,做了数据库迁移, 脚本: `scripts/spacetime-normalize-editor-character-actions.mjs`   手动进行过画布+素材库中动作序列帧+原始视频 迁移的测试

- 移除preview_video_path字段,  reason:
>preview video
    → separate resource/asset with assetKind="video"
  final transparent action
    → assetKind="character-animation"
    → source_resource_id points to the preview-video resource
    → image_sequence_frames_json contains the actual playable result
>
> So preview_video_path on the final action duplicates the source video resource’s image_src.
- 移除每个frame的index字段 原因: 这个只用于后端内部处理时有一个并发请求, 每个赋一个index方便收集, 后续没有再用到,且与数组本身重复

- 清除副产品preview video的generation_input_json,  因为会影响改造功能, 迁移后预览视频不提供改造(参数), 只有序列帧动作有改造

仍存在的共性问题: #134

- 导出:
下载改为完整序列帧, 封面不再作为fallback, 部分帧读取失败时行为:仍生成 ZIP,并记录失败帧, 不变

画布放置:
before:
![shotmd-1785207959-compressed.webp](/attachments/e56454f0-49a3-4a8a-951f-c21960975f99)
after:
![shotmd-1785231365-compressed.webp](/attachments/886f07df-c25b-4ecc-acc9-160e1795a991)
精选模块: 主页展示, 审核部分UI:
![shotmd-1785500046-compressed.webp](/attachments/9ec3b679-0045-494a-9f20-134f7e73fecf)
![shotmd-1785499780-compressed.webp](/attachments/42396e63-5f26-4853-a367-0f3c15111add)
这两处序列帧们的加载设计为惰式的, 只有hover和单独preview才会全部加载

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/117
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-06 16:57:38 +08:00

1038 lines
28 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 {
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 = field.value.trim();
return (
value &&
USER_PROMPT_INPUT_TITLES.has(
normalizeGenerationInputTitle(field.title),
) &&
!isBuiltInPromptText(value)
);
});
const promptValue = promptField?.value.trim();
if (promptValue) {
return promptValue;
}
const structuredLines = fields.flatMap((field) => {
if (
!STRUCTURED_USER_INPUT_TITLES.has(
normalizeGenerationInputTitle(field.title),
)
) {
return [];
}
const value = 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 matchedValue = durationField?.value.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) => 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;
}