1
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
本方案用于改造 `生成抓大鹅草稿` 的首版生成链路:点击按钮后先进入独立生成过程页,生成结束后自动进入抓大鹅结果页,并在结果页 `素材配置 > 物品` 预览本次生成的 2D 多视角物品素材。
|
||||
|
||||
草稿生成不再调用 Hyper3D Rodin,也不再生成 GLB 模型。物品素材继续沿用原来的“生成图片 -> 网格拆分 -> 上传 OSS -> 写回草稿”机制,但每个物品必须生成 `5` 个不同视角的 2D 视图。试玩和正式运行态的消除次数、总物品数和物品种类数以结果页 `难度配置` 保存的难度为准。难度对应物品种类固定为:轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21` 种。历史硬核草稿若仍保存 `clearCount = 20`,运行态按新硬核升为 `21` 次消除、`63` 件总物品。正式发布前如果已生成 `image_ready` 物品种类不足当前难度要求,必须阻断发布;试玩不阻断,但启动时把物品种类自动降到当前可用 2D 素材数量。
|
||||
草稿生成不再调用 Hyper3D Rodin,也不再生成 GLB 模型。物品素材继续沿用原来的“生成图片 -> 网格拆分 -> 上传 OSS -> 写回草稿”机制,但每个物品必须生成 `5` 个不同视角的 2D 视图。试玩和正式运行态的消除次数、总物品数和物品种类数以结果页 `难度配置` 保存的难度为准。难度对应物品种类固定为:轻松 `3` 种、标准 `9` 种、进阶 `15` 种、硬核 `21` 种。历史硬核草稿若仍保存 `clearCount = 20`,运行态按新硬核升为 `21` 次消除、`63` 件总物品。正式发布前如果已生成 `image_ready` 且具备至少 `5` 张有效 `imageViews[]` 的物品种类不足当前难度要求,必须阻断发布;试玩不阻断,但启动时把物品种类自动降到当前可用 2D 素材数量。
|
||||
|
||||
## 2. 前端流程
|
||||
|
||||
@@ -173,7 +173,7 @@ Match3DWorkProfile / PlatformMatch3DGalleryCard
|
||||
| 进阶 | 16 | 6 | 48 | 15 |
|
||||
| 硬核 | 21 | 8 | 63 | 21 |
|
||||
|
||||
预览区展示 `需要消除`、`总物品数`、`物品种类` 和 `已生成物品种类`。历史草稿如果保存的是旧 `clearCount/difficulty`,前端按 `clearCount` 精确命中优先、否则按 `difficulty` 就近归一到上述选项,并把归一后的数值保存回 profile。发布校验以 `generatedItemAssets[]` 中有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的 `image_ready` 素材数量为准;试玩启动时用同一数量计算 `itemTypeCountOverride`,不足时自动降低,不修改草稿难度配置本身。
|
||||
预览区展示 `需要消除`、`总物品数`、`物品种类` 和 `已生成物品种类`。历史草稿如果保存的是旧 `clearCount/difficulty`,前端按 `clearCount` 精确命中优先、否则按 `difficulty` 就近归一到上述选项,并把归一后的数值保存回 profile。发布校验以 `generatedItemAssets[]` 中 `image_ready` 且至少有 `5` 张有效 `imageViews[]` 的素材数量为准;试玩启动时用同一数量计算 `itemTypeCountOverride`,不足时自动降低,不修改草稿难度配置本身。历史单图 `imageSrc/imageObjectKey` 只作为运行态和预览兜底,不计入新发布素材完成数。
|
||||
|
||||
结果页 `素材配置` Tab 取代旧一级素材入口,并包含三个子 Tab:
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。
|
||||
2. 作品信息:展示并编辑作品名称、作品描述、作品标签。
|
||||
3. UI:展示并编辑拼图运行态 UI 背景提示词,支持基于作品名称、作品描述、标签和首关信息重新生成 9:16 UI 背景图。生成 action 为 `generate_puzzle_ui_background`,图片生成在 `api-server` 中读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 背景参考图,并调用 VectorEngine `gpt-image-2-all` 的 `9:16` 图片生成链路。生成结果写入首关 `levels_json` 的 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。
|
||||
3. UI:展示并编辑拼图运行态 UI 背景提示词。`compile_puzzle_draft` 草稿编译完成首图和背景音乐后,`api-server` 会基于作品名称、作品描述、标签和首关信息自动生成首关 9:16 UI 背景图;结果页继续支持用户修改提示词并通过 `generate_puzzle_ui_background` 重新生成。图片生成在 `api-server` 中读取 `public/ui-previews/puzzle-image-compact-ui-2026-05-08.png` 作为非拼图 UI 背景参考图,并调用 VectorEngine `gpt-image-2-all` 的 `9:16` 图片生成链路。生成结果写入首关 `levels_json` 的 `uiBackgroundPrompt`、`uiBackgroundImageSrc`、`uiBackgroundImageObjectKey`,不新增 SpacetimeDB 表字段。
|
||||
4. 音乐:编辑并生成背景音乐,音乐资产暂存到首关 `levels_json[0].backgroundMusic`。
|
||||
|
||||
### 2026-05-12 UI 背景生成补充
|
||||
@@ -105,8 +105,9 @@
|
||||
1. UI 背景图只生成拼图棋盘以外的运行态背景与 UI 容器层次,提示词必须要求中央正方形拼图区和外部 UI 背景之间有明确描边、容器或留白边界。
|
||||
2. UI 背景图不得生成文字、水印、按钮文字、数字、拼图碎片、完整拼图图像或教程浮层,避免与真实拼图图块和运行态 HUD 混淆。
|
||||
3. 结果页 UI Tab 支持直接修改提示词并重新生成;点击生成前会把本地首关 `uiBackgroundPrompt` 同步进 `levelsJson`,使自动保存尚未完成时后端仍能拿到最新提示词。
|
||||
4. `api-server` 负责读取参考图、拼接生成 prompt、调用 VectorEngine、下载并转存 OSS;SpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。
|
||||
5. 拼图运行态读取 `currentLevel.uiBackgroundImageSrc` 渲染为全屏背景;无 UI 背景图时继续使用原封面模糊背景兜底。棋盘本身仍由正式拼图图生成,不能把 UI 背景当作拼图切块来源。
|
||||
4. 草稿编译阶段自动生成 UI 背景失败时只记录 warning,并保留草稿进入结果页;用户可在 UI Tab 重新生成,不因背景图上游波动阻断首图草稿主流程。
|
||||
5. `api-server` 负责读取参考图、拼接生成 prompt、调用 VectorEngine、下载并转存 OSS;SpacetimeDB 只通过 `save_puzzle_ui_background` procedure 保存结果,不做外部 I/O。
|
||||
6. 拼图运行态读取 `currentLevel.uiBackgroundImageSrc` 渲染为全屏背景;无 UI 背景图时继续使用原封面模糊背景兜底。棋盘本身仍由正式拼图图生成,不能把 UI 背景当作拼图切块来源。
|
||||
|
||||
### 2026-05-12 草稿生成完成自动试玩补充
|
||||
|
||||
@@ -149,9 +150,9 @@
|
||||
## 验收
|
||||
|
||||
1. 从拼图创作入口只能看到作品名称、作品描述、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。
|
||||
2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。
|
||||
2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择、背景音乐生成和首关 UI 背景图生成。
|
||||
3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图;作品详情页展示玩家作品描述。
|
||||
4. 结果页包含“拼图关卡”“作品信息”“UI”“音乐”四个 Tab;关卡列表默认至少一关,支持新增、删除和进入关卡详情。
|
||||
5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。
|
||||
6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。
|
||||
7. UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc`,且拼图棋盘区域和 UI 背景区域有明确边界。
|
||||
7. 草稿初次生成后首关默认带 `uiBackgroundImageSrc`;UI Tab 可修改提示词并重新生成背景图;生成后运行态应显示 `uiBackgroundImageSrc`,且拼图棋盘区域和 UI 背景区域有明确边界。
|
||||
|
||||
@@ -3862,12 +3862,6 @@ fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) ->
|
||||
})
|
||||
.count();
|
||||
view_count >= MATCH3D_ITEM_VIEW_COUNT
|
||||
|| asset
|
||||
.image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn has_match3d_required_item_images(
|
||||
@@ -4952,7 +4946,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_required_item_images_require_object_keys() {
|
||||
fn match3d_required_item_images_require_five_views() {
|
||||
let assets = vec![
|
||||
Match3DGeneratedItemAsset {
|
||||
item_id: "match3d-item-1".to_string(),
|
||||
@@ -5024,6 +5018,47 @@ mod tests {
|
||||
];
|
||||
|
||||
assert!(!has_match3d_required_item_images(&assets, 3));
|
||||
|
||||
let five_view_assets = (1..=3)
|
||||
.map(|index| Match3DGeneratedItemAsset {
|
||||
item_id: format!("match3d-item-{index}"),
|
||||
item_name: format!("物品{index}"),
|
||||
image_src: Some(format!(
|
||||
"/generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
|
||||
)),
|
||||
image_object_key: Some(format!(
|
||||
"generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
|
||||
)),
|
||||
image_views: (1..=MATCH3D_ITEM_VIEW_COUNT)
|
||||
.map(|view_index| Match3DGeneratedItemImageView {
|
||||
view_id: format!("view-{view_index:02}"),
|
||||
view_index: view_index as u32,
|
||||
image_src: Some(format!(
|
||||
"/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
|
||||
)),
|
||||
image_object_key: Some(format!(
|
||||
"generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
model_src: None,
|
||||
model_object_key: None,
|
||||
model_file_name: None,
|
||||
task_uuid: None,
|
||||
subscription_key: None,
|
||||
sound_prompt: None,
|
||||
background_music_title: None,
|
||||
background_music_style: None,
|
||||
background_music_prompt: None,
|
||||
background_music: None,
|
||||
click_sound: None,
|
||||
background_asset: None,
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(has_match3d_required_item_images(&five_view_assets, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1338,7 +1338,7 @@ fn count_ready_generated_item_types(value: Option<&str>) -> Result<usize, String
|
||||
let status_ready = asset
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.map(|status| matches!(status, "image_ready" | "model_ready"))
|
||||
.map(|status| status == "image_ready")
|
||||
.unwrap_or(false);
|
||||
let view_count = asset
|
||||
.get("imageViews")
|
||||
@@ -1363,19 +1363,7 @@ fn count_ready_generated_item_types(value: Option<&str>) -> Result<usize, String
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let has_primary_image = asset
|
||||
.get("imageSrc")
|
||||
.or_else(|| asset.get("image_src"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
|| asset
|
||||
.get("imageObjectKey")
|
||||
.or_else(|| asset.get("image_object_key"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
status_ready && (view_count >= 5 || has_primary_image)
|
||||
status_ready && view_count >= 5
|
||||
})
|
||||
.count())
|
||||
}
|
||||
@@ -1893,6 +1881,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_publish_ready_requires_five_image_views_per_item() {
|
||||
let base_work = Match3DWorkProfileRow {
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: "session-1".to_string(),
|
||||
author_display_name: "作者".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary_text: "水果主题".to_string(),
|
||||
tags_json: "[\"水果\"]".to_string(),
|
||||
cover_image_src: "/cover.png".to_string(),
|
||||
cover_asset_id: String::new(),
|
||||
clear_count: 8,
|
||||
difficulty: 2,
|
||||
config_json: to_json_string(&Match3DCreatorConfigSnapshot {
|
||||
theme_text: "水果".to_string(),
|
||||
reference_image_src: None,
|
||||
clear_count: 8,
|
||||
difficulty: 2,
|
||||
asset_style_id: None,
|
||||
asset_style_label: None,
|
||||
asset_style_prompt: None,
|
||||
generate_click_sound: false,
|
||||
}),
|
||||
publication_status: MATCH3D_PUBLICATION_DRAFT.to_string(),
|
||||
play_count: 0,
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(1),
|
||||
published_at: None,
|
||||
generated_item_assets_json: Some(
|
||||
r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"},{"itemId":"match3d-item-2","itemName":"苹果","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"},{"imageSrc":"/v5.png"}],"status":"model_ready"},{"itemId":"match3d-item-3","itemName":"香蕉","imageViews":[{"imageSrc":"/v1.png"},{"imageSrc":"/v2.png"},{"imageSrc":"/v3.png"},{"imageSrc":"/v4.png"}],"status":"image_ready"}]"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let error = validate_publishable_work(&base_work).unwrap_err();
|
||||
assert!(error.contains("当前已有 0 种"));
|
||||
|
||||
let ready_assets = (1..=3)
|
||||
.map(|index| {
|
||||
let views = (1..=5)
|
||||
.map(|view_index| {
|
||||
format!(
|
||||
r#"{{"imageSrc":"/generated-match3d-assets/session/profile/items/i{index}/views/view-{view_index:02}.png"}}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
r#"{{"itemId":"match3d-item-{index}","itemName":"物品{index}","imageViews":[{views}],"status":"image_ready"}}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let ready_work = Match3DWorkProfileRow {
|
||||
generated_item_assets_json: Some(format!("[{ready_assets}]")),
|
||||
..base_work
|
||||
};
|
||||
|
||||
assert!(validate_publishable_work(&ready_work).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_compile_without_metadata_payload_preserves_existing_metadata() {
|
||||
let existing = Match3DWorkProfileRow {
|
||||
|
||||
@@ -349,6 +349,39 @@ describe('Match3DResultView', () => {
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('发布要求当前难度素材都具备五个视角', () => {
|
||||
const generatedItemAssets = [
|
||||
createReadyGeneratedItemAsset(1),
|
||||
createReadyGeneratedItemAsset(2),
|
||||
{
|
||||
...createReadyGeneratedItemAsset(3),
|
||||
imageViews: createReadyGeneratedItemAsset(3).imageViews?.slice(0, 4),
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
summary: '轻松消除水果',
|
||||
coverImageSrc: 'data:image/png;base64,cover',
|
||||
clearCount: 8,
|
||||
difficulty: 2,
|
||||
generatedItemAssets,
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const publishButton = screen.getByRole('button', { name: '发布' });
|
||||
expect(publishButton).toHaveProperty('disabled', true);
|
||||
fireEvent.click(publishButton);
|
||||
expect(match3dWorksService.publishMatch3DWork).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '难度配置' }));
|
||||
expect(screen.getByText('已生成物品种类')).toBeTruthy();
|
||||
expect(screen.getAllByText('2 种').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('发布前会先把当前 2D 多视角素材写回 profile', async () => {
|
||||
const generatedItemAssets = [
|
||||
createReadyGeneratedItemAsset(1),
|
||||
@@ -776,6 +809,45 @@ describe('Match3DResultView', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('物品详情五视角预览不混入兼容首图', () => {
|
||||
const generatedAsset = createReadyGeneratedItemAsset(1);
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={createProfile({
|
||||
clearCount: 3,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...generatedAsset,
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
|
||||
},
|
||||
],
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开物品1物品素材' }),
|
||||
);
|
||||
|
||||
const imageSources = [...document.querySelectorAll('img')].map((image) =>
|
||||
image.getAttribute('src') ?? '',
|
||||
);
|
||||
expect(
|
||||
imageSources.some((source) => source.includes('legacy-primary.png')),
|
||||
).toBe(false);
|
||||
expect(
|
||||
imageSources.some((source) => source.includes('views/view-05.png')),
|
||||
).toBe(true);
|
||||
expect(screen.getAllByText('5 视角').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('草稿阶段仅有切割图片时展示 2D 素材', () => {
|
||||
render(
|
||||
<Match3DResultView
|
||||
|
||||
@@ -229,7 +229,8 @@ function getMatch3DDifficultyOption(optionId: Match3DDifficultyOptionId) {
|
||||
function getMatch3DReadyItemTypeCount(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
return generatedItemAssets.filter(hasMatch3DGeneratedImageSource).length;
|
||||
return generatedItemAssets.filter(hasMatch3DGeneratedFiveViewImageSource)
|
||||
.length;
|
||||
}
|
||||
|
||||
function getMatch3DPlayableItemTypeCount(
|
||||
@@ -410,12 +411,44 @@ function hasMatch3DGeneratedImageSource(asset: Match3DGeneratedItemAsset) {
|
||||
return getMatch3DGeneratedImageViewSources(asset).length > 0;
|
||||
}
|
||||
|
||||
function hasMatch3DGeneratedFiveViewImageSource(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
return (
|
||||
(asset.imageViews ?? []).filter(
|
||||
(view) =>
|
||||
Boolean(view.imageSrc?.trim()) || Boolean(view.imageObjectKey?.trim()),
|
||||
).length >= 5
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMatch3DGeneratedImageViewSourceFromDraft(
|
||||
view: NonNullable<Match3DGeneratedItemAsset['imageViews']>[number],
|
||||
) {
|
||||
return view.imageObjectKey?.trim() || view.imageSrc?.trim() || '';
|
||||
}
|
||||
|
||||
function resolveMatch3DAssetDraftImageViewSources(
|
||||
asset: Match3DItemAssetDraft,
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
(asset.imageViews ?? [])
|
||||
.map(resolveMatch3DGeneratedImageViewSourceFromDraft)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function resolveMatch3DAssetDraftPreviewSources(asset: Match3DItemAssetDraft) {
|
||||
const imageViewSources = resolveMatch3DAssetDraftImageViewSources(asset);
|
||||
if (imageViewSources.length > 0) {
|
||||
return imageViewSources.slice(0, 5);
|
||||
}
|
||||
const fallbackSource = asset.referenceImageSrc.trim();
|
||||
return fallbackSource ? [fallbackSource] : [];
|
||||
}
|
||||
|
||||
function hasPersistableMatch3DGeneratedItemAsset(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
@@ -1703,7 +1736,7 @@ function Match3DItemAssetListCard({
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const pillClass = getMatch3DAssetStatusPillClass(asset.status);
|
||||
const imageViews = asset.imageViews ?? [];
|
||||
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1746,7 +1779,7 @@ function Match3DItemAssetListCard({
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{Math.max(imageViews.length, asset.referenceImageSrc ? 1 : 0)} 视角
|
||||
{previewSources.length} 视角
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
2D素材
|
||||
@@ -1783,33 +1816,25 @@ function Match3DItemAssetDetail({
|
||||
onChange: (asset: Match3DItemAssetDraft) => void;
|
||||
onGenerateClickSound: (asset: Match3DItemAssetDraft) => void;
|
||||
}) {
|
||||
const imageViews = asset.imageViews ?? [];
|
||||
const previewSources = resolveMatch3DAssetDraftPreviewSources(asset);
|
||||
|
||||
return (
|
||||
<section className="platform-subpanel min-h-0 rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(18rem,0.95fr)_minmax(14rem,0.62fr)]">
|
||||
<div className="grid aspect-square min-h-[18rem] grid-cols-2 gap-2 overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
|
||||
{[
|
||||
asset.referenceImageSrc,
|
||||
...imageViews
|
||||
.map(resolveMatch3DGeneratedImageViewSourceFromDraft)
|
||||
.filter(Boolean),
|
||||
]
|
||||
.filter((source, index, list) => source && list.indexOf(source) === index)
|
||||
.slice(0, 5)
|
||||
.map((source, index) => (
|
||||
<div
|
||||
key={`${source}-${index}`}
|
||||
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={source}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{previewSources.map((source, index) => (
|
||||
<div
|
||||
key={`${source}-${index}`}
|
||||
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.9rem] bg-white/80"
|
||||
>
|
||||
<ResolvedAssetImage
|
||||
src={source}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 space-y-3">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { setStoredAccessToken, clearStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
clearMatch3DGeneratedModelBytesCache,
|
||||
getMatch3DGeneratedImageViewSources,
|
||||
getMatch3DGeneratedModelAssetSources,
|
||||
preloadMatch3DGeneratedModelAssets,
|
||||
readMatch3DGeneratedModelBytes,
|
||||
@@ -113,4 +114,35 @@ describe('match3dGeneratedModelCache', () => {
|
||||
'generated-match3d-assets/session/profile/items/match3d-item-legacy/model.glb',
|
||||
]);
|
||||
});
|
||||
|
||||
test('多视角图片源优先使用 imageViews,兼容首图只做兜底', () => {
|
||||
const sources = getMatch3DGeneratedImageViewSources({
|
||||
itemId: 'match3d-item-1',
|
||||
itemName: '草莓',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/items/item-1/legacy-primary.png',
|
||||
imageViews: [1, 2, 3, 4, 5].map((viewIndex) => ({
|
||||
viewId: `view-${String(viewIndex).padStart(2, '0')}`,
|
||||
viewIndex,
|
||||
imageSrc: `/generated-match3d-assets/session/profile/items/item-1/views/view-${String(viewIndex).padStart(2, '0')}.png`,
|
||||
imageObjectKey: null,
|
||||
})),
|
||||
modelSrc: null,
|
||||
modelObjectKey: null,
|
||||
modelFileName: null,
|
||||
taskUuid: null,
|
||||
subscriptionKey: null,
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(sources).toHaveLength(5);
|
||||
expect(sources[0]).toContain('views/view-01.png');
|
||||
expect(sources[4]).toContain('views/view-05.png');
|
||||
expect(sources.some((source) => source.includes('legacy-primary'))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,14 +88,17 @@ export function resolveMatch3DGeneratedImageViewSource(
|
||||
export function getMatch3DGeneratedImageViewSources(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
const sources =
|
||||
const viewSources =
|
||||
asset.imageViews
|
||||
?.map(resolveMatch3DGeneratedImageViewSource)
|
||||
.filter((source) => source.length > 0) ?? [];
|
||||
if (viewSources.length > 0) {
|
||||
return [...new Set(viewSources)];
|
||||
}
|
||||
const primarySource =
|
||||
normalizeMatch3DModelSource(asset.imageObjectKey) ||
|
||||
normalizeMatch3DModelSource(asset.imageSrc);
|
||||
return [...new Set(primarySource ? [primarySource, ...sources] : sources)];
|
||||
return primarySource ? [primarySource] : [];
|
||||
}
|
||||
|
||||
export function resolveMatch3DGeneratedImageAssetSource(
|
||||
|
||||
Reference in New Issue
Block a user