From 945b8133348ef9c34d78ff682a06d813c324a120 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 21 Jun 2026 19:56:27 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=A0=87=E7=AD=BE=E8=8F=9C=E5=8D=95=E4=BA=A4?= =?UTF-8?q?=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 素材标签菜单拦截滚轮事件,避免画布滚动抢占 点击菜单外空白区域自动关闭素材标签菜单 补充画布素材标签菜单交互测试 --- .../ImageCanvasWorldView.test.tsx | 28 ++++++++++++++ .../image-editor/ImageCanvasWorldView.tsx | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index a35816958..3f2739fea 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -823,6 +823,34 @@ describe('ImageCanvasWorldView', () => { ); }); + it('keeps the layer type menu scrollable and closes it when clicking outside', () => { + const layer = createLayer({ assetKind: 'character' }); + const documentWheel = vi.fn(); + document.addEventListener('wheel', documentWheel); + + try { + renderWorldView({ layers: [layer] }); + + fireEvent.click(screen.getByRole('button', { name: '素材标签:角色' })); + + const menu = screen.getByRole('menu', { name: '选择素材标签' }); + fireEvent.wheel(menu, { deltaY: 120 }); + + expect(documentWheel).not.toHaveBeenCalled(); + expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy(); + + fireEvent.pointerDown(menu); + expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy(); + + fireEvent.pointerDown(document.body); + expect( + screen.queryByRole('menu', { name: '选择素材标签' }), + ).toBeNull(); + } finally { + document.removeEventListener('wheel', documentWheel); + } + }); + it('hides resolution and duration text for focused audio generation frames', () => { const dialog = createGenerationDialog({ id: 'dialog-audio', diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 759b834b5..709a0465f 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -651,6 +651,8 @@ export function ImageCanvasWorldView({ const [openLayerKindMenuId, setOpenLayerKindMenuId] = useState( null, ); + const openLayerKindTriggerRef = useRef(null); + const openLayerKindMenuRef = useRef(null); const visibleLayers = useMemo( () => layers @@ -660,6 +662,34 @@ export function ImageCanvasWorldView({ [layers], ); + useEffect(() => { + if (!openLayerKindMenuId) { + return undefined; + } + + const closeKindMenuWhenClickingOutside = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if ( + openLayerKindMenuRef.current?.contains(target) || + openLayerKindTriggerRef.current?.contains(target) + ) { + return; + } + setOpenLayerKindMenuId(null); + }; + + document.addEventListener('pointerdown', closeKindMenuWhenClickingOutside); + return () => { + document.removeEventListener( + 'pointerdown', + closeKindMenuWhenClickingOutside, + ); + }; + }, [openLayerKindMenuId]); + return (
{openLayerKindMenuId === layer.id ? (
event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} > {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => ( Date: Sun, 21 Jun 2026 20:15:07 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=89=A9=E5=B1=95=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E7=94=9F=E6=88=90=E8=A7=86=E9=A2=91=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成视频面板支持多比例、4到15秒时长滑杆和静音开关 生成视频清晰度增加1080p并限制Fast模型不可选 生成视频请求默认携带联网搜索标记但不在前端展示 后端校验视频比例、时长、清晰度和声音参数并映射音频生成 更新编辑器视频参数文档和项目决策记录 --- .../shared-memory/decision-log.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 10 +- .../src/character_animation_assets.rs | 117 ++++++++++++-- .../src/editor_generation_config.rs | 18 ++- .../crates/shared-contracts/src/assets.rs | 9 ++ .../image-editor/ImageCanvasEditorModel.ts | 31 +++- .../image-editor/ImageCanvasEditorTypes.ts | 12 +- ...ImageCanvasGenerationComposerView.test.tsx | 22 ++- .../ImageCanvasGenerationComposerView.tsx | 150 ++++++++++++------ .../ImageCanvasGenerationDialogModel.test.ts | 3 +- .../ImageCanvasGenerationDialogModel.ts | 13 +- .../ImageCanvasGenerationModel.ts | 27 +++- .../ImageCanvasGenerationSubmissionModel.ts | 18 ++- src/index.css | 72 +++++++++ .../image-editor/editorProjectClient.test.ts | 2 + .../image-editor/editorProjectClient.ts | 14 +- 17 files changed, 420 insertions(+), 110 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 165c7343f..0d0b7a56e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -72,6 +72,14 @@ - 验证方式:运行 `npx vitest run src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/services/image-editor/editorReferenceUploadClient.test.ts --reporter verbose`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts editor_video_request_supports_seedance_multimodal_references --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、火山 Seedance 2.0 任务创建文档。 +## 2026-06-21 图片画布生成视频参数扩展 + +- 背景:编辑器画布生成视频需要开放更多 Lovart 式参数,同时保留模型能力边界;`seedance2.0-fast` 不支持 `1080p`,联网搜索暂没有可确认的 Ark 视频生成 body 字段。 +- 决策:生成视频参数面板支持比例 `16:9 / 9:16 / 1:1 / 4:3 / 3:4 / 21:9`,时长为 4 到 15 秒整数 slider,清晰度支持 `480p / 720p / 1080p`;`seedance2.0-fast` 不展示可用 `1080p`,从其它模型的 `1080p` 切回 Fast 时自动降到 `720p`,后端也拒绝 `seedance2.0-fast + 1080p`。静音只作为一个 toggle 展示,默认有声并映射 `sound=on` / Ark `generate_audio=true`;关闭静音时传 `sound=off` / `generate_audio=false`。`webSearchEnabled` 默认随请求提交为 `true`,但前端不展示联网搜索开关,后端当前只接收契约字段,不向 Ark 透传未知参数。 +- 影响范围:图片画布生成视频面板、生成提交模型、画布项目快照恢复、`editorProjectClient`、`shared-contracts`、`api-server` 编辑器视频 BFF、编辑器技术文档。 +- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts`、`cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`,并执行 `npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。 + ## 2026-06-18 图片画布生成音乐入口作为音频图层接入 - 背景:图片画布底部生成工具需要补齐游戏音效和游戏背景音乐生成,既要复用现有 Lovart 式画布生成器快照、占位避让和持久化,又不能把音频能力并入图片素材库或视觉小说专用音频开关。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index c6e6da28c..e156e9877 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -18,7 +18,7 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;快速编辑按原图模型提交;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和重绘在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;快速编辑按原图模型提交;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频比例可选 `16:9`、`9:16`、`1:1`、`4:3`、`3:4`、`21:9`,时长为 4 到 15 秒整数 slider,清晰度为 `480p`、`720p`、`1080p`,其中 `seedance2.0-fast` 不支持 `1080p` 并在切回 Fast 时自动降到 `720p`;静音作为一个 toggle 控制 `sound=off/on`,默认有声;提交契约默认携带 `webSearchEnabled=true`,但前端不展示联网搜索开关,后端在上游视频生成接口没有稳定参数前只接收该字段、不透传未知 Ark 字段。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和重绘在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图自动作为最后一张参考图展示和提交。尺寸、模型沿用原图配置,只以禁用态参数胶囊展示,不可点击修改。提交前将提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张原图编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变。 @@ -69,7 +69,7 @@ - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。 - `POST /api/editor/ui-designs/assets/extractions`:以 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。 - `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 -- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 +- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 所有写接口都必须校验 Bearer 登录态和 owner;接口只返回当前用户有权读取的工程与资源。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 26e16f826..a5b72d7c0 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -94,7 +94,7 @@ - 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。 - 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。 - 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9 · 2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 -- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换到 `720p` 后同步为 `1280 x 720`;调整参数时保持占位中心点不变。 +- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 - 快速编辑点击生成后不在原图上播放生成中遮罩,而是立即创建独立 `Quick Edit Generator` 画布生成占位并播放生成中动画;生成成功后结果落在该占位框位置,失败时占位标记失败并恢复快速编辑面板。 @@ -119,7 +119,7 @@ ```text 生成图片 / 生成角色形象 / 生成图标素材 / 生成UI设计图 / 宣发素材 / 快速编辑 / 重绘:nanobanana2 为 12 泥点,gpt-image-2 为 20 泥点 生成规范:固定 gpt-image-2,5 泥点 -生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20;seedance2.0 为 480p 每秒 12 / 720p 每秒 24;kling3.0 为 480p 每秒 15 / 720p 每秒 30;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 +生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20,不支持 1080p;seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80 角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点 生成音效:10 泥点 生成背景音乐:5 泥点 @@ -139,7 +139,7 @@ ## 生成视频模型与接口 - 底部 `生成视频` 面板提交到 `POST /api/editor/videos/generations`。 -- 固定 `16:9`、静音、标准模式;结果作为 `mediaType="video"`、`assetKind="video"` 的视频图层加入画布,并在画布中使用原生 `
+
+ + ) : null} + + ); } export function CreationLandingView({ @@ -303,6 +376,9 @@ export function CreationLandingView({ const [assetError, setAssetError] = useState(null); const [activeShowcaseTab, setActiveShowcaseTab] = useState('packs'); + const [selectedShowcaseItem, setSelectedShowcaseItem] = + useState(null); + const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0); const refreshRecentProjects = useCallback(() => { if (!isAuthenticated) { @@ -386,15 +462,16 @@ export function CreationLandingView({ .finally(() => setIsCreatingProject(false)); }, [authUi, onOpenProject]); - const showcaseItems = useMemo(() => { - const libraryItems = (assetLibrary?.assets ?? []) - .filter((asset) => matchesShowcaseTab(asset, activeShowcaseTab)) - .map(toLibraryShowcaseItem); - if (libraryItems.length > 0) { - return libraryItems.slice(0, 36); - } - return toPublicGalleryShowcaseItems(publicGalleryEntries, activeShowcaseTab); - }, [activeShowcaseTab, assetLibrary, publicGalleryEntries]); + const showcaseItems = useMemo( + () => + buildCreationShowcaseItems({ + activeTab: activeShowcaseTab, + assetLibrary, + publicGalleryEntries, + fallbackAuthorName: authUi?.user?.displayName, + }), + [activeShowcaseTab, assetLibrary, authUi?.user?.displayName, publicGalleryEntries], + ); const isShowcaseLoading = isAuthenticated && isLoadingAssets; const showcaseEmptyText = @@ -426,22 +503,16 @@ export function CreationLandingView({ return (
{showcaseItems.map((item) => ( -
-
- -
+
- + ))} ); @@ -638,6 +709,12 @@ export function CreationLandingView({ {renderAssetShowcase()} + setSelectedShowcaseItem(null)} + /> ); } diff --git a/src/components/creation-home/creationShowcaseModel.test.ts b/src/components/creation-home/creationShowcaseModel.test.ts new file mode 100644 index 000000000..90ffd8089 --- /dev/null +++ b/src/components/creation-home/creationShowcaseModel.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from 'vitest'; + +import type { + EditorAssetLibrarySnapshot, + EditorAssetSnapshot, +} from '../../services/image-editor/editorProjectClient'; +import { buildCreationShowcaseItems } from './creationShowcaseModel'; + +function createAsset( + overrides: Partial & Record, +): EditorAssetSnapshot { + return { + assetId: 'asset-1', + folderId: 'folder-1', + label: '素材', + imageSrc: 'data:image/png;base64,asset', + width: 512, + height: 512, + sourceType: 'generated', + prompt: null, + actualPrompt: null, + model: null, + provider: null, + taskId: null, + ...overrides, + }; +} + +function createLibrary(assets: EditorAssetSnapshot[]): EditorAssetLibrarySnapshot { + return { + folders: [], + assets, + }; +} + +describe('creationShowcaseModel', () => { + it('groups pack items by the same referenced spec image and sums known cost', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'packs', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'spec-1', + label: '角色规范图', + assetKind: 'spec', + prompt: '规范提示词', + priceMudPoints: 5, + authorName: '创作者A', + }), + createAsset({ + assetId: 'character-1', + label: '角色主图', + assetKind: 'character', + priceMudPoints: 12, + generationInputs: { + fields: [{ title: '角色设定', value: '角色提示词' }], + references: [ + { + title: '角色规范', + label: '角色规范图', + refType: 'asset', + refId: 'spec-1', + }, + ], + }, + }), + ]), + publicGalleryEntries: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.label).toBe('角色规范图'); + expect(items[0]?.previews.map((preview) => preview.label)).toEqual([ + '角色规范图', + '角色主图', + ]); + expect(items[0]?.prompt).toBe('规范提示词'); + expect(items[0]?.author).toBe('创作者A'); + expect(items[0]?.cost).toBe('17泥点'); + }); + + it('groups assets when references point to the source project resource', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'packs', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'spec-1', + label: '项目规范图', + assetKind: 'spec', + sourceResourceId: 'resource-spec-1', + }), + createAsset({ + assetId: 'character-1', + label: '项目角色图', + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '项目角色提示词' }], + references: [ + { + title: '角色规范', + label: '项目规范图', + refType: 'project-resource', + refId: 'resource-spec-1', + }, + ], + }, + }), + ]), + publicGalleryEntries: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.previews.map((preview) => preview.label)).toEqual([ + '项目规范图', + '项目角色图', + ]); + }); + + it('groups character animations under the referenced character image', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'characters', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'character-1', + label: '游戏角色图', + assetKind: 'character', + prompt: '角色提示词', + }), + createAsset({ + assetId: 'animation-1', + label: '待机动画', + assetKind: 'character-animation', + imageSrc: 'data:image/png;base64,animation', + generationInputs: { + fields: [{ title: '动作描述', value: '待机动作' }], + references: [ + { + title: '角色图片', + label: '游戏角色图', + refType: 'asset', + refId: 'character-1', + }, + ], + }, + }), + ]), + publicGalleryEntries: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.label).toBe('游戏角色图'); + expect(items[0]?.previews.map((preview) => preview.label)).toEqual([ + '游戏角色图', + '待机动画', + ]); + }); + + it('groups UI design extraction assets under the referenced UI image', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'ui', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'ui-design-1', + label: '游戏UI图', + assetKind: 'ui-design', + prompt: 'UI提示词', + }), + createAsset({ + assetId: 'icon-sheet-1', + label: 'UI子素材图集', + assetKind: 'icon-spritesheet', + generationInputs: { + fields: [{ title: '提取提示词', value: '提取UI子素材' }], + references: [ + { + title: 'UI设计图', + label: '游戏UI图', + refType: 'asset', + refId: 'ui-design-1', + }, + ], + }, + }), + createAsset({ + assetId: 'icon-1', + label: '开始按钮', + assetKind: 'icon', + generationInputs: { + fields: [{ title: '提取提示词', value: '提取UI子素材' }], + references: [ + { + title: 'UI设计图', + label: '游戏UI图', + refType: 'asset', + refId: 'ui-design-1', + }, + ], + }, + }), + ]), + publicGalleryEntries: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.label).toBe('游戏UI图'); + expect(items[0]?.previews.map((preview) => preview.label)).toEqual([ + '游戏UI图', + 'UI子素材图集', + '开始按钮', + ]); + }); + + it('counts a spritesheet task only once when UI children share the same task', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'ui', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'ui-design-1', + label: '游戏UI图', + assetKind: 'ui-design', + model: 'gemini-3.1-flash-image-preview', + taskId: 'task-ui-design', + }), + createAsset({ + assetId: 'icon-sheet-1', + label: 'UI子素材图集', + assetKind: 'icon-spritesheet', + model: 'gpt-image-2', + taskId: 'task-ui-extract', + generationInputs: { + fields: [{ title: '提取提示词', value: '提取UI子素材' }], + references: [ + { + title: 'UI设计图', + label: '游戏UI图', + refType: 'asset', + refId: 'ui-design-1', + }, + ], + }, + }), + createAsset({ + assetId: 'icon-1', + label: '开始按钮', + assetKind: 'icon', + model: 'gpt-image-2', + taskId: 'task-ui-extract', + generationInputs: { + fields: [{ title: '提取提示词', value: '提取UI子素材' }], + references: [ + { + title: 'UI设计图', + label: '游戏UI图', + refType: 'asset', + refId: 'ui-design-1', + }, + ], + }, + }), + ]), + publicGalleryEntries: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.cost).toBe('32泥点'); + }); + + it('keeps music assets as audio preview items', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'music', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'music-1', + label: '背景音乐', + imageSrc: '/generated-editor-audios/music.mp3', + assetKind: 'background-music', + prompt: '舒缓背景音乐', + priceMudPoints: '5', + }), + ]), + publicGalleryEntries: [], + fallbackAuthorName: '当前用户', + }); + + expect(items).toHaveLength(1); + expect(items[0]?.previews[0]?.mediaType).toBe('audio'); + expect(items[0]?.author).toBe('当前用户'); + expect(items[0]?.cost).toBe('5泥点'); + }); + + it('keeps character animation videos out of the marketing tab', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'marketing', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'animation-1', + label: '角色动画', + assetKind: 'character-animation', + imageSrc: '/generated-editor-videos/animation.mp4', + }), + createAsset({ + assetId: 'poster-1', + label: '上线美宣视频', + assetKind: 'publication-material', + imageSrc: '/generated-editor-videos/poster.mp4', + }), + ]), + publicGalleryEntries: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.label).toBe('上线美宣视频'); + expect(items[0]?.previews[0]?.mediaType).toBe('video'); + }); + + it('infers costs from generated asset kinds when stored price is absent', () => { + const items = buildCreationShowcaseItems({ + activeTab: 'characters', + assetLibrary: createLibrary([ + createAsset({ + assetId: 'character-1', + label: '游戏角色图', + imageSrc: '/generated-editor-assets/character.png', + objectKey: 'generated-editor-assets/character.png', + assetKind: 'character', + model: 'gpt-image-2', + prompt: '角色提示词', + }), + createAsset({ + assetId: 'animation-1', + label: '待机动画', + assetKind: 'character-animation', + imageSrc: '/generated-editor-videos/animation.mp4', + objectKey: 'generated-editor-videos/animation.mp4', + model: 'seedance2.0-fast', + durationSeconds: 6, + generationInputs: { + fields: [{ title: '清晰度', value: '720p' }], + references: [ + { + title: '角色图片', + label: '游戏角色图', + refType: 'asset', + refId: 'character-1', + }, + ], + }, + }), + ]), + publicGalleryEntries: [], + }); + + expect(items).toHaveLength(1); + expect(items[0]?.cost).toBe('140泥点'); + expect(items[0]?.previews[0]?.objectKey).toBe( + 'generated-editor-assets/character.png', + ); + }); +}); diff --git a/src/components/creation-home/creationShowcaseModel.ts b/src/components/creation-home/creationShowcaseModel.ts new file mode 100644 index 000000000..2e6f299c9 --- /dev/null +++ b/src/components/creation-home/creationShowcaseModel.ts @@ -0,0 +1,754 @@ +import type { + EditorAssetGenerationInputReference, + EditorAssetLibrarySnapshot, + EditorAssetSnapshot, +} 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'; +import { + resolvePlatformWorkAuthorDisplayName, + resolvePlatformWorldCoverImage, + resolvePlatformWorldCoverSlides, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +export type ShowcaseTabId = + | 'packs' + | 'characters' + | 'ui' + | 'music' + | 'effects' + | 'scenes' + | 'marketing' + | 'games'; + +export type ShowcaseAssetMediaType = 'image' | 'video' | 'audio'; + +export type ShowcaseAssetPreview = { + id: string; + label: string; + src: string; + objectKey?: string | null; + mediaType: ShowcaseAssetMediaType; + width?: number; + height?: number; +}; + +export type ShowcaseAssetItem = { + id: string; + label: string; + previews: ShowcaseAssetPreview[]; + prompt: string; + author: string; + cost: string; +}; + +type ShowcaseAssetGroup = { + id: string; + label?: string; + assets: EditorAssetSnapshot[]; +}; + +const UNKNOWN_META_VALUE = '-'; + +const SHOWCASE_COST_KEYS = [ + 'priceMudPoints', + 'costMudPoints', + 'generationCostMudPoints', + 'mudPointCost', +] as const; + +const SHOWCASE_AUTHOR_KEYS = [ + 'authorName', + 'creatorName', + 'ownerLabel', + 'ownerName', + 'authorDisplayName', +] as const; + +export const SHOWCASE_TABS: Array<{ id: ShowcaseTabId; label: string }> = [ + { id: 'packs', label: '素材包' }, + { id: 'characters', label: '角色' }, + { id: 'ui', label: 'UI' }, + { id: 'music', label: '音乐' }, + { id: 'effects', label: '特效' }, + { id: 'scenes', label: '场景' }, + { id: 'marketing', label: '美宣' }, + { id: 'games', label: '游戏' }, +]; + +function assetRecord(asset: EditorAssetSnapshot) { + return asset as Record; +} + +function normalizeText(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +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.provider, + 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.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 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 (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 isSceneAsset(asset: EditorAssetSnapshot) { + return /场景|scene|background/u.test(getAssetHaystack(asset)); +} + +function isEffectAsset(asset: EditorAssetSnapshot) { + return /特效|effect|vfx/u.test(getAssetHaystack(asset)); +} + +function isGameAsset(asset: EditorAssetSnapshot) { + return /游戏工程|game asset|game package|导出工程/u.test(getAssetHaystack(asset)); +} + +function addAssetToGroup( + groups: Map, + 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, + 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 groupPackAssets(assets: EditorAssetSnapshot[]) { + const groups = new Map(); + assets.forEach((asset) => { + const specReference = findSpecReference(asset); + if (specReference) { + addAssetToGroup(groups, getReferenceKey(specReference), asset, specReference.label); + return; + } + if (isSpecAsset(asset)) { + addAssetToGroup(groups, getSelfAssetKey(asset), asset, asset.label); + return; + } + addAssetToGroup(groups, `standalone:${asset.assetId}`, asset, asset.label); + }); + const specAssets = assets.filter(isSpecAsset); + specAssets.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 groupCharacterAssets(assets: EditorAssetSnapshot[]) { + const groups = new Map(); + 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(); + 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 === 'packs') { + return groupPackAssets(assets); + } + 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); + } + if (tab === 'effects') { + return groupSingleAssets(assets, isEffectAsset); + } + if (tab === 'scenes') { + return groupSingleAssets(assets, isSceneAsset); + } + return groupSingleAssets(assets, isGameAsset); +} + +function resolveAssetPrompt(asset: EditorAssetSnapshot) { + const directPrompt = asset.prompt?.trim() || asset.actualPrompt?.trim(); + if (directPrompt) { + return directPrompt; + } + + const generationFields = asset.generationInputs?.fields ?? []; + const firstPromptField = + generationFields.find((field) => /提示词|描述|设定|prompt/u.test(field.title)) ?? + generationFields[0]; + return firstPromptField?.value?.trim() || 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.durationSeconds, 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(asset.durationSeconds, DEFAULT_VIDEO_DURATION_SECONDS, 4, 15), + ); + } + if (asset.sourceType === 'generated' || kind) { + return calculateEditorImageGenerationPrice({ kind, model }); + } + return null; +} + +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(); + 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 toAssetPreview(asset: EditorAssetSnapshot): ShowcaseAssetPreview { + return { + id: asset.assetId, + label: asset.label, + src: asset.imageSrc, + objectKey: asset.objectKey, + mediaType: resolveAssetMediaType(asset), + 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.src.trim()); + if (!previews.length) { + return null; + } + const primaryAsset = sortedAssets[0]; + 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), + }; +} + +function matchesPublicGalleryTab( + entry: PlatformPublicGalleryCard, + tab: ShowcaseTabId, +) { + if (tab === 'packs') { + return true; + } + if (tab === 'games') { + return false; + } + + const themeTags = 'themeTags' in entry ? entry.themeTags : []; + const haystack = [ + entry.worldName, + entry.subtitle, + entry.summaryText, + entry.sourceType, + ...themeTags, + ] + .join(' ') + .toLowerCase(); + + if (tab === 'characters') { + return /角色|character|rpg|visual/u.test(haystack); + } + if (tab === 'ui') { + return /\bui\b|界面|图标|icon|puzzle/u.test(haystack); + } + if (tab === 'music') { + return /音乐|音效|audio|music|sound/u.test(haystack); + } + if (tab === 'marketing') { + return /美宣|宣传|poster|video|视觉|visual/u.test(haystack); + } + if (tab === 'effects') { + return /特效|effect|vfx/u.test(haystack); + } + if (tab === 'scenes') { + return /场景|scene|background|world|rpg/u.test(haystack); + } + return false; +} + +function toPublicGalleryShowcaseItems( + entries: PlatformPublicGalleryCard[], + tab: ShowcaseTabId, +): ShowcaseAssetItem[] { + const usedImageSrcs = new Set(); + const items: ShowcaseAssetItem[] = []; + + entries + .filter((entry) => matchesPublicGalleryTab(entry, tab)) + .forEach((entry) => { + const coverImageSrc = resolvePlatformWorldCoverImage(entry).trim(); + const imageCandidates = [ + ...resolvePlatformWorldCoverSlides(entry).map((slide) => ({ + src: slide.imageSrc.trim(), + label: slide.label?.trim() || entry.worldName, + })), + { src: coverImageSrc, label: entry.worldName }, + ].filter((candidate) => candidate.src); + + imageCandidates.forEach((candidate, index) => { + if (usedImageSrcs.has(candidate.src)) { + return; + } + usedImageSrcs.add(candidate.src); + items.push({ + id: `public:${entry.sourceType}:${entry.profileId}:${index}`, + label: index === 0 ? entry.worldName : `${entry.worldName} ${index + 1}`, + previews: [ + { + id: `public:${entry.profileId}:${index}`, + label: candidate.label, + src: candidate.src, + mediaType: 'image', + width: index === 0 ? 720 : 640, + height: index === 0 ? 480 : 720, + }, + ], + prompt: entry.summaryText || entry.subtitle || UNKNOWN_META_VALUE, + author: resolvePlatformWorkAuthorDisplayName(entry), + cost: UNKNOWN_META_VALUE, + }); + }); + }); + + return items.slice(0, 36); +} + +export function buildCreationShowcaseItems({ + activeTab, + assetLibrary, + publicGalleryEntries, + fallbackAuthorName, +}: { + activeTab: ShowcaseTabId; + assetLibrary: EditorAssetLibrarySnapshot | null; + publicGalleryEntries: PlatformPublicGalleryCard[]; + fallbackAuthorName?: string | null; +}) { + const libraryItems = getGroupsForTab(assetLibrary?.assets ?? [], activeTab) + .map((group) => toLibraryShowcaseItem(group, fallbackAuthorName)) + .filter((item): item is ShowcaseAssetItem => item !== null); + + if (libraryItems.length > 0) { + return libraryItems.slice(0, 36); + } + + return toPublicGalleryShowcaseItems(publicGalleryEntries, activeTab); +} diff --git a/src/index.css b/src/index.css index c7fc28769..e6d5d913e 100644 --- a/src/index.css +++ b/src/index.css @@ -3617,10 +3617,20 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { border: 1px solid rgba(220, 189, 163, 0.78); border-radius: 1rem; background: rgba(255, 254, 251, 0.88); + padding: 0; + color: inherit; + text-align: left; box-shadow: 0 16px 32px rgba(124, 72, 35, 0.1); + cursor: zoom-in; +} + +.creation-landing__asset-card:hover { + border-color: rgba(195, 104, 48, 0.46); + box-shadow: 0 20px 40px rgba(124, 72, 35, 0.14); } .creation-landing__asset-preview { + position: relative; display: grid; min-height: 9.5rem; max-height: 23rem; @@ -3629,6 +3639,64 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { background: linear-gradient(180deg, #fffdfa, #fff4ea); } +.creation-landing__asset-preview--bundle { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: stretch; + gap: 0.18rem; + padding: 0.18rem; +} + +.creation-landing__asset-preview-tile { + position: relative; + display: grid; + width: 100%; + height: 100%; + min-height: 5rem; + overflow: hidden; + place-items: center; + border-radius: 0.72rem; + background: rgba(255, 255, 255, 0.66); +} + +.creation-landing__asset-preview-more { + position: absolute; + right: 0.65rem; + bottom: 0.65rem; + display: inline-flex; + min-width: 2rem; + height: 2rem; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(44, 31, 22, 0.72); + color: #fffdf9; + font-size: 0.78rem; + font-weight: 850; +} + +.creation-landing__asset-audio-preview { + display: grid; + width: 100%; + height: 100%; + min-height: 8rem; + place-items: center; + gap: 1rem; + padding: 1rem; + background: + linear-gradient(135deg, rgba(244, 187, 129, 0.4), rgba(255, 247, 236, 0.92)), + radial-gradient(circle at 30% 24%, rgba(255, 255, 255, 0.9), transparent 42%); + color: #9a5428; +} + +.creation-landing__asset-audio-preview svg { + width: 2.6rem; + height: 2.6rem; +} + +.creation-landing__asset-audio-preview audio { + width: min(100%, 28rem); +} + .creation-landing__asset-meta { display: grid; gap: 0.55rem; @@ -3675,6 +3743,215 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { white-space: nowrap; } +.creation-landing__showcase-modal-overlay { + padding: 0.5rem; + background: rgba(11, 8, 6, 0.82); +} + +.creation-landing__showcase-modal-panel { + width: min(100vw - 1rem, 88rem); + height: min(96vh, 64rem); + max-height: calc(100vh - 1rem); + overflow: hidden; + border-radius: 1.2rem; + background: #100d0b; + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.5); +} + +.creation-landing__showcase-modal-body { + display: flex; + min-height: 0; + flex: 1; + padding: 0; + overflow: hidden; +} + +.creation-landing__showcase-modal { + position: relative; + display: grid; + width: 100%; + height: 100%; + min-height: 0; + grid-template-rows: minmax(0, 1fr) auto; + overflow: hidden; + background: + radial-gradient(circle at 18% 12%, rgba(186, 117, 65, 0.18), transparent 32%), + #100d0b; +} + +.creation-landing__showcase-modal-close { + z-index: 4; +} + +.creation-landing__showcase-modal-stage { + position: relative; + z-index: 1; + display: grid; + min-height: 0; + overflow: hidden; + place-items: center; + padding: clamp(1rem, 3vw, 2.5rem); +} + +.creation-landing__showcase-current { + position: relative; + display: grid; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; + place-items: center; +} + +.creation-landing__showcase-current > img, +.creation-landing__showcase-current > video, +.creation-landing__showcase-current .creation-landing__asset-audio-preview { + max-width: 100%; + max-height: 100%; + border-radius: 0.9rem; +} + +.creation-landing__showcase-current > img, +.creation-landing__showcase-current > video { + width: auto; + height: auto; + object-fit: contain; + object-position: center; + box-shadow: 0 18px 52px rgba(0, 0, 0, 0.32); +} + +.creation-landing__showcase-current > .creation-landing__showcase-media { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + object-fit: contain; + object-position: center; +} + +.creation-landing__showcase-dock { + position: relative; + z-index: 3; + display: grid; + gap: 0.85rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(24, 18, 14, 0.94); + backdrop-filter: none; + padding: 0.85rem; +} + +.creation-landing__showcase-dock-meta { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(13rem, 0.44fr); + gap: 0.75rem 1rem; + align-items: start; + color: #fff8ef; +} + +.creation-landing__showcase-dock-meta h3 { + margin: 0; + overflow: hidden; + color: #fff8ef; + font-size: 1rem; + font-weight: 860; + text-overflow: ellipsis; + white-space: nowrap; +} + +.creation-landing__showcase-dock-meta p { + grid-column: 1 / 2; + display: -webkit-box; + min-height: 2.6em; + overflow: hidden; + margin: 0; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + color: rgba(255, 248, 239, 0.72); + font-size: 0.82rem; + font-weight: 620; + line-height: 1.32; +} + +.creation-landing__showcase-dock-meta dl { + display: grid; + grid-column: 2 / 3; + grid-row: 1 / span 2; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + margin: 0; +} + +.creation-landing__showcase-dock-meta div { + min-width: 0; +} + +.creation-landing__showcase-dock-meta dt { + color: rgba(255, 248, 239, 0.5); + font-size: 0.68rem; + font-weight: 780; +} + +.creation-landing__showcase-dock-meta dd { + overflow: hidden; + margin: 0; + color: #fff8ef; + font-size: 0.82rem; + font-weight: 820; + text-overflow: ellipsis; + white-space: nowrap; +} + +.creation-landing__showcase-thumbs { + display: flex; + gap: 0.55rem; + overflow-x: auto; + padding-bottom: 0.1rem; +} + +.creation-landing__showcase-thumb { + display: grid; + flex: 0 0 6.5rem; + min-width: 0; + gap: 0.38rem; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 0.72rem; + background: rgba(255, 255, 255, 0.06); + padding: 0.38rem; + color: rgba(255, 248, 239, 0.72); + font-size: 0.68rem; + font-weight: 760; + text-align: center; +} + +.creation-landing__showcase-thumb--active { + border-color: rgba(242, 184, 124, 0.8); + background: rgba(242, 184, 124, 0.18); + color: #fff8ef; +} + +.creation-landing__showcase-thumb-media { + display: grid; + height: 4.1rem; + overflow: hidden; + place-items: center; + border-radius: 0.52rem; + background: rgba(255, 255, 255, 0.08); +} + +.creation-landing__showcase-thumb-media svg { + width: 1.45rem; + height: 1.45rem; +} + +.creation-landing__showcase-thumb > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + @media (max-width: 900px) { .creation-landing__hero, .creation-landing__section { @@ -3718,6 +3995,21 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .creation-landing__asset-waterfall { column-count: 2; } + + .creation-landing__showcase-modal-panel { + width: calc(100vw - 0.7rem); + height: calc(100vh - 0.7rem); + } + + .creation-landing__showcase-dock-meta { + grid-template-columns: 1fr; + } + + .creation-landing__showcase-dock-meta p, + .creation-landing__showcase-dock-meta dl { + grid-column: auto; + grid-row: auto; + } } @media (max-width: 560px) { @@ -3763,6 +4055,29 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .creation-landing__asset-waterfall { column-count: 1; } + + .creation-landing__showcase-modal-overlay { + padding: 0; + } + + .creation-landing__showcase-modal-panel { + width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + } + + .creation-landing__showcase-modal-stage { + padding: 3.2rem 0.75rem 0.75rem; + } + + .creation-landing__showcase-dock { + padding: 0.72rem; + } + + .creation-landing__showcase-thumb { + flex-basis: 5.7rem; + } } .image-canvas-editor {