From 13a1044cf230764f86552fed338f103ed405ee82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 15:02:43 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5ui=E8=A1=A8=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收窄创作主页和项目页共用的左侧导航栏与顶部搜索栏 压缩导航按钮、搜索框、泥点入口和账号入口的内部间距 修复项目页切换后创作入口仍保留主按钮样式的问题 补充项目页导航默认态的文档与回归测试 --- ...作】创作主页与项目入口改版计划-2026-06-18.md | 3 +- ...gEntryFlowShell.agent.interaction.test.tsx | 50 +++++++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 2 +- src/index.css | 99 +++++++++---------- src/index.test.ts | 17 ++++ 5 files changed, 116 insertions(+), 55 deletions(-) diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index 547bd3249..b448a361d 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -125,6 +125,7 @@ Tab: - 桌面端点击“创作”时调用 `pushAppHistoryPath('/creation')`。 - 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`。 - 桌面端 `/project` 必须复用 `/creation` 的平台桌面 chrome,保留左侧完整导航栏和右侧顶部栏;`ProjectGalleryView` 只替换右侧主内容区,不能作为脱离导航的独立全屏页渲染,避免用户进入项目页后无法返回或切换页签。 +- 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。 - 移动端导航列表不展示“创作”和“项目”。 - 移动端不拦截用户直接访问 `/creation` 或 `/project`。 @@ -144,7 +145,7 @@ Tab: - 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选素材瀑布流。 - 桌面端复用现有平台壳层,保留上方栏和左侧导航栏。 - 主页整体采用浅色布局,避免外站品牌文案。 -- 视觉排版参考陶土质感创作工具工作台:首屏标题居中、主按钮与社区按钮使用大号圆角胶囊尺寸,九大创作工具以 3 列大卡片展示,卡片左侧保留大号陶土感图标区域,右侧展示原有标题与描述;`/creation` 场景下的平台顶部栏和左侧导航压平成单层工作台 chrome,避免外框套内框;桌面端页面结构必须先分为“左侧完整栏 / 右侧内容区”,左侧完整栏同时包含顶部品牌 Logo 和下方导航页签,右侧内容区再拆为顶部搜索、钱包、账号栏与下方主屏,不能用顶部栏的第一列假装左侧品牌区;左侧栏保持接近参考图的宽侧栏、横向图标按钮和单层导航胶囊,不回退为窄图标栏;只调整样式与布局,不改文案、搜索、登录、导航、项目创建、社区弹层、最近项目、精选素材 Tab 或数据读取逻辑。 +- 视觉排版参考陶土质感创作工具工作台:首屏标题居中、主按钮与社区按钮使用大号圆角胶囊尺寸,九大创作工具以 3 列大卡片展示,卡片左侧保留大号陶土感图标区域,右侧展示原有标题与描述;`/creation` 场景下的平台顶部栏和左侧导航压平成单层工作台 chrome,避免外框套内框;桌面端页面结构必须先分为“左侧完整栏 / 右侧内容区”,左侧完整栏同时包含顶部品牌 Logo 和下方导航页签,右侧内容区再拆为顶部搜索、钱包、账号栏与下方主屏,不能用顶部栏的第一列假装左侧品牌区;左侧栏采用紧凑但保留图标和文字的导航胶囊,顶部搜索栏、泥点入口和账号入口都保持精简宽度,提升创作主页和项目页的工具感与精致度;只调整样式与布局,不改文案、搜索、登录、导航、项目创建、社区弹层、最近项目、精选素材 Tab 或数据读取逻辑。 - 背景、顶部品牌小陶偶、左侧导航、按钮和九大创作工具图标使用 `gpt-image-2` / image2 生成资源,统一保存在 `public/creation-home/`;资源只作为装饰图标或背景纹理接入,不包含图内文字,不替换或隐藏现有 UI 文案和交互。 - 社群入口复用现有玩家社区弹层,点击后必须停留在 `/creation`。 - 不在 UI 中放规则说明、实现说明或“暂不实装”提示。 diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 32c678cbe..fb18c202a 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -9195,9 +9195,21 @@ test('project page keeps desktop rail and can return to platform tabs', async () expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy(); const nav = container.querySelector('.platform-desktop-rail'); expect(nav).toBeTruthy(); - expect( - within(nav as HTMLElement).getByRole('button', { name: '项目' }), - ).toBeTruthy(); + const createNavButton = within(nav as HTMLElement).getByRole('button', { + name: '创作', + }); + const projectNavButton = within(nav as HTMLElement).getByRole('button', { + name: '项目', + }); + expect(projectNavButton.className).toContain( + 'platform-desktop-rail__button--active', + ); + expect(createNavButton.className).not.toContain( + 'platform-desktop-rail__button--active', + ); + expect(createNavButton.className).not.toContain( + 'platform-desktop-rail__button--primary', + ); await user.click(within(nav as HTMLElement).getByRole('button', { name: '推荐' })); @@ -9207,6 +9219,38 @@ test('project page keeps desktop rail and can return to platform tabs', async () expect(window.location.pathname).toBe('/'); }); +test('desktop project tab switch clears creation primary styling', async () => { + const user = userEvent.setup(); + mockDesktopPlatformLayout(); + window.history.replaceState(null, '', '/creation'); + const { container } = render(); + + await screen.findByRole('main', { name: '陶泥儿创作主页' }); + const nav = container.querySelector('.platform-desktop-rail'); + expect(nav).toBeTruthy(); + + await user.click(within(nav as HTMLElement).getByRole('button', { name: '项目' })); + + expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy(); + const projectNav = container.querySelector('.platform-desktop-rail'); + expect(projectNav).toBeTruthy(); + const createNavButton = within(projectNav as HTMLElement).getByRole('button', { + name: '创作', + }); + const projectNavButton = within(projectNav as HTMLElement).getByRole('button', { + name: '项目', + }); + expect(projectNavButton.className).toContain( + 'platform-desktop-rail__button--active', + ); + expect(createNavButton.className).not.toContain( + 'platform-desktop-rail__button--active', + ); + expect(createNavButton.className).not.toContain( + 'platform-desktop-rail__button--primary', + ); +}); + test('direct mobile creation home keeps landing content without mobile nav entry', async () => { window.history.replaceState(null, '', '/creation'); const { container } = render(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 7eb20db9f..921d33a07 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -5037,7 +5037,7 @@ export function RpgEntryHomeView({ label={tabLabels[tab]} icon={tabIcons[tab]} creationHomeIconSrc={CREATION_HOME_NAV_ICON_SRC[tab]} - emphasized={tab === 'create'} + emphasized={tab === 'create' && resolvedDesktopNavTab === 'create'} showDot={false} disabled={isRechargePaymentConfirmationPending} onClick={() => { diff --git a/src/index.css b/src/index.css index ca9625088..f1a780144 100644 --- a/src/index.css +++ b/src/index.css @@ -11081,7 +11081,7 @@ button.image-canvas-editor__reference-chip:disabled { } .creation-home-stage .platform-desktop-shell { - --creation-home-sidebar-width: 13.75rem; + --creation-home-sidebar-width: 10rem; border-color: rgba(221, 191, 166, 0.9); border-radius: 1.85rem; padding: 0 !important; @@ -11112,7 +11112,7 @@ button.image-canvas-editor__reference-chip:disabled { min-height: 0; border-right: 1px solid rgba(219, 190, 166, 0.72); background: rgba(255, 250, 245, 0.46); - padding: 1.22rem 1rem 1.3rem; + padding: 0.86rem 0.5rem 0.96rem; } .creation-home-stage .platform-desktop-main { @@ -11121,12 +11121,13 @@ button.image-canvas-editor__reference-chip:disabled { .creation-home-stage .platform-desktop-topbar { display: flex; - gap: 1rem; - min-height: 5.9rem; + gap: 0.58rem; + min-height: 3.62rem; border: 0; border-bottom: 1px solid rgba(219, 190, 166, 0.72); border-radius: 0; background: rgba(255, 250, 245, 0.54); + padding: 0.42rem 1rem !important; box-shadow: none; backdrop-filter: none; } @@ -11136,8 +11137,9 @@ button.image-canvas-editor__reference-chip:disabled { } .creation-home-stage .platform-desktop-topbar__brand-search { + gap: 0.58rem !important; justify-content: flex-start; - padding-left: clamp(10rem, 18vw, 22rem); + padding-left: clamp(3.1rem, 5.8vw, 8.2rem); } .creation-home-stage .platform-brand-logo { @@ -11146,15 +11148,15 @@ button.image-canvas-editor__reference-chip:disabled { .creation-home-stage .creation-home-brand-lockup { align-self: flex-start; - gap: 0.62rem; - min-height: 4.35rem; - padding-left: 0.48rem; + gap: 0.42rem; + min-height: 3.28rem; + padding-left: 0.28rem; } .creation-home-stage .creation-home-brand-lockup__art { display: block; - width: 3.44rem; - height: 3.44rem; + width: 2.58rem; + height: 2.58rem; flex: 0 0 auto; border-radius: 999px; object-fit: cover; @@ -11164,24 +11166,25 @@ button.image-canvas-editor__reference-chip:disabled { } .creation-home-stage .platform-brand-logo__title { - font-size: 1.88rem; + font-size: 1.38rem; color: #4a220f; } .creation-home-stage .platform-brand-logo__subtitle { color: #9b5a35; - font-size: 0.58rem; + font-size: 0.46rem; } .creation-home-stage .platform-desktop-topbar__brand-search > .platform-desktop-search { width: 100%; - max-width: 38.75rem !important; + max-width: 20rem !important; } .creation-home-stage .platform-desktop-topbar__actions { align-self: center; + gap: 0.5rem !important; margin-left: auto; } @@ -11189,7 +11192,7 @@ button.image-canvas-editor__reference-chip:disabled { position: relative; box-sizing: border-box; min-height: 0; - height: 3.36rem; + height: 2.36rem; border: 1px solid rgba(214, 184, 159, 0.92); background: rgba(255, 250, 244, 0.78); box-shadow: none; @@ -11199,21 +11202,23 @@ button.image-canvas-editor__reference-chip:disabled { .platform-desktop-topbar__brand-search > .platform-desktop-search { padding-block: 0 !important; + padding-inline: 0.64rem 0.58rem !important; + gap: 0.48rem !important; } .creation-home-stage .platform-desktop-create-wallet-chip { position: relative; - min-height: 3.24rem; + min-height: 2.28rem; border-radius: 999px !important; - padding-inline: 0.82rem 1.12rem !important; + padding-inline: 0.42rem 0.58rem !important; } .creation-home-stage .platform-desktop-create-wallet-chip::after { position: absolute; top: 50%; - right: -1.38rem; + right: -0.52rem; width: 1px; - height: 2.7rem; + height: 1.66rem; background: rgba(217, 190, 166, 0.82); content: ""; transform: translateY(-50%); @@ -11229,9 +11234,9 @@ button.image-canvas-editor__reference-chip:disabled { } .creation-home-stage .creation-home-topbar-art--search { - width: 2.05rem; - height: 2.05rem; - margin: -0.34rem -0.52rem -0.34rem -0.68rem; + width: 1.34rem; + height: 1.34rem; + margin: -0.16rem -0.26rem -0.16rem -0.34rem; } .creation-home-stage .platform-desktop-search > svg { @@ -11239,9 +11244,9 @@ button.image-canvas-editor__reference-chip:disabled { } .creation-home-stage .creation-home-topbar-art--wallet { - width: 2rem; - height: 2rem; - margin: -0.32rem -0.32rem -0.32rem -0.58rem; + width: 1.32rem; + height: 1.32rem; + margin: -0.14rem -0.18rem -0.14rem -0.3rem; } .creation-home-stage @@ -11253,11 +11258,12 @@ button.image-canvas-editor__reference-chip:disabled { .creation-home-stage .platform-desktop-topbar button.platform-desktop-search { width: auto; max-width: none !important; - min-width: 12.6rem; - height: 3.48rem; + min-width: 7.35rem; + height: 2.3rem; border-color: transparent; background: transparent; - padding-inline: 0.7rem 0.84rem; + padding-inline: 0.34rem 0.42rem; + gap: 0.38rem !important; } .creation-home-stage .platform-desktop-topbar button.platform-desktop-search:hover { @@ -11266,48 +11272,47 @@ button.image-canvas-editor__reference-chip:disabled { } .creation-home-stage .platform-desktop-topbar button.platform-desktop-search > span:first-child { - width: 3.34rem; - height: 3.34rem; + width: 2.06rem; + height: 2.06rem; } .creation-home-stage .platform-desktop-rail { align-self: flex-start; - width: 10.28rem; - margin: 1.82rem auto 0; + width: 7.05rem; + margin: 1.12rem auto 0; border-color: rgba(220, 192, 168, 0.7); border-radius: 1.55rem; background: rgba(255, 250, 244, 0.54); - padding: 0.68rem; + padding: 0.38rem; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72), 0 18px 34px rgba(112, 57, 30, 0.08); } .creation-home-stage .platform-desktop-rail__button { - min-height: 4.22rem; + min-height: 2.88rem; display: flex; flex-direction: row; justify-content: flex-start; - gap: 1.06rem; - border-radius: 1.12rem; - padding: 0 0.86rem; + gap: 0.34rem; + border-radius: 0.82rem; + padding: 0 0.34rem; text-align: left; } .creation-home-stage .platform-desktop-rail__button--primary { - min-height: 4.22rem; + min-height: 2.88rem; } .creation-home-stage .platform-desktop-rail__icon-shell { - width: 3.12rem; - height: 3.12rem; + width: 2.08rem; + height: 2.08rem; flex: 0 0 auto; - border-radius: 1rem; + border-radius: 0.74rem; background: transparent; box-shadow: none; } -.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell, .creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell { background: radial-gradient(circle at 35% 22%, rgba(255, 196, 133, 0.72), transparent 42%), @@ -11317,24 +11322,20 @@ button.image-canvas-editor__reference-chip:disabled { inset 0 1px 0 rgba(255, 255, 255, 0.5); } -.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__icon, .creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon { color: #fffaf3; } .creation-home-stage .creation-home-nav-art { display: block; - width: 2.45rem; - height: 2.45rem; + width: 1.62rem; + height: 1.62rem; object-fit: contain; object-position: center; filter: drop-shadow(0 0.3rem 0.28rem rgba(112, 62, 32, 0.14)); mix-blend-mode: multiply; } -.creation-home-stage - .platform-desktop-rail__button--primary - .creation-home-nav-art, .creation-home-stage .platform-desktop-rail__button--active .creation-home-nav-art { @@ -11350,20 +11351,18 @@ button.image-canvas-editor__reference-chip:disabled { display: none; } -.creation-home-stage .platform-desktop-rail__button--primary, .creation-home-stage .platform-desktop-rail__button--active { border-color: transparent; background: transparent; box-shadow: none; } -.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__label, .creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label { color: #c15f28; } .creation-home-stage .platform-desktop-rail__label { - font-size: 0.88rem; + font-size: 0.78rem; font-weight: 820; letter-spacing: 0; } diff --git a/src/index.test.ts b/src/index.test.ts index f24f246db..ac3c42f19 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -31,6 +31,23 @@ function getCssBlock(source: string, selector: string) { } describe('index stylesheet unread dots', () => { + it('keeps creation home primary rail styling separate from active tab styling', () => { + const css = readIndexCss(); + + expect(css).not.toContain( + '.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell,\n.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell', + ); + expect(css).not.toContain( + '.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__label,\n.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label', + ); + expect(css).toContain( + '.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell', + ); + expect(css).toContain( + '.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label', + ); + }); + it('hides the outer page scrollbar without changing inner scroll helpers', () => { const css = readIndexCss(); From d19f8ba1da22119258db449bcf6cdee7420ec9b7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 15:01:44 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=B8=BA=E9=A1=B9=E7=9B=AE=E9=A1=B5?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=94=E5=9B=9E=E4=B8=BB=E7=AB=99=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在项目页头部增加返回主站按钮。 将返回动作接入平台首页路由。 补充项目页返回主站测试与文档说明。 --- ...作】创作主页与项目入口改版计划-2026-06-18.md | 6 ++- .../PlatformEntryFlowShellImpl.tsx | 9 +++- .../project/ProjectGalleryView.test.tsx | 49 +++++++++++++++---- src/components/project/ProjectGalleryView.tsx | 17 ++++++- ...gEntryFlowShell.agent.interaction.test.tsx | 16 ++++++ src/index.css | 6 +++ 6 files changed, 89 insertions(+), 14 deletions(-) diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index b448a361d..fa38fb23c 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -126,6 +126,7 @@ Tab: - 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`。 - 桌面端 `/project` 必须复用 `/creation` 的平台桌面 chrome,保留左侧完整导航栏和右侧顶部栏;`ProjectGalleryView` 只替换右侧主内容区,不能作为脱离导航的独立全屏页渲染,避免用户进入项目页后无法返回或切换页签。 - 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。 +- `/project` 项目页头部必须提供 `返回主站` 按钮,点击后切回平台首页 `/`,行为与桌面侧栏切回推荐页保持一致。 - 移动端导航列表不展示“创作”和“项目”。 - 移动端不拦截用户直接访问 `/creation` 或 `/project`。 @@ -169,8 +170,8 @@ Tab: - `appPageRoutes.test.ts`:覆盖 `/creation -> creation-home`,并确认现有 `/creation/` 不回归。 - `RpgEntryHomeView.recharge.test.tsx`:桌面显示“创作”“项目”,不显示“草稿”;移动端不显示“创作”和“项目”;我的页不再出现项目快捷入口。 - `CreationLandingView.test.tsx`:覆盖首屏主视觉、九大功能、最近项目、新建入口、查看全部、未登录隐藏最近项目、素材 Tab。 -- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`,新建项目进入 `/editor/canvas?projectid=xxx`。 -- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、重命名、删除和选择模式保持通过。 +- `PlatformEntryFlowShellImpl` 相关测试:点击“创作”进入 `/creation`,点击“项目”进入 `/project`,项目页头部“返回主站”回到 `/`,新建项目进入 `/editor/canvas?projectid=xxx`。 +- `ProjectGalleryView.test.tsx`:封面抽取后项目封面、头部“返回主站”、重命名、删除和选择模式保持通过。 验证命令: @@ -191,6 +192,7 @@ git diff --check - 登录后显示最近项目、新建项目、最多 4 个项目和“查看全部”。 - 点击新建项目进入 `/editor/canvas?projectid=xxx`。 - 桌面导航显示“项目”,不显示“草稿”。 +- `/project` 头部点击“返回主站”回到 `/`,并退出项目页主内容。 - 移动视口底部不显示“创作”和“项目”。 - “我的”页没有项目快捷入口。 - 陶泥儿精选素材瀑布流不出现 mock 素材、假作者或假泥点成本。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 9997573c7..d060c9c6e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -15253,7 +15253,14 @@ export function PlatformEntryFlowShellImpl({ ); const projectGalleryContent = ( }> - + { + platformBootstrap.setPlatformTab('home'); + setSelectionStage('platform'); + pushAppHistoryPath('/'); + }} + /> ); const handlePlatformHomeTabChange = useCallback( diff --git a/src/components/project/ProjectGalleryView.test.tsx b/src/components/project/ProjectGalleryView.test.tsx index 35e4c0e52..b1ebc5ff6 100644 --- a/src/components/project/ProjectGalleryView.test.tsx +++ b/src/components/project/ProjectGalleryView.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { ContextType } from 'react'; +import type { ComponentProps, ContextType } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; @@ -72,6 +72,18 @@ const projectItems = [ }, ]; +function renderProjectGalleryView( + props: Partial> = {}, +) { + return render( + , + ); +} + describe('ProjectGalleryView', () => { afterEach(() => { listEditorProjectsMock.mockReset(); @@ -85,7 +97,7 @@ describe('ProjectGalleryView', () => { listEditorProjectsMock.mockResolvedValueOnce(projectItems); const user = userEvent.setup(); - render(); + renderProjectGalleryView({ onOpenProject }); await screen.findByText('角色设定板'); await user.click(screen.getByRole('button', { name: '打开项目角色设定板' })); @@ -93,6 +105,19 @@ describe('ProjectGalleryView', () => { expect(onOpenProject).toHaveBeenCalledWith('editor-project-1'); }); + it('returns to the main site from the project header', async () => { + const onReturnToMainSite = vi.fn(); + listEditorProjectsMock.mockResolvedValueOnce(projectItems); + const user = userEvent.setup(); + + renderProjectGalleryView({ onReturnToMainSite }); + + await screen.findByText('角色设定板'); + await user.click(screen.getByRole('button', { name: '返回主站' })); + + expect(onReturnToMainSite).toHaveBeenCalledTimes(1); + }); + it('uses canvas-center layer composition as the project cover', async () => { listEditorProjectsMock.mockResolvedValueOnce([ { @@ -143,7 +168,7 @@ describe('ProjectGalleryView', () => { }, ]); - render(); + renderProjectGalleryView(); await screen.findByText('封面项目'); const cover = document.querySelector('.project-gallery__canvas-cover'); @@ -172,7 +197,10 @@ describe('ProjectGalleryView', () => { render( - + , ); @@ -191,7 +219,10 @@ describe('ProjectGalleryView', () => { render( - + , ); @@ -205,7 +236,7 @@ describe('ProjectGalleryView', () => { it('renders project loading errors through the shared status message', async () => { listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败')); - render(); + renderProjectGalleryView(); const alert = await screen.findByRole('alert'); @@ -223,7 +254,7 @@ describe('ProjectGalleryView', () => { deleteEditorProjectMock.mockResolvedValueOnce('editor-project-2'); const user = userEvent.setup(); - render(); + renderProjectGalleryView(); await screen.findByText('角色设定板'); await user.click(screen.getByRole('button', { name: '打开项目角色设定板菜单' })); @@ -252,7 +283,7 @@ describe('ProjectGalleryView', () => { deleteEditorProjectMock.mockResolvedValue('deleted'); const user = userEvent.setup(); - render(); + renderProjectGalleryView(); await screen.findByText('角色设定板'); await user.click(screen.getByRole('button', { name: '选择' })); @@ -279,7 +310,7 @@ describe('ProjectGalleryView', () => { }); const user = userEvent.setup(); - render(); + renderProjectGalleryView({ onOpenProject }); const newProjectButton = await screen.findByRole('button', { name: '新建项目', diff --git a/src/components/project/ProjectGalleryView.tsx b/src/components/project/ProjectGalleryView.tsx index b54d4c902..1a29fdf55 100644 --- a/src/components/project/ProjectGalleryView.tsx +++ b/src/components/project/ProjectGalleryView.tsx @@ -1,4 +1,5 @@ import { + ArrowLeft, Check, CheckSquare, MoreHorizontal, @@ -34,6 +35,7 @@ import { ProjectCanvasCover } from './ProjectCanvasCover'; type ProjectGalleryViewProps = { onOpenProject: (projectId: string) => void; + onReturnToMainSite: () => void; }; type RenameDraft = { @@ -58,7 +60,10 @@ function formatProjectUpdatedAt(value: string) { }).format(date); } -export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { +export function ProjectGalleryView({ + onOpenProject, + onReturnToMainSite, +}: ProjectGalleryViewProps) { const authUi = useAuthUi(); const [projects, setProjects] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -298,11 +303,19 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) { return (
-
+

项目

{projects.length} 个画布项目
+ + + 返回主站 + { ); }); +test('project page header can return to main site', async () => { + const user = userEvent.setup(); + mockDesktopPlatformLayout(); + window.history.replaceState(null, '', '/project'); + render(); + + expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '返回主站' })); + + await waitFor(() => { + expect(screen.queryByRole('main', { name: '项目' })).toBeNull(); + }); + expect(window.location.pathname).toBe('/'); +}); + test('direct mobile creation home keeps landing content without mobile nav entry', async () => { window.history.replaceState(null, '', '/creation'); const { container } = render(); diff --git a/src/index.css b/src/index.css index f1a780144..d8b891884 100644 --- a/src/index.css +++ b/src/index.css @@ -3001,6 +3001,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { padding: 0 1.5rem; } +.project-gallery__heading { + min-width: 0; +} + .project-gallery__header h1 { margin: 0; color: #111827; @@ -3019,6 +3023,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .project-gallery__header-actions { display: flex; align-items: center; + justify-content: flex-end; + flex-wrap: wrap; gap: 0.6rem; } From c206ca7c336ad56c83cc652d392d8c2ad1952259 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 22 Jun 2026 15:09:11 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E7=BB=BF=E5=B9=95?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E9=A2=9C=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一绿幕生成提示词使用 #00FF00 / RGB(0,255,0) 补充暗绿绿幕去背兜底并覆盖聚焦测试 同步玩法、后端与项目记忆文档口径 --- ...玩法创作】敲木鱼玩法模板PRD-2026-05-20.md | 10 +-- .../shared-memory/decision-log.md | 7 +- docs/project-memory/shared-memory/pitfalls.md | 10 +-- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 6 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 8 +-- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 2 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 6 +- packages/shared/src/prompts/qwenSprite.ts | 6 +- ...ot-fused-reference-candidate-v2.prompt.txt | 2 +- ...ot-fused-reference-candidate-v3.prompt.txt | 2 +- ...ot-fused-reference-candidate-v4.prompt.txt | 2 +- ...ot-fused-reference-candidate-v5.prompt.txt | 2 +- .../pot-fused-reference-candidate.prompt.txt | 2 +- scripts/export-match3d-resource-pipeline.mjs | 4 +- .../src/character_animation_assets.rs | 4 +- .../api-server/src/character_visual_assets.rs | 2 +- .../crates/api-server/src/editor_project.rs | 47 ++++++++++-- server-rs/crates/api-server/src/jump_hop.rs | 8 +-- .../api-server/src/match3d/item_assets.rs | 2 +- .../crates/api-server/src/match3d/tests.rs | 2 +- .../crates/api-server/src/match3d/works.rs | 2 +- .../crates/api-server/src/prompt/big_fish.rs | 2 +- .../src/prompt/character_animation.rs | 12 ++-- .../api-server/src/prompt/character_visual.rs | 2 +- server-rs/crates/api-server/src/puzzle.rs | 2 +- .../crates/api-server/src/puzzle/tests.rs | 2 +- .../crates/api-server/src/wooden_fish.rs | 7 +- .../src/generated_asset_sheets/alpha.rs | 71 +++++++++++++------ .../src/generated_asset_sheets/color.rs | 24 +++++++ .../src/generated_asset_sheets/prompt.rs | 2 +- .../tests/generated_asset_sheets.rs | 29 ++++++++ 31 files changed, 203 insertions(+), 86 deletions(-) diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md index 218b02f06..cec5df42a 100644 --- a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md +++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md @@ -151,15 +151,15 @@ WF-* 1. 调用 VectorEngine `/v1/images/edits`,模型固定为 `gpt-image-2`; 2. multipart 参考图固定包含默认木鱼图 `/wooden-fish/default-hit-object.png`,作为基础结构和画风参考; 3. 若用户上传参考图,该图只作为新主题参考追加到同一次 image2 edits 请求,不直接进入运行态; -4. 尺寸固定 `1:1`,必须输出绿色背景主体图(纯绿色绿幕),背景为单一纯绿色 `#00FF00`,并显式禁止黑底、白底、棋盘格、纸板底或任何其它实底背景; +4. 尺寸固定 `1:1`,必须输出单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,并显式禁止黑底、白底、棋盘格、纸板底或任何其它实底背景; 5. 提示词严格使用: ```text -生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。尺寸1:1,先输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,主体边缘必须干净,不要直接输出透明底。随后由服务端对绿色背景主体图做抠图去除绿色背景。最终结果只保留单个敲击物图案,禁止黑底、白底、棋盘格、纸板底或任何实底背景;主体本身不要使用与绿幕接近的纯绿色,若新主题天然包含绿色,请改用偏深、偏黄或偏蓝的绿色并与绿幕清晰区分。 +生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。尺寸1:1,先输出单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景主体图,背景必须严格使用 #00FF00 / RGB(0,255,0) 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,主体边缘必须干净,不要直接输出透明底。随后由服务端对绿色背景主体图做抠图去除绿色背景。最终结果只保留单个敲击物图案,禁止黑底、白底、棋盘格、纸板底或任何实底背景;主体本身不要使用与绿幕接近的纯绿色,若新主题天然包含绿色,请改用偏深、偏黄或偏蓝的绿色并与绿幕清晰区分。 新主题为:(用户提供参考图或用户输入关键词) ``` -敲击物图案落盘前,`api-server` 必须只对第一步生成的纯绿色绿幕背景执行去绿处理,把绿色背景转成真实透明 alpha PNG;不得对黑底、白底或其它未知实底执行泛抠图,避免误伤玉米等主体像素。去绿处理必须保留主体内部深色结构和主题细节。 +敲击物图案落盘前,`api-server` 必须只对第一步生成的单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景执行去绿处理,把绿色背景转成真实透明 alpha PNG;不得对黑底、白底或其它未知实底执行泛抠图,避免误伤玉米等主体像素。去绿处理必须保留主体内部深色结构和主题细节。 背景环境图生成流程固定为: @@ -178,12 +178,12 @@ WF-* 1. 调用 VectorEngine `/v1/images/edits`,模型固定为 `gpt-image-2`; 2. multipart 参考图固定包含第一步去除绿色背景后的敲击物主体图,以及第二步生成的背景环境图; -3. 尺寸固定 `1:1`,必须输出绿色背景主体图(纯绿色绿幕),后端落库前执行同一套去绿背景处理; +3. 尺寸固定 `1:1`,必须输出单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,后端落库前执行同一套去绿背景处理; 4. 按主题、画风、材质和配色生成左上角返回按钮图,但参考图只用于约束圆形底色和中央左箭头的颜色搭配,不得借鉴复杂造型、花纹、浮雕边、异形外框或装饰图案;按钮必须始终是标准圆形,主体视觉尺寸比当前模板再放大约 50%,圆形外沿必须有与主题色搭配的干净外描边,中央只保留单个清晰左箭头或返回箭头,不得包含文字、数字、水印、额外 UI 面板、木槌或敲击道具; 5. 提示词严格使用: ```text -生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,按钮主体在画布中的视觉尺寸比当前模板再放大约 50%,圆心居中,圆形外沿加一圈和主题色搭配的干净外描边,让它更像一个按钮,但仍然只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1,输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。 +生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,按钮主体在画布中的视觉尺寸比当前模板再放大约 50%,圆心居中,圆形外沿加一圈和主题色搭配的干净外描边,让它更像一个按钮,但仍然只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1,输出单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景主体图,背景必须严格使用 #00FF00 / RGB(0,255,0) 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。 主题为:(用户提供参考图或用户输入关键词) ``` diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 416adf545..fa3a5a343 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1210,7 +1210,7 @@ ## 2026-05-22 敲木鱼图片创作采用三图 image2 链路 - 背景:敲木鱼自定义题材只生成中央敲击物时,运行态缺少与新主题匹配的竖屏背景和主题化返回按钮;若直接让背景 prompt 自由发挥,又容易把敲击物或木槌画进背景里。 -- 决策:敲木鱼 `compile-draft` / `regenerate-hit-object` 图片链路固定为三步 image2 edits。第一步调用 VectorEngine `/v1/images/edits` + `gpt-image-2`,以默认木鱼图作为结构和画风参考,用户上传参考图只作为同次请求的新主题参考,结合用户题材关键词或参考图主题生成 `1:1` 绿色背景主体图;`api-server` 先对这张绿幕图执行去绿背景处理并写回 `hitObjectAsset`。第二步必须以第一步抠图完成后的透明敲击物图作为参考,结合用户原始题材生成 `9:16` 背景环境图并写回 `backgroundAsset`,避免背景图继承绿幕或纯绿色画布。第三步必须以去绿后的敲击物主体图和背景环境图为参考,生成 `1:1` 绿色背景返回按钮图,服务端去绿后写回 `backButtonAsset`。三步 prompt 使用 PRD 中固定隐藏关键词,不追加额外 negative prompt;返回按钮只允许参考图约束圆形底色和箭头配色,不允许继承复杂造型、花纹、浮雕边、异形外框或装饰图案,主体视觉尺寸比当前模板再放大约 50%,并带主题色外描边;背景图不得包含敲击物本体或木槌互动物品,返回按钮图不得包含文字、数字、水印或额外 UI 面板。 +- 决策:敲木鱼 `compile-draft` / `regenerate-hit-object` 图片链路固定为三步 image2 edits。第一步调用 VectorEngine `/v1/images/edits` + `gpt-image-2`,以默认木鱼图作为结构和画风参考,用户上传参考图只作为同次请求的新主题参考,结合用户题材关键词或参考图主题生成 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图;`api-server` 先对这张绿幕图执行去绿背景处理并写回 `hitObjectAsset`。第二步必须以第一步抠图完成后的透明敲击物图作为参考,结合用户原始题材生成 `9:16` 背景环境图并写回 `backgroundAsset`,避免背景图继承绿幕或纯绿色画布。第三步必须以去绿后的敲击物主体图和背景环境图为参考,生成 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景返回按钮图,服务端去绿后写回 `backButtonAsset`。三步 prompt 使用 PRD 中固定隐藏关键词,不追加额外 negative prompt;返回按钮只允许参考图约束圆形底色和箭头配色,不允许继承复杂造型、花纹、浮雕边、异形外框或装饰图案,主体视觉尺寸比当前模板再放大约 50%,并带主题色外描边;背景图不得包含敲击物本体或木槌互动物品,返回按钮图不得包含文字、数字、水印或额外 UI 面板。 - 影响范围:`api-server` 木鱼图片生成编排、`wooden_fish_work_profile.background_asset_json`、`wooden_fish_work_profile.back_button_asset_json`、shared contracts、前端结果页 / 运行态背景与返回按钮展示、敲木鱼 PRD 和平台链路文档。 - 验证方式:执行 `cargo test -p api-server wooden_fish --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-client wooden_fish --manifest-path server-rs/Cargo.toml`、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run typecheck`。 - 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1300,7 +1300,7 @@ ## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图 - 背景:旧抓大鹅素材链路按物品 5x5 sheet、纯背景和独立容器图分开生产,难以保证背景、UI、容器和物品风格一致,也让结果页继续暴露背景 / 容器重生成入口。 -- 决策:抓大鹅草稿生成先用 `gpt-image-2` 无参考图生成竖屏 `9:16` 完整关卡画面;关卡画面完成后,以它作为参考并发生成三张可运行资产:`1K 1:1` UI spritesheet、`1K 9:16` 关卡背景图、`2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都固定要求纯绿色绿幕背景,后端上传 OSS 前扣成真实透明 PNG。物品 spritesheet 固定 `10*10`,每行两种物品、每种五个形态。运行态和编辑器都按 alpha 连通域矩形检测解析 UI 和物品图集,不按固定像素坐标切图。 +- 决策:抓大鹅草稿生成先用 `gpt-image-2` 无参考图生成竖屏 `9:16` 完整关卡画面;关卡画面完成后,以它作为参考并发生成三张可运行资产:`1K 1:1` UI spritesheet、`1K 9:16` 关卡背景图、`2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前扣成真实透明 PNG。物品 spritesheet 固定 `10*10`,每行两种物品、每种五个形态。运行态和编辑器都按 alpha 连通域矩形检测解析 UI 和物品图集,不按固定像素坐标切图。 - 兼容:新增字段继续存入现有 `generatedItemAssets[].backgroundAsset` / `generatedBackgroundAsset` JSON,不新增 SpacetimeDB schema 字段。历史 `containerImage*` 字段只作兼容;如果它与 `uiSpritesheetImage*` 同源,不得再作为运行态中心容器图。 - 影响范围:`server-rs/crates/api-server/src/match3d/*`、`server-rs/crates/shared-contracts/src/match3d_*`、`packages/shared/src/contracts/match3dWorks.ts`、`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/services/match3dSpritesheetParser.ts`。 - 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts`、`npm run typecheck`、`npm run check:encoding`。 @@ -2588,5 +2588,6 @@ - 背景:画板编辑器里的 `生成角色形象` 属于编辑器图片生成链路,用户要求把“人物抠图”改成通用抠图方法,不再与 RPG / 资产工坊角色主图专用后处理绑定。 - 决策:仅 `/api/editor/images/generations` 中 `kind = "character"` 的编辑器角色形象回填改用 `platform-image::generated_asset_sheets` 通用绿幕 / 近白去背能力,并开启内部镂空检测;输出仍归一为透明 PNG。`character_visual_assets::try_apply_background_alpha_to_png` 继续服务 RPG 角色主图与 Big Fish 等“角色主图口径”调用者,本轮不改变这些链路。 +- 2026-06-22 补充:所有明确设置绿幕用于后续抠图的 prompt 都必须固定写明 `#00FF00 / RGB(0,255,0)`,不能只写“纯绿色绿幕”或“接近 #00FF00”;编辑器角色图通用抠图额外开启暗绿 / 灰绿绿幕背景识别,只作为生成模型偏离标准亮绿时的兜底。该宽松识别只参与从画布边缘连通扩散出的背景清理,不参与全图断开绿色区域删除,避免误伤角色衣物或纹理。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 -- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_character_image_general_cutout`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 +- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_character_image_general_cutout`、`cargo test -p platform-image --manifest-path server-rs/Cargo.toml generated_asset_sheet_muted_green_alpha_requires_explicit_option`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 627dedb8c..774f9b39f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -619,7 +619,7 @@ - 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。 - 原因:前端解析依赖 alpha 连通域检测,透明背景是前提;但生图模型收到“透明背景 spritesheet”提示后仍可能输出带实景背景或伪透明棋盘格的普通不透明 PNG,OSS 中保存的图没有真实 alpha。 -- 处理:UI spritesheet 提示词应要求统一纯绿色绿幕背景,而不是让模型直接产透明背景;后端在上传 OSS 前复用 `generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(...)` 把绿幕扣成真实透明 PNG,再把透明图写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`。 +- 处理:UI spritesheet 提示词应要求统一单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,而不是让模型直接产透明背景;后端在上传 OSS 前复用 `generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha(...)` 把绿幕扣成真实透明 PNG,再把透明图写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`。 - 验证:`cargo test -p api-server puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server puzzle_level_scene_spritesheet_and_background_requests_use_references --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d_derived_asset_prompts_match_three_sheet_pipeline --manifest-path server-rs\Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/puzzle/generation.rs`、`server-rs/crates/api-server/src/match3d/works.rs`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -627,7 +627,7 @@ - 现象:苹果等主题试玩时,中央敲击物图带明显黑底;背景图中央还可能出现苹果主体,或背景环境图偶发变成纯绿色底,和“中央只叠加 hitObjectAsset”的运行态设定冲突。 - 原因:gpt-image-2 对“透明底”和“背景只做外围氛围”的遵循不稳定。若 hit object 直接入库,黑底会被当成真实像素展示;若背景 prompt 只有软描述,模型会把主题主体画进中央。第一步为了去背刻意要求绿幕图时,如果第二步参考图或 prompt 没有切断绿幕语义,背景图也可能继承纯绿色画布。 -- 处理:敲木鱼 hit object prompt 固定要求先输出 `1:1` 绿色背景主体图(纯绿色绿幕、单一 `#00FF00` 背景),再由 `api-server` 只对绿幕背景做去绿透明化;不要回到黑底 / 白底 / 透明底 prompt 后再做泛抠图。背景生成必须使用第一步抠图完成后的透明图作为参考图,并在 prompt 中显式禁止继承绿色底色、绿幕底色或纯绿色画布;背景 prompt 还要固定要求中央 40% 主体预留区干净,禁止主题主体、局部特写、轮廓影子、重复元素和主题碎片,只允许外围氛围。不要在背景 prompt 写“木鱼预设在屏幕中央位置”或类似中心主体正向描述,运行态敲击物只能由前端叠放。 +- 处理:敲木鱼 hit object prompt 固定要求先输出 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,再由 `api-server` 只对绿幕背景做去绿透明化;不要回到黑底 / 白底 / 透明底 prompt 后再做泛抠图。背景生成必须使用第一步抠图完成后的透明图作为参考图,并在 prompt 中显式禁止继承绿色底色、绿幕底色或纯绿色画布;背景 prompt 还要固定要求中央 40% 主体预留区干净,禁止主题主体、局部特写、轮廓影子、重复元素和主题碎片,只允许外围氛围。不要在背景 prompt 写“木鱼预设在屏幕中央位置”或类似中心主体正向描述,运行态敲击物只能由前端叠放。 - 验证:`cargo test -p api-server wooden_fish --manifest-path server-rs\Cargo.toml`,并用花朵 / 苹果 / 玉米主题跑试玩图确认绿幕被去除、主体未被抠除、背景中央不出现主题主体,背景环境图不再出现纯绿色底。 - 关联:`server-rs/crates/api-server/src/wooden_fish.rs`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 @@ -1016,11 +1016,11 @@ - 现象:点击生成抓大鹅草稿后,页面只提示“服务暂不可用”,或者本地 `npm run dev:api-server` 看似启动但生成接口不可用。 - 原因:配置缺失类错误通常在后端 `error.details.reason` 中给出具体缺项,前端如果只读 `details.message` 会吞掉原因;本地只配置 `ALIYUN_OSS_BUCKET` / `ALIYUN_OSS_ENDPOINT` 时,旧逻辑还会在启动期构造空 AccessKey 的 OSS 客户端并失败。抓大鹅新链路仍是 2D 生图切割,不需要也不应回退 Rodin/GLB。 -- 处理:前端 API 错误展示优先读取 `details.reason`,再读取 `details.message`,避免底层 `error sending request` 覆盖真正可操作的配置或网络原因;`api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 UI spritesheet 和物品 spritesheet 的提示词必须要求纯绿色绿幕背景,后端上传 OSS 前统一扣成透明 PNG,避免运行态 alpha 连通域解析失败。 +- 处理:前端 API 错误展示优先读取 `details.reason`,再读取 `details.message`,避免底层 `error sending request` 覆盖真正可操作的配置或网络原因;`api-server` 只有在 OSS 四件套齐全时初始化 OSS 客户端,部分缺失只记 warning 并让具体 generated 上传/换签接口返回 `OSS 未完成环境变量配置`。抓大鹅素材、封面和背景生成在调用 VectorEngine 前先预检 OSS,并通过 `details.missingEnv` 列出缺项;真实生成需补齐 `VECTOR_ENGINE_BASE_URL`、`VECTOR_ENGINE_API_KEY` 和完整 `ALIYUN_OSS_*` 四件套。抓大鹅 UI spritesheet 和物品 spritesheet 的提示词必须要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前统一扣成透明 PNG,避免运行态 alpha 连通域解析失败。 - 验证:`npm run test -- src/services/apiClient.test.ts` 覆盖 `details.reason`;`cargo test -p api-server state --manifest-path server-rs/Cargo.toml` 覆盖半配置 OSS 不阻断启动;`npm run dev:api-server` 后按实际 `GENARRATIVE_API_PORT` 请求 `/healthz`,不要默认打 `3100`。 - 关联:`packages/shared/src/http.ts`、`server-rs/crates/api-server/src/state.rs`、`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`、`docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md`。 -2026-05-22 补充:抓大鹅“物品 spritesheet”不再按旧 Gemini `generateContent` / `5*5` sheet 路径排查;当前链路先用 `gpt-image-2` 无参考图生成 `9:16` 关卡整图,再以该关卡整图作为 multipart `image` 参考并发编辑生成 `1K 1:1` UI spritesheet、`1K 9:16` 背景图和 `2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都要求纯绿色绿幕背景,上传 OSS 前通过后端透明化处理写入真实 alpha PNG。 +2026-05-22 补充:抓大鹅“物品 spritesheet”不再按旧 Gemini `generateContent` / `5*5` sheet 路径排查;当前链路先用 `gpt-image-2` 无参考图生成 `9:16` 关卡整图,再以该关卡整图作为 multipart `image` 参考并发编辑生成 `1K 1:1` UI spritesheet、`1K 9:16` 背景图和 `2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,上传 OSS 前通过后端透明化处理写入真实 alpha PNG。 ## 抓大鹅发布按钮要先开发布面板,封面编辑收口到发布面板内 @@ -1698,7 +1698,7 @@ - 现象:修改抓大鹅素材时容易沿用旧 Rodin/GLB 方案,导致新草稿生成耗时变长、进度停在模型阶段,或运行态等待不存在的 GLB。 - 原因:仓库里保留了 Hyper3D 通用代理和历史模型字段,旧文档也曾要求草稿阶段同步生成 GLB。当前产品口径已经改为 2D 多视角素材。 -- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个形态,单张 `2K 1:1` 物品 spritesheet 固定 `10*10`,每行承载两种物品、每种五个形态,单张最多承载 20 种物品。素材图 prompt 固定要求纯绿色绿幕背景,上传 OSS 前先把整张 spritesheet 绿幕处理为透明 alpha,再由运行态和编辑器按 alpha 连通域解析;`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]`、首图引用或可解析的物品 spritesheet。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。 +- 处理:新 `match3d_compile_draft` 与批量新增只生成 2D 图片:每个物品 5 个形态,单张 `2K 1:1` 物品 spritesheet 固定 `10*10`,每行承载两种物品、每种五个形态,单张最多承载 20 种物品。素材图 prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,上传 OSS 前先把整张 spritesheet 绿幕处理为透明 alpha,再由运行态和编辑器按 alpha 连通域解析;`generatedItemAssets[].status` 使用 `image_ready`,发布校验看 `imageViews[]`、首图引用或可解析的物品 spritesheet。`generated-models` 仅用于历史外部模型链接转存,不能作为新生产链路。 - 验证:`cargo test -p api-server match3d --manifest-path server-rs/Cargo.toml`、`npm run test -- src\services\miniGameDraftGenerationProgress.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`。 - 关联:`server-rs/crates/api-server/src/match3d.rs`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 956b80f2b..8130008e1 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -200,10 +200,10 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 -- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 -- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 +- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 +- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 -- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 +- 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物和返回按钮上传 OSS 前只做服务端绿幕去背后处理,避免泛抠图误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 - OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index ac3104e60..7b01094e4 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -139,7 +139,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 前端创作、结果页、生成页和错误提示不展示 GPT / Gemini 等具体模型名称;如需在内部保留模型路由,UI 只使用“标准模式”“创意模式”等产品化名称。 - 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。 - 拼图参考图 AI 重绘走 VectorEngine `/v1/images/edits`;无参考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,参考图由后端作为 multipart `image` part 传入编辑接口。 -- 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1`、`1k` 先生成纯绿色绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16`、`1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示。 +- 每次新建关卡生成或重新生成关卡图都必须由 `api-server` 串起当前关卡资产包:AI 重绘开启时第一段沿用草稿生成第一关的拼图主图提示词配置和模型 / 尺寸 / 参考图规则生成 `coverImageSrc/coverAssetId` 作为关卡拼图画面和结果页预览图,提示词来源同样按显式画面描述、关卡画面描述、草稿摘要顺序回退,且固定要求输出画面比例为 `1:1`;上传图且关闭 AI 重绘时跳过这一段,把上传图或历史图持久化为 `sourceType=uploaded` 的正式候选。随后用正式候选图作为参考,`9:16` 生成完整拼图游戏关卡画面并写入 `levelSceneImageSrc/levelSceneImageObjectKey`,提示词必须要求道具按钮上不要显示次数标注,且返回按钮和设置按钮旁禁止标注文字;UI spritesheet 与关卡纯背景在关卡画面完成后并发生成,spritesheet 用 `1:1`、`1k` 先生成单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景图,后端上传 OSS 前必须把绿幕扣成透明 PNG,再写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,按钮顺序固定为返回、设置、下一关、提示、原图、冻结,按钮素材自身保留对应中文文字,返回和设置按钮不得额外生成白色外圈、白底圆环或浮雕外框;纯背景用 `9:16`、`1k` 写入 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,提示词必须包含“禁止在背景中出现人像或和拼图画面中主体一致的内容”。运行态不直接使用第二段完整关卡画面,但必须持久化它用于追踪和后续再生成。结果页局部关卡生成进度按 AI 重绘开启约 270 秒、关闭 AI 重绘约 180 秒展示。 - 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。 - 结果页关卡图片生成只标记对应关卡的局部生成进度,不禁用“新增关卡”、其它关卡详情编辑和结果页导航。 - 结果页单关测试只能把完整草稿持久化,并通过 `levelId` 指定运行态起始关卡;不得把单关快照作为整份草稿调用 `updatePuzzleWork`,否则 source session 和作品 profile 的 `levels` 会被覆盖成单关,退出重进后其它关卡会丢失。 @@ -219,7 +219,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。 4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。 -图片生成链路固定为三图 image2 流程:第一步用默认木鱼图作为结构和画风参考,按用户题材关键词或参考图主题生成 `1:1` 绿色背景主体图(纯绿色绿幕),prompt 必须显式要求背景为单一纯绿色 `#00FF00` 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,且禁止黑底、白底、棋盘格和任何实底背景;后端在落库前只对这张绿幕主体图执行去绿背景处理,不做泛抠图,避免误伤玉米等主体像素。第二步必须使用第一步抠图完成后的透明图作为参考图,再用新敲击物作为主题和画风参考生成 `9:16` 背景环境图,背景图只适配主题和画风,不能包含新敲击物本体,也不能增加木槌互动物品;画面中央主体预留区必须干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围。第三步必须使用去绿后的敲击物主体图和背景环境图作为参考图生成 `1:1` 返回按钮图,返回按钮必须始终是标准圆形,主体视觉尺寸比当前模板再放大约 50%,圆形外沿必须有与主题色搭配的干净外描边,中央只保留单个左箭头,参考图只约束圆形底色和箭头配色,不得延伸到复杂造型和花纹;按钮不得出现文字、数字、水印、额外 UI 面板或木槌物品。三个资产分别写回 `hitObjectAsset`、`backgroundAsset` 与 `backButtonAsset`,并绑定到 `wooden_fish_work` 的 `hit_object` / `background` / `back_button` 槽位。运行态和结果页消费 `backgroundAsset` 做竖屏背景,中央再叠加 `hitObjectAsset`,左上角返回按钮消费 `backButtonAsset`。 +图片生成链路固定为三图 image2 流程:第一步用默认木鱼图作为结构和画风参考,按用户题材关键词或参考图主题生成 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,prompt 必须显式要求背景严格使用该固定色且平整无纹理、无渐变、无阴影、无道具,主体完整居中,且禁止黑底、白底、棋盘格和任何实底背景;后端在落库前只对这张绿幕主体图执行去绿背景处理,不做泛抠图,避免误伤玉米等主体像素。第二步必须使用第一步抠图完成后的透明图作为参考图,再用新敲击物作为主题和画风参考生成 `9:16` 背景环境图,背景图只适配主题和画风,不能包含新敲击物本体,也不能增加木槌互动物品;画面中央主体预留区必须干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围。第三步必须使用去绿后的敲击物主体图和背景环境图作为参考图生成 `1:1` 返回按钮图,返回按钮必须始终是标准圆形,主体视觉尺寸比当前模板再放大约 50%,圆形外沿必须有与主题色搭配的干净外描边,中央只保留单个左箭头,参考图只约束圆形底色和箭头配色,不得延伸到复杂造型和花纹;按钮不得出现文字、数字、水印、额外 UI 面板或木槌物品。三个资产分别写回 `hitObjectAsset`、`backgroundAsset` 与 `backButtonAsset`,并绑定到 `wooden_fish_work` 的 `hit_object` / `background` / `back_button` 槽位。运行态和结果页消费 `backgroundAsset` 做竖屏背景,中央再叠加 `hitObjectAsset`,左上角返回按钮消费 `backButtonAsset`。 木鱼初始 `compile-draft` 是长耗时同步 action,生成页必须按上述三图 image2 链路展示进度:整理草稿、生成敲击物、生成背景环境图、生成返回按钮图、写入正式草稿。本地或供应商慢时一次 action 可能持续数分钟;前端不得把已关闭的提示词生成音效当成进度阶段,也不得在未收到 action 回包前宣称生成完成。 @@ -281,8 +281,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 2. 先写入可恢复草稿 profile,再执行文本计划、关卡整图生成、三张派生图生成、OSS 上传和素材解析;作品摘要在背景、UI spritesheet 或物品 spritesheet 未完整时下发 `generationStatus=generating`,完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。 3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。 4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。 -5. UI spritesheet 提示词固定要求按从上到下、从左到右整理纯绿色绿幕背景素材:返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮;后端上传 OSS 前必须把绿幕扣成透明 PNG。背景图提示词固定要求移除全部 UI 组件和容器内含物,完整保留容器和背景,并补全被 UI 覆盖的背景内容。 -6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;后端切 `generatedItemAssets[].imageViews[]` 时优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序,每 `5` 个区域组成一个物品的五个形态;只有识别出的区域数量不足时才回退 `10*10` 固定网格。持久化单格映射元数据仍按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 +5. UI spritesheet 提示词固定要求按从上到下、从左到右整理单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景素材:返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮;后端上传 OSS 前必须把绿幕扣成透明 PNG。背景图提示词固定要求移除全部 UI 组件和容器内含物,完整保留容器和背景,并补全被 UI 覆盖的背景内容。 +6. 物品 spritesheet 固定 `10行*10列`、统一单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;后端切 `generatedItemAssets[].imageViews[]` 时优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序,每 `5` 个区域组成一个物品的五个形态;只有识别出的区域数量不足时才回退 `10*10` 固定网格。持久化单格映射元数据仍按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图先把识别出的透明素材矩形按行聚类,再在每一行内按横向 `x` 坐标排序,最后按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;不能只按全局 `y` 坐标排序,否则同一行素材上下略有错位时会把方格和底部道具按钮顺序打乱。物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 6e47d699a..9d43ebb96 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -49,7 +49,7 @@ - Prompt 固定为: ```text -参考图1的图标规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet: +参考图1的图标规范,单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet: <素材描述按中文顿号拼接> ``` diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 728862705..fb37da587 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -60,11 +60,11 @@ - `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架: ```text -基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 +基于图1的角色美术视觉规范指导生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 角色设定:<用户输入的角色设定> ``` -- 角色图生成完成后,编辑器后端必须走通用抠图方法:复用 `platform-image::generated_asset_sheets` 的绿幕 / 近白背景去背能力,并开启内部绿幕 / 近白镂空检测。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 +- 角色图生成完成后,编辑器后端必须走通用抠图方法:复用 `platform-image::generated_asset_sheets` 的绿幕 / 近白背景去背能力,并开启内部绿幕 / 近白镂空检测。角色图 prompt 固定要求标准 `#00FF00 / RGB(0,255,0)` 绿幕;后端路径仍兼容生成模型把标准绿幕压成暗绿 / 灰绿背景的情况,但这类宽松识别只用于从画布边缘连通扩散出的背景,不作为全图断开绿色区域删除依据。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理;输出仍统一为透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`。接口回包仍返回透明 PNG Data URL 供画板立即显示,同时返回 `objectKey` / `assetObjectId`,前端创建图层和画板资源记录时必须保存这两个字段。 - 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;普通图片图层重绘仍保持 `kind: "quick-edit"`。 ## 生成规范参考图 @@ -152,7 +152,7 @@ - 后端 prompt 使用以下固定骨架,并把面板输入追加到 `动作描述:` 后: ```text -生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 +生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。 动作描述: <用户输入的动画描述> ``` diff --git a/packages/shared/src/prompts/qwenSprite.ts b/packages/shared/src/prompts/qwenSprite.ts index 68b5ff832..560a22b45 100644 --- a/packages/shared/src/prompts/qwenSprite.ts +++ b/packages/shared/src/prompts/qwenSprite.ts @@ -134,7 +134,7 @@ export function buildMasterPrompt(characterBrief: string) { '单人,2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。', `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, - `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, + `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,形体清晰,服装层次明确,优先体现像素动作角色感而不是软萌 Q版插画感,便于后续连续动作生成。`, '请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,只有当文字设定明确要求非人结构时,才改为对应非人身体。', '主题词默认只作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上,不要把主题词自动扩写成背景建筑、自然场景、漂浮装饰或额外环境物件。', @@ -163,11 +163,11 @@ export function buildVideoActionPrompt(options: { `角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,不要退化成完全 90 度纯右视图。`, `视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。`, `主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。`, - `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, + `画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素或其他角色以外的场景内容。`, `风格要求:${BODY_RATIO_TEXT} ${PIXEL_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,优先保证像素动作角色感,不要退化成只剩 Q 版比例的普通插画,便于后续连续动作生成。`, `动作结构:${options.actionTemplate.sequenceLines.join(';')}。结尾要求:${options.actionTemplate.ending}。`, options.useChromaKey - ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' + ? '背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抽帧与抠像。' : '背景简洁纯净,无复杂场景。', `动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`, `角色设定:${options.characterBrief.trim()}`, diff --git a/public/match3d-background-references/pot-fused-reference-candidate-v2.prompt.txt b/public/match3d-background-references/pot-fused-reference-candidate-v2.prompt.txt index f6e2cd4fb..2e610fc4d 100644 --- a/public/match3d-background-references/pot-fused-reference-candidate-v2.prompt.txt +++ b/public/match3d-background-references/pot-fused-reference-candidate-v2.prompt.txt @@ -1 +1 @@ -生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只生成一个完整容器,画面中绝对不能出现第二个容器、备选方案、上下排列、多个版本或任何物品。参考用户给出的木质圆形容器图片的外边缘面积和视角:容器外轮廓接近画布四边,宽度约占画布 88%-92%,高度约占画布 84%-90%,主体中心略偏下;只留很少外侧空间。容器类型:游戏 UI 用的圆形浅盘/浅碗竞技容器,外圈厚实,前沿较高且可见,后沿也能看到层次;整体像一只浅而宽的木质或竹编圆形容器,而不是深锅。视角:轻俯视 3/4 上方视角,必须能看到容器外壁、厚实前沿、内壁层次和横向椭圆形内口;不能画成正俯视扁圆盘、不能画成侧视碗、不能画成小托盘。内部必须是干净空的浅盘空间:只有平滑的金色木质或竹质内底和内壁层次,不要内部图案、不要发光球、不要食物、不要水、不要场景、不要物品槽、不要棋盘。风格:高品质休闲游戏 2D UI 资产,清晰轮廓,柔和体积光影,边缘干净,可作为抓大鹅运行态中心棋盘覆盖层。背景必须是完全平整统一的纯绿色 #00FF00 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。禁止文字、水印、按钮、倒计时、分数、HUD、教程浮层、菜单、角色、手、动物、logo、边框、网格线。 \ No newline at end of file +生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只生成一个完整容器,画面中绝对不能出现第二个容器、备选方案、上下排列、多个版本或任何物品。参考用户给出的木质圆形容器图片的外边缘面积和视角:容器外轮廓接近画布四边,宽度约占画布 88%-92%,高度约占画布 84%-90%,主体中心略偏下;只留很少外侧空间。容器类型:游戏 UI 用的圆形浅盘/浅碗竞技容器,外圈厚实,前沿较高且可见,后沿也能看到层次;整体像一只浅而宽的木质或竹编圆形容器,而不是深锅。视角:轻俯视 3/4 上方视角,必须能看到容器外壁、厚实前沿、内壁层次和横向椭圆形内口;不能画成正俯视扁圆盘、不能画成侧视碗、不能画成小托盘。内部必须是干净空的浅盘空间:只有平滑的金色木质或竹质内底和内壁层次,不要内部图案、不要发光球、不要食物、不要水、不要场景、不要物品槽、不要棋盘。风格:高品质休闲游戏 2D UI 资产,清晰轮廓,柔和体积光影,边缘干净,可作为抓大鹅运行态中心棋盘覆盖层。背景必须是完全平整统一的纯绿色 #00FF00 / RGB(0,255,0) 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。禁止文字、水印、按钮、倒计时、分数、HUD、教程浮层、菜单、角色、手、动物、logo、边框、网格线。 \ No newline at end of file diff --git a/public/match3d-background-references/pot-fused-reference-candidate-v3.prompt.txt b/public/match3d-background-references/pot-fused-reference-candidate-v3.prompt.txt index dc0a0737e..f8f6d7423 100644 --- a/public/match3d-background-references/pot-fused-reference-candidate-v3.prompt.txt +++ b/public/match3d-background-references/pot-fused-reference-candidate-v3.prompt.txt @@ -1 +1 @@ -生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只生成一个完整容器,不能出现第二个容器、多个方案、物品、角色、手、文字或任何 UI 控件。视图必须严格贴近用户红线标注的容器视角:正前方轻俯视,不要明显左右 3/4 偏转;容器外轮廓接近圆形大桶,主体宽度占画布 94%-98%,高度占画布 92%-98%,几乎顶到画布四边。开口面积必须很大:上方开口是一个巨大的横向椭圆,外开口椭圆宽度占容器外宽的 90%-94%,开口椭圆高度占容器外高的 62%-70%;开口上缘靠近画布顶部,开口下缘要落到画布垂直方向约 66%-72% 的位置。容器不是浅盘。必须是有明显深度的圆形木桶/竹桶式竞技容器:后壁高,左右内壁厚,前壁从开口下缘到容器底部有明显高度,能看到厚实前沿和外壁纵深。内部空间必须干净可放 2D 物品:只保留平滑的金色木质内底、内壁层次和柔和阴影;不要内部图案、不要发光球、不要食物、不要水、不要格子、不要物品槽、不要棋盘。外沿和内沿要像红线圈出的结构:大而连续的圆桶边缘,前沿厚实,后沿在顶部形成弧形,内部底面是低处的横向椭圆平面。风格:高品质休闲游戏 2D UI 资产,暖金木质或竹质,清晰轮廓,柔和体积光影,适合覆盖在移动端抓大鹅中心棋盘。背景必须是完全平整统一的纯绿色 #00FF00 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。强制避免:浅盘、小托盘、扁圆盘、低矮盘子、侧视碗、斜向 3/4 侧视、上下两个容器、多个版本、开口太小、开口太扁、画布大量留白、红线、标注线、文字、水印、logo。 \ No newline at end of file +生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只生成一个完整容器,不能出现第二个容器、多个方案、物品、角色、手、文字或任何 UI 控件。视图必须严格贴近用户红线标注的容器视角:正前方轻俯视,不要明显左右 3/4 偏转;容器外轮廓接近圆形大桶,主体宽度占画布 94%-98%,高度占画布 92%-98%,几乎顶到画布四边。开口面积必须很大:上方开口是一个巨大的横向椭圆,外开口椭圆宽度占容器外宽的 90%-94%,开口椭圆高度占容器外高的 62%-70%;开口上缘靠近画布顶部,开口下缘要落到画布垂直方向约 66%-72% 的位置。容器不是浅盘。必须是有明显深度的圆形木桶/竹桶式竞技容器:后壁高,左右内壁厚,前壁从开口下缘到容器底部有明显高度,能看到厚实前沿和外壁纵深。内部空间必须干净可放 2D 物品:只保留平滑的金色木质内底、内壁层次和柔和阴影;不要内部图案、不要发光球、不要食物、不要水、不要格子、不要物品槽、不要棋盘。外沿和内沿要像红线圈出的结构:大而连续的圆桶边缘,前沿厚实,后沿在顶部形成弧形,内部底面是低处的横向椭圆平面。风格:高品质休闲游戏 2D UI 资产,暖金木质或竹质,清晰轮廓,柔和体积光影,适合覆盖在移动端抓大鹅中心棋盘。背景必须是完全平整统一的纯绿色 #00FF00 / RGB(0,255,0) 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。强制避免:浅盘、小托盘、扁圆盘、低矮盘子、侧视碗、斜向 3/4 侧视、上下两个容器、多个版本、开口太小、开口太扁、画布大量留白、红线、标注线、文字、水印、logo。 \ No newline at end of file diff --git a/public/match3d-background-references/pot-fused-reference-candidate-v4.prompt.txt b/public/match3d-background-references/pot-fused-reference-candidate-v4.prompt.txt index 9c1be0ce9..9fa2f5ce5 100644 --- a/public/match3d-background-references/pot-fused-reference-candidate-v4.prompt.txt +++ b/public/match3d-background-references/pot-fused-reference-candidate-v4.prompt.txt @@ -1 +1 @@ -生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只允许一个完整容器。不要多个容器、不要备选方案、不要物品、不要角色、不要手、不要文字、不要红色标注线。视图必须贴近用户红线标注:正前方轻俯视的圆形木桶容器,yaw=0,不要左右偏转,不要斜向 3/4 侧视。外轮廓要几乎撑满画布:左边缘在 x=0-25,右边缘在 x=999-1024,顶部外沿在 y=35-70,底部桶身在 y=980-1024;画布上方和左右只留极少白边。开口面积和角度是核心:顶部开口是一个巨大的横向椭圆,外开口椭圆宽度约为画布 94%-98%,高度约为画布 72%-80%,椭圆上缘在 y=50-85,椭圆下缘在 y=790-850。容器必须是连续闭合的圆桶/竹桶,不是浅盘。前沿不能出现缺口、断口、凹口、门洞或围栏式开口;前方桶壁必须连续完整,从开口下缘一直延伸到画布底部附近。桶壁要有明显厚度和深度:后壁高、左右内壁厚、前壁厚实,能看到内壁层次、顶部厚外沿、内侧圆弧和下方桶身外壁。内部必须空且干净:只有金色木质内底和柔和阴影,底面是低处的横向椭圆平面;不要发光球、不要食物、不要水、不要纹章、不要格子、不要棋盘、不要物品槽。造型接近用户参考图外边缘:圆润木桶外形,厚实金色木质外沿,大开口,大前壁,整体像可以承载大量 2D 物品的中心竞技容器。风格:高品质休闲游戏 2D UI 资产,暖金木质或竹质,清晰轮廓,柔和体积光影,边缘干净。背景必须完全平整统一纯绿色 #00FF00 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。强制避免:浅盘、小托盘、低矮盘子、正俯视扁圆盘、侧视碗、左右斜视、前沿缺口、围栏缺口、画布大量留白、内部图案、红线、文字、水印、logo。 \ No newline at end of file +生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只允许一个完整容器。不要多个容器、不要备选方案、不要物品、不要角色、不要手、不要文字、不要红色标注线。视图必须贴近用户红线标注:正前方轻俯视的圆形木桶容器,yaw=0,不要左右偏转,不要斜向 3/4 侧视。外轮廓要几乎撑满画布:左边缘在 x=0-25,右边缘在 x=999-1024,顶部外沿在 y=35-70,底部桶身在 y=980-1024;画布上方和左右只留极少白边。开口面积和角度是核心:顶部开口是一个巨大的横向椭圆,外开口椭圆宽度约为画布 94%-98%,高度约为画布 72%-80%,椭圆上缘在 y=50-85,椭圆下缘在 y=790-850。容器必须是连续闭合的圆桶/竹桶,不是浅盘。前沿不能出现缺口、断口、凹口、门洞或围栏式开口;前方桶壁必须连续完整,从开口下缘一直延伸到画布底部附近。桶壁要有明显厚度和深度:后壁高、左右内壁厚、前壁厚实,能看到内壁层次、顶部厚外沿、内侧圆弧和下方桶身外壁。内部必须空且干净:只有金色木质内底和柔和阴影,底面是低处的横向椭圆平面;不要发光球、不要食物、不要水、不要纹章、不要格子、不要棋盘、不要物品槽。造型接近用户参考图外边缘:圆润木桶外形,厚实金色木质外沿,大开口,大前壁,整体像可以承载大量 2D 物品的中心竞技容器。风格:高品质休闲游戏 2D UI 资产,暖金木质或竹质,清晰轮廓,柔和体积光影,边缘干净。背景必须完全平整统一纯绿色 #00FF00 / RGB(0,255,0) 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。强制避免:浅盘、小托盘、低矮盘子、正俯视扁圆盘、侧视碗、左右斜视、前沿缺口、围栏缺口、画布大量留白、内部图案、红线、文字、水印、logo。 \ No newline at end of file diff --git a/public/match3d-background-references/pot-fused-reference-candidate-v5.prompt.txt b/public/match3d-background-references/pot-fused-reference-candidate-v5.prompt.txt index 9bb2e00a7..cc42ec01a 100644 --- a/public/match3d-background-references/pot-fused-reference-candidate-v5.prompt.txt +++ b/public/match3d-background-references/pot-fused-reference-candidate-v5.prompt.txt @@ -1 +1 @@ -生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只允许一个完整容器。不要多个容器、不要备选方案、不要物品、不要角色、不要手、不要文字、不要红色标注线。核心比例:开口是绝对主体,桶身只是装饰。可见桶身高度必须只有开口椭圆高度的约 1/8(12.5%),不要高桶身,不要深桶。开口必须极大:正前方轻俯视的巨大横向椭圆开口,外开口椭圆宽度占画布 94%-98%,开口椭圆高度占画布 78%-84%。开口上缘接近 y=45,开口下缘接近 y=850。桶身必须极矮:从前沿下缘到容器底部只保留一圈薄木质装饰桶身,高度约 90-110 像素;底部接近 y=960-990。整体看起来像一个超大开口的低矮木质竞技圈,不像深木桶。前方装饰边必须连续闭合,没有缺口、门洞或围栏断口。前沿是一圈厚实圆润的木质环,桶身只是这圈环下面的一小段装饰外壁。内部必须空且干净:只有平滑金色木质内底、内壁圆弧和柔和阴影;不要发光、不要食物、不要水、不要纹章、不要格子、不要棋盘、不要物品槽。视角:正前方轻俯视,yaw=0,不要左右斜向 3/4 侧视。能看到超大开口内部和很矮的前方外壁。造型:高品质休闲游戏 2D UI 资产,暖金木质或竹质,清晰轮廓,柔和体积光影,边缘干净,适合覆盖在移动端抓大鹅中心棋盘。背景必须完全平整统一纯绿色 #00FF00 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。强制避免:高桶身、深锅、深木桶、桶身高度超过开口高度 1/6、浅盘、小托盘、低矮盘子、正俯视扁圆盘、侧视碗、左右斜视、前沿缺口、画布大量留白、内部图案、红线、文字、水印、logo。 \ No newline at end of file +生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。只允许一个完整容器。不要多个容器、不要备选方案、不要物品、不要角色、不要手、不要文字、不要红色标注线。核心比例:开口是绝对主体,桶身只是装饰。可见桶身高度必须只有开口椭圆高度的约 1/8(12.5%),不要高桶身,不要深桶。开口必须极大:正前方轻俯视的巨大横向椭圆开口,外开口椭圆宽度占画布 94%-98%,开口椭圆高度占画布 78%-84%。开口上缘接近 y=45,开口下缘接近 y=850。桶身必须极矮:从前沿下缘到容器底部只保留一圈薄木质装饰桶身,高度约 90-110 像素;底部接近 y=960-990。整体看起来像一个超大开口的低矮木质竞技圈,不像深木桶。前方装饰边必须连续闭合,没有缺口、门洞或围栏断口。前沿是一圈厚实圆润的木质环,桶身只是这圈环下面的一小段装饰外壁。内部必须空且干净:只有平滑金色木质内底、内壁圆弧和柔和阴影;不要发光、不要食物、不要水、不要纹章、不要格子、不要棋盘、不要物品槽。视角:正前方轻俯视,yaw=0,不要左右斜向 3/4 侧视。能看到超大开口内部和很矮的前方外壁。造型:高品质休闲游戏 2D UI 资产,暖金木质或竹质,清晰轮廓,柔和体积光影,边缘干净,适合覆盖在移动端抓大鹅中心棋盘。背景必须完全平整统一纯绿色 #00FF00 / RGB(0,255,0) 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体不要使用 #00FF00 或接近纯绿色。强制避免:高桶身、深锅、深木桶、桶身高度超过开口高度 1/6、浅盘、小托盘、低矮盘子、正俯视扁圆盘、侧视碗、左右斜视、前沿缺口、画布大量留白、内部图案、红线、文字、水印、logo。 \ No newline at end of file diff --git a/public/match3d-background-references/pot-fused-reference-candidate.prompt.txt b/public/match3d-background-references/pot-fused-reference-candidate.prompt.txt index c64e9019d..2b5607835 100644 --- a/public/match3d-background-references/pot-fused-reference-candidate.prompt.txt +++ b/public/match3d-background-references/pot-fused-reference-candidate.prompt.txt @@ -1 +1 @@ -生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。主体只允许是一个大尺寸浅盘状或圆形竞技容器,用于移动端抓大鹅运行态中心棋盘覆盖层。构图必须严格满足:容器外轮廓接近画布四边,宽度约占画布 88%-92%,高度约占画布 84%-90%,中心在画布中心略偏下;只保留极少量外侧留白。视角必须是轻俯视 3/4 上方视角,可以看到厚实前沿、碗体外壁、内侧空间和横向椭圆形内口;不能是正俯视扁圆盘,不能是侧视碗,不能是小托盘。容器设计为题材中性、可复用的高品质休闲游戏 UI:浅米白陶瓷或浅蓝半透明玻璃质感,外沿清晰,内侧干净,可放置 2D 物品素材,边界高辨识,轻微内阴影和高光。背景必须是完全平整统一的纯绿色 #00FF00 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体绝对不要使用 #00FF00 或接近纯绿色;不要出现任何物品、角色、手、文字、水印、按钮、倒计时、分数、HUD、教程浮层、菜单、整页背景、棋盘格、网格线。 \ No newline at end of file +生成一张 1024x1024 的 1:1 抓大鹅中心容器 UI 参考图。主体只允许是一个大尺寸浅盘状或圆形竞技容器,用于移动端抓大鹅运行态中心棋盘覆盖层。构图必须严格满足:容器外轮廓接近画布四边,宽度约占画布 88%-92%,高度约占画布 84%-90%,中心在画布中心略偏下;只保留极少量外侧留白。视角必须是轻俯视 3/4 上方视角,可以看到厚实前沿、碗体外壁、内侧空间和横向椭圆形内口;不能是正俯视扁圆盘,不能是侧视碗,不能是小托盘。容器设计为题材中性、可复用的高品质休闲游戏 UI:浅米白陶瓷或浅蓝半透明玻璃质感,外沿清晰,内侧干净,可放置 2D 物品素材,边界高辨识,轻微内阴影和高光。背景必须是完全平整统一的纯绿色 #00FF00 / RGB(0,255,0) 色键背景,用于后续抠透明;背景无纹理、无渐变、无阴影、无地面、无反射。容器本体绝对不要使用 #00FF00 或接近纯绿色;不要出现任何物品、角色、手、文字、水印、按钮、倒计时、分数、HUD、教程浮层、菜单、整页背景、棋盘格、网格线。 \ No newline at end of file diff --git a/scripts/export-match3d-resource-pipeline.mjs b/scripts/export-match3d-resource-pipeline.mjs index 8b6d11261..93f7839cb 100644 --- a/scripts/export-match3d-resource-pipeline.mjs +++ b/scripts/export-match3d-resource-pipeline.mjs @@ -97,7 +97,7 @@ function buildLevelScenePrompt(theme) { } function buildUiSpritesheetPrompt() { - return '提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。'; + return '提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景spritesheet。背景必须是统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。'; } function buildBackgroundPrompt() { @@ -105,7 +105,7 @@ function buildBackgroundPrompt() { } function buildItemSpritesheetPrompt() { - return '固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品'; + return '固定生成10行*10列spritesheet图,统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景,背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品'; } function collectStringsByKey(value, targetKey, output) { diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index b258a2bb1..58aed6907 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -88,7 +88,7 @@ const FIXED_ARK_CHARACTER_VIDEO_DURATION_SECONDS: u32 = 4; const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; const EDITOR_CHARACTER_ANIMATION_MODEL: &str = "seedance2.0-fast"; const EDITOR_CHARACTER_ANIMATION_ASSET_KIND: &str = "editor_character_animation"; -const EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX: &str = "生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:"; +const EDITOR_CHARACTER_ANIMATION_PROMPT_PREFIX: &str = "生成游戏角色动画,参考图作为首帧和尾帧,画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。\n动作描述:"; const EDITOR_VIDEO_ASSET_KIND: &str = "editor_video"; const EDITOR_VIDEO_ENTITY_KIND: &str = "editor_canvas"; const EDITOR_VIDEO_SLOT: &str = "video_preview"; @@ -4932,7 +4932,7 @@ mod tests { assert!(prompt.contains("生成游戏角色动画")); assert!(prompt.contains("参考图作为首帧和尾帧")); - assert!(prompt.contains("背景固定为纯绿色绿幕")); + assert!(prompt.contains("背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕")); assert!(prompt.contains("动作描述:\n行走两步后回到站姿。")); } diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index 7b6901560..01de8aa41 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -1904,7 +1904,7 @@ mod tests { assert!(prompt.contains("潮雾港向导")); assert!(prompt.contains("右向斜侧身")); - assert!(prompt.contains("纯绿色绿幕")); + assert!(prompt.contains("单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕")); } #[test] diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d205c1f0b..d84424d48 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2271,7 +2271,7 @@ fn validate_editor_icon_spritesheet_price( fn build_editor_icon_spritesheet_prompt(icon_descriptions: &[String]) -> String { format!( - "参考图1的图标素材规范,纯绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}", + "参考图1的图标素材规范,单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景方便扣除背景,禁止出现文字,保证每个图标素材的所有内容区域是完全连通的。按照以下的素材的顺序从上到下从左到右依次生成并整理成一张spritesheet:\n\n{}", icon_descriptions.join("、") ) } @@ -2289,7 +2289,7 @@ fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool fn build_editor_character_image_prompt(role_setting: &str) -> String { vec![ - "严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为纯绿色绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), + "严格基于图1的角色美术视觉规范指导中的美术风格、角色头身比、角色朝向等特征生成游戏角色形象图。画面中心构图,角色主体完整置于画面中央,禁止镜头透视,禁止特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,禁止生成美术视觉规范、出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), format!("角色设定:{}", role_setting.trim()), ] .join("\n") @@ -2323,6 +2323,7 @@ fn prepare_editor_character_image_for_response( fn editor_general_cutout_alpha_options() -> GeneratedAssetSheetAlphaOptions { GeneratedAssetSheetAlphaOptions { + remove_muted_green_screen_background: true, detect_internal_holes: true, internal_hole_min_pixels: 16, ..GeneratedAssetSheetAlphaOptions::green_screen() @@ -2703,7 +2704,7 @@ mod tests { assert!(prompt.contains("基于图1的角色美术视觉规范指导")); assert!(prompt.contains("生成游戏角色形象图")); - assert!(prompt.contains("背景固定为纯绿色绿幕")); + assert!(prompt.contains("背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕")); assert!(prompt.contains("禁止镜头透视")); assert!(prompt.contains("角色设定:菜市场卖菜大妈")); } @@ -2738,6 +2739,44 @@ mod tests { assert_eq!(decoded.get_pixel(5, 5).0[3], 255); } + #[test] + fn editor_character_image_general_cutout_removes_muted_green_background() { + let width = 20; + let height = 20; + let mut image = + image::RgbaImage::from_pixel(width, height, image::Rgba([59, 131, 67, 255])); + for x in 0..width { + image.put_pixel(x, 0, image::Rgba([59, 81, 67, 255])); + image.put_pixel(x, height - 1, image::Rgba([63, 84, 70, 255])); + } + for y in 0..height { + image.put_pixel(0, y, image::Rgba([59, 81, 67, 255])); + image.put_pixel(width - 1, y, image::Rgba([63, 84, 70, 255])); + } + for y in 7..13 { + for x in 7..13 { + image.put_pixel(x, y, image::Rgba([188, 82, 45, 255])); + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, image::ImageFormat::Png) + .expect("test image should encode"); + + let processed = prepare_editor_character_image_for_response(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed image should decode") + .to_rgba8(); + + assert_eq!(decoded.get_pixel(0, 0).0[3], 0); + assert_eq!(decoded.get_pixel(4, 4).0[3], 0); + assert_eq!(decoded.get_pixel(10, 10).0[3], 255); + } + #[test] fn editor_character_image_general_cutout_converts_non_png_to_transparent_png() { let width = 12; @@ -3128,7 +3167,7 @@ mod tests { let prompt = build_editor_icon_spritesheet_prompt(&descriptions); assert!(prompt.contains("参考图1的图标素材规范")); - assert!(prompt.contains("纯绿幕背景方便扣除背景")); + assert!(prompt.contains("单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景方便扣除背景")); assert!(prompt.contains("返回按钮、设置按钮、下一关按钮")); } diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 8200eab26..bdd44c586 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -1037,7 +1037,7 @@ fn build_jump_hop_back_button_prompt(theme_text: &str) -> String { let theme_text = normalize_jump_hop_generation_theme_text(theme_text); format!( - "生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标,圆心居中,主体视觉尺寸占画布约72%-82%,外沿有一圈干净描边,内部只有一个居中的向左箭头;不要写“返回”文字,不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1,输出绿色背景主体图,背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 green-screen background for later alpha removal." + "生成跳一跳运行态左上角返回按钮的独立透明素材。主题关键词严格只使用“{theme_text}”,按钮的底色、材质、描边和轻微装饰跟随该主题,但必须仍然是清晰可识别的游戏 UI 返回按钮。\n按钮必须是单个标准圆形图标,圆心居中,主体视觉尺寸占画布约72%-82%,外沿有一圈干净描边,内部只有一个居中的向左箭头;不要写“返回”文字,不要数字、Logo、水印、按钮外标签或额外 UI 面板。\n允许在圆形底色里做很轻的主题材质包装,例如水果主题可用果皮色和果肉色、森林主题可用叶片色和木质描边、未来主题可用金属边和发光内环;但不要把按钮画成主题物体本身,不要继承复杂花纹、浮雕边、异形外框、贴纸堆叠或徽章装饰。\n尺寸1:1,输出单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景主体图,背景必须严格使用 #00FF00 / RGB(0,255,0) 且平整无纹理、无渐变、无阴影;按钮主体边缘干净,后续由服务端扣除绿色背景。按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请使用偏深、偏黄或偏蓝的主题绿色,并用高对比箭头颜色区分。\nEnglish guardrail: one standalone circular mobile game back button asset only, theme-styled colors/materials from \"{theme_text}\", centered left arrow only, no text, no logo, no extra UI, no complex badge, no object silhouette, solid #00FF00 / RGB(0,255,0) green-screen background for later alpha removal." ) } @@ -2081,7 +2081,7 @@ mod tests { assert!(!prompt.contains("25格造型要混排")); assert!(!prompt.contains("no simple circles")); assert!(!prompt.contains("no simple squares")); - assert!(!prompt.contains("纯绿色绿幕")); + assert!(!prompt.contains("单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕")); assert!(!prompt.contains("#00FF00")); assert!(!prompt.contains("isolated top-down")); assert!(!prompt.contains("按5行*5列")); @@ -2128,10 +2128,10 @@ mod tests { assert!(prompt.contains("单个标准圆形图标")); assert!(prompt.contains("内部只有一个居中的向左箭头")); assert!(prompt.contains("不要写“返回”文字")); - assert!(prompt.contains("背景必须是单一纯绿色 #00FF00")); + assert!(prompt.contains("背景必须严格使用 #00FF00 / RGB(0,255,0)")); assert!(prompt.contains("后续由服务端扣除绿色背景")); assert!(prompt.contains("one standalone circular mobile game back button asset only")); - assert!(prompt.contains("solid #00FF00 green-screen background")); + assert!(prompt.contains("solid #00FF00 / RGB(0,255,0) green-screen background")); } #[test] diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 5de20f2ed..fddcb3a3b 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1483,7 +1483,7 @@ pub(super) fn build_match3d_material_sheet_prompt( } pub(super) fn build_match3d_item_spritesheet_prompt() -> String { - "固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string() + "固定生成10行*10列spritesheet图,统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景,背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string() } fn match3d_material_sheet_special_prompt() -> String { diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 3f753d5a8..8e139f854 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -914,7 +914,7 @@ fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_layout() { assert!(prompt.contains("每一行包含两种物品")); assert!(prompt.contains("每种物品的五个不同形态")); assert!(prompt.contains("绿幕背景")); - assert!(prompt.contains("#00FF00")); + assert!(prompt.contains("#00FF00 / RGB(0,255,0)")); assert!(prompt.contains("严禁出现两种高相似度的物品")); } diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 05e5f35aa..fe00e7f64 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -714,7 +714,7 @@ pub(super) fn build_match3d_level_scene_generation_prompt(config: &Match3DConfig } pub(super) fn build_match3d_ui_spritesheet_prompt() -> String { - "提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string() + "提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景spritesheet。背景必须是统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string() } pub(super) fn build_match3d_background_from_scene_prompt() -> String { diff --git a/server-rs/crates/api-server/src/prompt/big_fish.rs b/server-rs/crates/api-server/src/prompt/big_fish.rs index 0cc0a3f74..ced20fa0d 100644 --- a/server-rs/crates/api-server/src/prompt/big_fish.rs +++ b/server-rs/crates/api-server/src/prompt/big_fish.rs @@ -116,7 +116,7 @@ pub(crate) fn build_big_fish_level_main_image_prompt( format!("形象描述:{}。", level.visual_description), format!("主图提示词:{}。", level.visual_prompt_seed), "等级对形象的影响规则:等级越高越霸气、有气场、看起来强大、画面细节丰富,等级级别越低越弱小、普通。最低等级为1级,最高等级可能是6-12级".to_string(), - "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), + "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), "背景要求:透明背景 PNG 风格,不出现任何场景、水草、气泡、阴影地面、UI、文字、logo、水印、对话框或边框;不要出现多只主体。".to_string(), ] .join("") diff --git a/server-rs/crates/api-server/src/prompt/character_animation.rs b/server-rs/crates/api-server/src/prompt/character_animation.rs index 7246e1411..492f72e16 100644 --- a/server-rs/crates/api-server/src/prompt/character_animation.rs +++ b/server-rs/crates/api-server/src/prompt/character_animation.rs @@ -53,7 +53,7 @@ fn build_image_sequence_prompt( "固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。".to_string(), "帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。".to_string(), if use_chroma_key { - "纯绿色背景,无地面装饰,方便后期抠像。".to_string() + "单一纯绿色 #00FF00 / RGB(0,255,0) 背景,无地面装饰,方便后期抠像。".to_string() } else { "背景尽量纯净,避免复杂场景。".to_string() }, @@ -94,7 +94,7 @@ fn build_npc_animation_prompt( template.animation ), if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + "背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抠像。".to_string() } else { "背景简洁纯净,无复杂场景。".to_string() }, @@ -123,7 +123,7 @@ fn build_npc_animation_prompt( "角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。".to_string(), "动作连贯,避免服装、发型、面部、武器随机漂移。".to_string(), if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。".to_string() + "背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抠像。".to_string() } else { "背景简洁纯净,无复杂场景。".to_string() }, @@ -200,10 +200,10 @@ fn build_video_action_prompt( [ format!("生成有创意细节饱满的角色动作视频,动作英文名是 {}。", action_id), "角色固定为图1同一角色,保持右向斜侧身动作视角,镜头稳定,轮廓清晰,禁止退化成完全90度纯右视图。".to_string(), - "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容。".to_string(), + "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景等场景内容。".to_string(), format!("动作结构:{}。结尾要求:动作收束清楚,便于后续抽帧。", action_sequence), if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string() + "背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素,方便后期抽帧与抠像。".to_string() } else { "背景简洁纯净,无其他人物和复杂场景元素,方便后期抽帧。".to_string() }, @@ -242,7 +242,7 @@ pub(crate) fn build_fallback_moderation_safe_animation_prompt( "非循环动作首尾回到角色标准站姿,中段完成动作变化。".to_string() }, if use_chroma_key { - "背景为纯绿色绿幕,无其他人物和场景元素。".to_string() + "背景为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,无其他人物和场景元素。".to_string() } else { "背景简洁纯净。".to_string() }, diff --git a/server-rs/crates/api-server/src/prompt/character_visual.rs b/server-rs/crates/api-server/src/prompt/character_visual.rs index b35b140c4..c2dfd4ccf 100644 --- a/server-rs/crates/api-server/src/prompt/character_visual.rs +++ b/server-rs/crates/api-server/src/prompt/character_visual.rs @@ -50,7 +50,7 @@ fn build_master_prompt(character_brief: &str) -> String { "单人,2D像素角色形象,头身比必须控制在1.5头身,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。".to_string(), "视角要求:角色采用横版动作素材常用的右向斜侧身站姿,身体整体朝右,但保留少量正面信息,能读到面部轮廓与胸肩结构,不是完全 90 度纯右视图,也不是正面立绘。".to_string(), "主体要求:画面中只保留单个角色主体,不要额外人物、动物、召唤物、载具或陪体。".to_string(), - "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为纯绿色绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), + "画面要求:1:1 正方形画布,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要镜头透视,不要特写。背景固定为单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,只作为抠像底色,不出现建筑、室内布景、风景、地面道具、漂浮物、烟雾叙事元素、文字或其他角色以外的场景内容。".to_string(), "风格要求:横版像素角色,细节精致,设计感足。使用深色清楚轮廓、稳定剪影、有限大色块和硬朗边缘,不要柔和厚涂插画感,发型、服装、配饰优先形成醒目可读的像素级识别点。".to_string(), "如果角色形象设定没有明确要求非人身体结构,默认优先使用人类或类人动作角色骨架。\ 默认将角色形象设定作用在角色自身的服装剪裁、材质、纹样、饰品、发光细节上。".to_string(), diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index e7f5ca4b1..13c042dde 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -336,7 +336,7 @@ pub(crate) fn build_puzzle_reference_image_too_large_message(actual_bytes: usize } const PUZZLE_LEVEL_SCENE_IMAGE_PROMPT: &str = "参考图作为拼图画面,生成对应的拼图游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n画面元素:\n返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 影”和倒计时时间,右上角显示设置按钮\n画面中间是一个正方形的3*3拼图,拼图区域宽度与画面宽度同宽,紧贴画面横向边缘,拼图区域边界带有边框装饰\n拼图区域下方包含一个下一关按钮,仅在关卡完成时显示\n底部是三个贴合画面主题的道具按钮分别为“提示”、“原图”、“冻结”\n道具按钮上不要显示次数标注,返回按钮和设置按钮旁禁止标注文字"; -const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成纯绿色绿幕背景的spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够纯绿色绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。"; +const PUZZLE_UI_SPRITESHEET_IMAGE_PROMPT: &str = "提取画面中的UI元素,将返回按钮、设置按钮、下一关按钮、提示按钮、原图按钮、冻结按钮整理成单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景的spritesheet。背景必须是统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。按钮顺序必须按原图位置从左到右、从上到下排列:返回、设置、下一关、提示、原图、冻结。按钮素材内必须保留对应中文文字,每个按钮必须是独立完整图形,按钮之间保留足够单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕空白,不能相互接触、重叠或连成一片,方便运行态按自动边界检测识别矩形素材。返回按钮和设置按钮不要额外画白色外圈、白底圆环或浮雕外框,直接画扁平图标本体。按钮自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。禁止水印、数字、次数标注、透明背景、背景图、拼图块、棋盘、网格线、按钮外标签和额外按钮。"; const PUZZLE_LEVEL_BACKGROUND_IMAGE_PROMPT: &str = "移除参考图中所有UI元素、移除拼图画面,仅保留背景图,补全被覆盖的背景图内容。禁止在背景中出现人像或和拼图画面中主体一致的内容"; mod handlers; pub(crate) use self::handlers::*; diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index dee3d4191..80f3f4152 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -109,7 +109,7 @@ fn puzzle_level_scene_spritesheet_and_background_requests_use_references() { spritesheet_body["prompt"] .as_str() .unwrap_or_default() - .contains("纯绿色绿幕背景") + .contains("单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景") ); assert!( spritesheet_body["prompt"] diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 9f583dd66..fc2d22684 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -1131,7 +1131,7 @@ async fn generate_wooden_fish_image_assets( fn build_wooden_fish_hit_object_prompt(prompt: &str) -> String { format!( - "生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。尺寸1:1,先输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,主体边缘必须干净,不要直接输出透明底。随后由服务端对绿色背景主体图做抠图去除绿色背景。最终结果只保留单个敲击物图案,禁止黑底、白底、棋盘格、纸板底或任何实底背景;主体本身不要使用与绿幕接近的纯绿色,若新主题天然包含绿色,请改用偏深、偏黄或偏蓝的绿色并与绿幕清晰区分。\n新主题为:{}", + "生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。尺寸1:1,先输出单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景主体图,背景必须严格使用 #00FF00 / RGB(0,255,0) 且平整无纹理、无渐变、无阴影、无道具,主体完整居中,主体边缘必须干净,不要直接输出透明底。随后由服务端对绿色背景主体图做抠图去除绿色背景。最终结果只保留单个敲击物图案,禁止黑底、白底、棋盘格、纸板底或任何实底背景;主体本身不要使用与绿幕接近的纯绿色,若新主题天然包含绿色,请改用偏深、偏黄或偏蓝的绿色并与绿幕清晰区分。\n新主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } @@ -1145,7 +1145,7 @@ fn build_wooden_fish_background_prompt(prompt: &str) -> String { fn build_wooden_fish_back_button_prompt(prompt: &str) -> String { format!( - "生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,按钮主体在画布中的视觉尺寸比当前模板再放大约 50%,圆心居中,圆形外沿加一圈和主题色搭配的干净外描边,让它更像一个按钮,但仍然只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1,输出绿色背景主体图(纯绿色绿幕),背景必须是单一纯绿色 #00FF00 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。\n主题为:{}", + "生成敲木鱼左上角返回按钮图。要求以参考图-去除绿色背景后的敲击物主体和背景环境图为主题、画风、材质和配色参考,但参考图只用来约束圆形底色和中央左箭头的颜色搭配,不要继承复杂造型、花纹、浮雕边、异形外框或装饰图案。按钮必须始终是标准圆形,整体像单个圆形图标,按钮主体在画布中的视觉尺寸比当前模板再放大约 50%,圆心居中,圆形外沿加一圈和主题色搭配的干净外描边,让它更像一个按钮,但仍然只保留一个清晰、简洁、居中的向左返回箭头,不要出现文字、数字、水印、按钮外标签、额外 UI 面板、木槌或敲击道具。尺寸1:1,输出单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景主体图,背景必须严格使用 #00FF00 / RGB(0,255,0) 且平整无纹理、无渐变、无阴影。按钮主体边缘干净,后续由服务端扣除绿色背景;按钮底色不要使用与绿幕接近的纯绿色,若主题天然包含绿色,请仅在圆形底色上使用偏深、偏黄或偏蓝的主题绿色,并用更高对比的箭头颜色区分。\n主题为:{}", clean_string(prompt, DEFAULT_HIT_OBJECT_PROMPT) ) } @@ -1597,8 +1597,7 @@ mod tests { "生成敲木鱼新样式,要求结构,画风与参考图保持高度一致,新样式颜色搭配使用新主题对应的颜色。" )); assert!(prompt.contains("尺寸1:1")); - assert!(prompt.contains("绿色背景主体图")); - assert!(prompt.contains("纯绿色绿幕")); + assert!(prompt.contains("先输出单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景主体图")); assert!(prompt.contains("#00FF00")); assert!(prompt.contains("不要直接输出透明底")); assert!(prompt.contains("主体本身不要使用与绿幕接近的纯绿色")); diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs index 95115c900..55b4b589f 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/alpha.rs @@ -3,6 +3,7 @@ use super::color::{ GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE, clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_green_screen_score, compute_generated_asset_sheet_key_color_score, + compute_generated_asset_sheet_muted_green_screen_score, compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_soft_green_matte_pixel, lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask, @@ -38,6 +39,7 @@ pub struct GeneratedAssetSheetAlphaOptions { pub key_color: GeneratedAssetSheetKeyColor, pub remove_near_white_background: bool, pub remove_disconnected_hard_key_background: bool, + pub remove_muted_green_screen_background: bool, // 中文注释:检测并清除被主体包围、不与画布四边连通的品红镂空区域。 // 仅对独立连通域整体判定,通过 min_pixels 过滤微小噪点。 pub detect_internal_holes: bool, @@ -50,6 +52,7 @@ impl GeneratedAssetSheetAlphaOptions { key_color: GeneratedAssetSheetKeyColor::GREEN_SCREEN, remove_near_white_background: true, remove_disconnected_hard_key_background: true, + remove_muted_green_screen_background: false, detect_internal_holes: false, internal_hole_min_pixels: 0, } @@ -60,6 +63,7 @@ impl GeneratedAssetSheetAlphaOptions { key_color: GeneratedAssetSheetKeyColor::MAGENTA_SCREEN, remove_near_white_background: false, remove_disconnected_hard_key_background: false, + remove_muted_green_screen_background: false, detect_internal_holes: true, internal_hole_min_pixels: 16, } @@ -108,6 +112,7 @@ fn remove_generated_asset_sheet_green_screen_background( } let mut key_scores = vec![0.0f32; pixel_count]; + let mut background_key_scores = vec![0.0f32; pixel_count]; let mut white_scores = vec![0.0f32; pixel_count]; let mut background_hints = vec![0.0f32; pixel_count]; let mut background_mask = vec![0u8; pixel_count]; @@ -120,8 +125,10 @@ fn remove_generated_asset_sheet_green_screen_background( let green = pixels[offset + 1]; let blue = pixels[offset + 2]; let alpha = pixels[offset + 3]; - let key_score = - compute_generated_asset_sheet_key_score([red, green, blue, alpha], options.key_color); + let pixel = [red, green, blue, alpha]; + let key_score = compute_generated_asset_sheet_key_score(pixel, options.key_color); + let background_key_score = + compute_generated_asset_sheet_background_key_score(pixel, options); let white_score = if options.remove_near_white_background { compute_generated_asset_sheet_white_screen_score([red, green, blue, alpha]) } else { @@ -131,27 +138,31 @@ fn remove_generated_asset_sheet_green_screen_background( clamp_generated_asset_sheet_unit((56.0 - alpha as f32) / 56.0) * 0.75; key_scores[pixel_index] = key_score; + background_key_scores[pixel_index] = background_key_score; white_scores[pixel_index] = white_score; - background_hints[pixel_index] = key_score.max(white_score).max(transparency_hint); + background_hints[pixel_index] = + background_key_score.max(white_score).max(transparency_hint); } - let seed_background_pixel = - |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { - if background_mask[pixel_index] != 0 { - return; - } - let alpha = pixels[pixel_index * 4 + 3]; - let strong_candidate = alpha < 40 - || key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 - && key_scores[pixel_index] > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) - || (options.remove_near_white_background && white_scores[pixel_index] > 0.32); - if !strong_candidate { - return; - } - background_mask[pixel_index] = 1; - queue.push(pixel_index); - }; + let seed_background_pixel = |pixel_index: usize, + background_mask: &mut [u8], + queue: &mut Vec| { + if background_mask[pixel_index] != 0 { + return; + } + let alpha = pixels[pixel_index * 4 + 3]; + let strong_candidate = alpha < 40 + || background_key_scores[pixel_index] >= GENERATED_ASSET_SHEET_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 + && background_key_scores[pixel_index] + > GENERATED_ASSET_SHEET_GREEN_SCREEN_MIN_SCORE) + || (options.remove_near_white_background && white_scores[pixel_index] > 0.32); + if !strong_candidate { + return; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + }; for x in 0..width { seed_background_pixel(x, &mut background_mask, &mut queue); @@ -193,7 +204,7 @@ fn remove_generated_asset_sheet_green_screen_background( } let next_offset = next_pixel_index * 4; let alpha = pixels[next_offset + 3]; - let key_score = key_scores[next_pixel_index]; + let key_score = background_key_scores[next_pixel_index]; let white_score = white_scores[next_pixel_index]; let hint = background_hints[next_pixel_index]; let reachable_soft_edge = hint > 0.08 @@ -314,7 +325,7 @@ fn remove_generated_asset_sheet_green_screen_background( pixels[offset + 2], pixels[offset + 3], ]; - let key_score = key_scores[pixel_index]; + let key_score = background_key_scores[pixel_index]; let white_score = white_scores[pixel_index]; if !is_generated_asset_sheet_soft_key_matte_pixel( pixel, @@ -353,7 +364,7 @@ fn remove_generated_asset_sheet_green_screen_background( continue; } let alpha = pixels[pixel_index * 4 + 3]; - let key_score = key_scores[pixel_index]; + let key_score = background_key_scores[pixel_index]; let white_score = white_scores[pixel_index]; let hint = background_hints[pixel_index]; let soft_matte_candidate = alpha < 224 @@ -607,6 +618,20 @@ fn compute_generated_asset_sheet_key_score( ) } +fn compute_generated_asset_sheet_background_key_score( + pixel: [u8; 4], + options: GeneratedAssetSheetAlphaOptions, +) -> f32 { + let key_score = compute_generated_asset_sheet_key_score(pixel, options.key_color); + if options.key_color.is_green_screen() && options.remove_muted_green_screen_background { + key_score.max(compute_generated_asset_sheet_muted_green_screen_score( + pixel, + )) + } else { + key_score + } +} + fn is_generated_asset_sheet_soft_key_matte_pixel( pixel: [u8; 4], key_score: f32, diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs index ecd5e2c8a..f555c81f9 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/color.rs @@ -139,6 +139,30 @@ pub(super) fn compute_generated_asset_sheet_green_screen_score(pixel: [u8; 4]) - .clamp(0.0, 1.0) } +pub(super) fn compute_generated_asset_sheet_muted_green_screen_score(pixel: [u8; 4]) -> f32 { + if pixel[3] == 0 { + return 1.0; + } + + let red = pixel[0] as f32; + let green = pixel[1] as f32; + let blue = pixel[2] as f32; + let red_lead = green - red; + let blue_lead = green - blue; + let chroma = green - red.min(blue); + if green < 76.0 || red_lead <= 10.0 || blue_lead <= 10.0 || chroma < 18.0 { + return 0.0; + } + + // 中文注释:生成模型偶尔会把标准 #00FF00 绿幕压成暗绿/灰绿边缘; + // 对这类明确偏绿的暗背景直接给到硬 key 分,便于边缘洪泛起步。 + (0.82 + + ((green - 76.0) / 72.0).clamp(0.0, 1.0) * 0.06 + + (red_lead.min(blue_lead) - 10.0).clamp(0.0, 48.0) / 48.0 * 0.08 + + ((chroma - 18.0) / 60.0).clamp(0.0, 1.0) * 0.04) + .clamp(0.0, 1.0) +} + pub(super) fn compute_generated_asset_sheet_key_color_score( pixel: [u8; 4], key_color: [u8; 3], diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs index 8cf6d9b75..b4ee9910a 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/prompt.rs @@ -60,6 +60,6 @@ pub fn build_generated_asset_sheet_prompt( .unwrap_or_else(|| format!("每个物品生成 {grid_size} 个不同视图。")); Ok(format!( - "生成一张1:1图片。固定生成{grid_size}行*{grid_size}列网格素材图,画面是{subject_text}。严格{grid_size}*{grid_size}均匀排布,严格按行组织:{item_rows}。{special_prompt}每个格子一个独立居中的完整素材,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若素材天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D素材。请让每个素材完整落在自己的格子中央,四周保留留白,相邻素材主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,素材主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。" + "生成一张1:1图片。固定生成{grid_size}行*{grid_size}列网格素材图,画面是{subject_text}。严格{grid_size}*{grid_size}均匀排布,严格按行组织:{item_rows}。{special_prompt}每个格子一个独立居中的完整素材,每格背景必须是统一单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕背景,背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若素材天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D素材。请让每个素材完整落在自己的格子中央,四周保留留白,相邻素材主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,素材主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。" )) } diff --git a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs index 40a0c8f08..ff0da6413 100644 --- a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs +++ b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs @@ -144,6 +144,35 @@ fn generated_asset_sheet_green_screen_alpha_removes_green_background() { assert_eq!(cleaned.get_pixel(10, 10).0[3], 255); } +#[test] +fn generated_asset_sheet_muted_green_alpha_requires_explicit_option() { + let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([59, 131, 67, 255])); + for y in 7..13 { + for x in 7..13 { + sheet.put_pixel(x, y, Rgba([220, 40, 40, 255])); + } + } + + let default_cleaned = + apply_generated_asset_sheet_green_screen_alpha(DynamicImage::ImageRgba8(sheet.clone())) + .to_rgba8(); + let muted_cleaned = apply_generated_asset_sheet_alpha_with_options( + DynamicImage::ImageRgba8(sheet), + GeneratedAssetSheetAlphaOptions { + remove_muted_green_screen_background: true, + ..GeneratedAssetSheetAlphaOptions::green_screen() + }, + ) + .to_rgba8(); + + assert!( + default_cleaned.get_pixel(0, 0).0[3] > 0, + "默认绿幕图集清理不应把暗绿背景直接清透明" + ); + assert_eq!(muted_cleaned.get_pixel(0, 0).0[3], 0); + assert_eq!(muted_cleaned.get_pixel(10, 10).0[3], 255); +} + #[test] fn generated_asset_sheet_magenta_key_preserves_green_white_and_disconnected_key_subject() { let mut sheet = RgbaImage::from_pixel(28, 28, Rgba([255, 0, 255, 255])); From 139ef3b3fa63a9716120c30c6b66d364c6f69cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 16:03:48 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=E7=95=8C=E9=9D=A2=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步推荐、发现、我的等桌面一级页的左侧栏和顶部搜索栏结构。 复用创作和项目页的陶土工作台背景、导航图标和顶部按钮规格。 补充桌面推荐页结构测试和样式保护测试。 更新创作主页与项目入口改版文档的桌面 chrome 口径。 修复图片画布生成完成态标题和占位框的类型门禁。 --- ...作】创作主页与项目入口改版计划-2026-06-18.md | 4 +- ...ImageCanvasGenerationSubmissionWorkflow.ts | 17 ++-- .../RpgEntryHomeView.recharge.test.tsx | 26 ++++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 45 +++------- src/index.css | 86 +++++++++---------- src/index.test.ts | 8 +- 6 files changed, 98 insertions(+), 88 deletions(-) diff --git a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md index fa38fb23c..a8d98456b 100644 --- a/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md +++ b/docs/【玩法创作】创作主页与项目入口改版计划-2026-06-18.md @@ -124,7 +124,7 @@ Tab: - `/creation/` 现有玩法工作台路由保持原有 stage 映射。 - 桌面端点击“创作”时调用 `pushAppHistoryPath('/creation')`。 - 桌面端原“草稿”入口改为“项目”,点击 `pushAppHistoryPath('/project')`。 -- 桌面端 `/project` 必须复用 `/creation` 的平台桌面 chrome,保留左侧完整导航栏和右侧顶部栏;`ProjectGalleryView` 只替换右侧主内容区,不能作为脱离导航的独立全屏页渲染,避免用户进入项目页后无法返回或切换页签。 +- 桌面端平台一级页统一复用 `/creation` 的平台桌面 chrome,保留左侧完整导航栏和右侧顶部栏;推荐、发现、我的、`/creation` 与 `/project` 只替换右侧主内容区,不能作为脱离导航的独立全屏页渲染,避免用户进入任一页后无法返回或切换页签。 - 桌面端从 `/creation` 切换到 `/project` 后,“项目”页签进入选中态,“创作”页签必须恢复为普通默认态,不保留创作主页的主按钮强调表现。 - `/project` 项目页头部必须提供 `返回主站` 按钮,点击后切回平台首页 `/`,行为与桌面侧栏切回推荐页保持一致。 - 移动端导航列表不展示“创作”和“项目”。 @@ -146,7 +146,7 @@ Tab: - 实现首屏主视觉、九大功能区、最近项目区和陶泥儿精选素材瀑布流。 - 桌面端复用现有平台壳层,保留上方栏和左侧导航栏。 - 主页整体采用浅色布局,避免外站品牌文案。 -- 视觉排版参考陶土质感创作工具工作台:首屏标题居中、主按钮与社区按钮使用大号圆角胶囊尺寸,九大创作工具以 3 列大卡片展示,卡片左侧保留大号陶土感图标区域,右侧展示原有标题与描述;`/creation` 场景下的平台顶部栏和左侧导航压平成单层工作台 chrome,避免外框套内框;桌面端页面结构必须先分为“左侧完整栏 / 右侧内容区”,左侧完整栏同时包含顶部品牌 Logo 和下方导航页签,右侧内容区再拆为顶部搜索、钱包、账号栏与下方主屏,不能用顶部栏的第一列假装左侧品牌区;左侧栏采用紧凑但保留图标和文字的导航胶囊,顶部搜索栏、泥点入口和账号入口都保持精简宽度,提升创作主页和项目页的工具感与精致度;只调整样式与布局,不改文案、搜索、登录、导航、项目创建、社区弹层、最近项目、精选素材 Tab 或数据读取逻辑。 +- 视觉排版参考陶土质感创作工具工作台:首屏标题居中、主按钮与社区按钮使用大号圆角胶囊尺寸,九大创作工具以 3 列大卡片展示,卡片左侧保留大号陶土感图标区域,右侧展示原有标题与描述;桌面平台一级页的顶部栏和左侧导航统一压平成单层工作台 chrome,避免外框套内框;桌面端页面结构必须先分为“左侧完整栏 / 右侧内容区”,左侧完整栏同时包含顶部品牌 Logo 和下方导航页签,右侧内容区再拆为顶部搜索、钱包、账号栏与下方主屏,不能用顶部栏的第一列假装左侧品牌区;左侧栏采用紧凑但保留图标和文字的导航胶囊,顶部搜索栏、泥点入口和账号入口都保持精简宽度,提升推荐、发现、我的、创作主页和项目页的工具感与精致度;只调整样式与布局,不改文案、搜索、登录、导航、项目创建、社区弹层、最近项目、精选素材 Tab 或数据读取逻辑。 - 背景、顶部品牌小陶偶、左侧导航、按钮和九大创作工具图标使用 `gpt-image-2` / image2 生成资源,统一保存在 `public/creation-home/`;资源只作为装饰图标或背景纹理接入,不包含图内文字,不替换或隐藏现有 UI 文案和交互。 - 社群入口复用现有玩家社区弹层,点击后必须停留在 `/creation`。 - 不在 UI 中放规则说明、实现说明或“暂不实装”提示。 diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 5db05a110..d8ec39135 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -996,19 +996,24 @@ export function useImageCanvasGenerationSubmissionWorkflow({ submissionPlan.input.kind === 'publication-material' ? await normalizePublicationReferenceImages(submissionPlan.input) : submissionPlan.input; + const resultTitle = + submissionPlan.result.title ?? + `生成图片 ${layerCounterRef.current + 1}`; + const canvasCompletionPlaceholder = + getGeneratingDialogPlaceholder(dialog); const generated = await generateEditorImage({ ...imageGenerationInput, projectId, assetKind: submissionPlan.result.assetKind, generationInputs: submissionPlan.result.generationInputs, assetFolderId, - assetLabel: submissionPlan.result.title, - ...(canvasDialog && projectId + assetLabel: resultTitle, + ...(canvasDialog && projectId && canvasCompletionPlaceholder ? { canvasCompletion: { dialogId: canvasDialog.id, - title: submissionPlan.result.title, - placeholder: getGeneratingDialogPlaceholder(dialog), + title: resultTitle, + placeholder: canvasCompletionPlaceholder, }, } : {}), @@ -1037,9 +1042,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } addGeneratedResultLayer(generated, { - frame: getGeneratingDialogPlaceholder(dialog), + frame: canvasCompletionPlaceholder, assetKind: submissionPlan.result.assetKind, - title: submissionPlan.result.title, + title: resultTitle, dialogId: canvasDialog?.id, generationInputs: submissionPlan.result.generationInputs, }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index b5ef1a8d5..e3401d22c 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -3819,6 +3819,32 @@ test('desktop nav opens creation home and project gallery from dedicated entries expect(onOpenProjects).toHaveBeenCalledTimes(1); }); +test('desktop recommend page reuses compact workbench chrome', () => { + const { container } = renderLoggedInHomeView({ + activeTab: 'home', + isDesktopLayout: true, + }); + + const shell = container.querySelector('.platform-desktop-shell'); + expect(shell).toBeTruthy(); + expect(shell?.className).toContain('platform-desktop-shell--workbench'); + + const leftbar = container.querySelector('.platform-desktop-leftbar'); + const topbar = container.querySelector('.platform-desktop-topbar'); + expect(leftbar).toBeTruthy(); + expect(topbar).toBeTruthy(); + expect(leftbar?.querySelector('.creation-home-brand-lockup')).toBeTruthy(); + expect(topbar?.querySelector('.creation-home-brand-lockup')).toBeNull(); + expect( + within(leftbar as HTMLElement).getByRole('button', { name: '推荐' }), + ).toBeTruthy(); + expect( + within(topbar as HTMLElement).getByPlaceholderText( + '搜索作品号、名称、作者、描述', + ), + ).toBeTruthy(); +}); + test('logged in saves tab shows loading state while fetching my entries', () => { renderLoggedInHomeView({ activeTab: 'saves', diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 921d33a07..005197429 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -4992,7 +4992,7 @@ export function RpgEntryHomeView({ ); const desktopTopbarActions: ReactNode = (
- {isAuthenticated && (activeTab === 'create' || activeTab === 'saves') ? ( + {isAuthenticated ? (
-
- {allowHiddenActiveTab ? ( -
- +
+
+ -
-
-
- {desktopSearchBar} -
- - {desktopTopbarActions} -
- -
- {tabPanels} -
-
-
- ) : ( - <> +
- {desktopBrandLockup} {desktopSearchBar}
{desktopTopbarActions}
-
- {desktopRailNav} - -
- {tabPanels} -
+
+ {tabPanels}
- - )} +
+
diff --git a/src/index.css b/src/index.css index d8b891884..995366226 100644 --- a/src/index.css +++ b/src/index.css @@ -11086,7 +11086,7 @@ button.image-canvas-editor__reference-chip:disabled { display: none; } -.creation-home-stage .platform-desktop-shell { +.platform-desktop-shell--workbench { --creation-home-sidebar-width: 10rem; border-color: rgba(221, 191, 166, 0.9); border-radius: 1.85rem; @@ -11098,22 +11098,22 @@ button.image-canvas-editor__reference-chip:disabled { box-shadow: 0 24px 58px rgba(112, 57, 30, 0.08); } -.creation-home-stage .platform-desktop-shell::before { +.platform-desktop-shell--workbench::before { display: none; } -.creation-home-stage .platform-desktop-shell::after { +.platform-desktop-shell--workbench::after { display: none; } -.creation-home-stage .platform-desktop-layout { +.platform-desktop-shell--workbench .platform-desktop-layout { position: relative; z-index: 1; width: 100%; height: 100%; } -.creation-home-stage .platform-desktop-leftbar { +.platform-desktop-shell--workbench .platform-desktop-leftbar { width: var(--creation-home-sidebar-width); min-height: 0; border-right: 1px solid rgba(219, 190, 166, 0.72); @@ -11121,11 +11121,11 @@ button.image-canvas-editor__reference-chip:disabled { padding: 0.86rem 0.5rem 0.96rem; } -.creation-home-stage .platform-desktop-main { +.platform-desktop-shell--workbench .platform-desktop-main { min-height: 0; } -.creation-home-stage .platform-desktop-topbar { +.platform-desktop-shell--workbench .platform-desktop-topbar { display: flex; gap: 0.58rem; min-height: 3.62rem; @@ -11138,28 +11138,28 @@ button.image-canvas-editor__reference-chip:disabled { backdrop-filter: none; } -.creation-home-stage .platform-desktop-topbar::before { +.platform-desktop-shell--workbench .platform-desktop-topbar::before { display: none; } -.creation-home-stage .platform-desktop-topbar__brand-search { +.platform-desktop-shell--workbench .platform-desktop-topbar__brand-search { gap: 0.58rem !important; justify-content: flex-start; padding-left: clamp(3.1rem, 5.8vw, 8.2rem); } -.creation-home-stage .platform-brand-logo { +.platform-desktop-shell--workbench .platform-brand-logo { align-self: center; } -.creation-home-stage .creation-home-brand-lockup { +.platform-desktop-shell--workbench .creation-home-brand-lockup { align-self: flex-start; gap: 0.42rem; min-height: 3.28rem; padding-left: 0.28rem; } -.creation-home-stage .creation-home-brand-lockup__art { +.platform-desktop-shell--workbench .creation-home-brand-lockup__art { display: block; width: 2.58rem; height: 2.58rem; @@ -11171,30 +11171,30 @@ button.image-canvas-editor__reference-chip:disabled { mix-blend-mode: multiply; } -.creation-home-stage .platform-brand-logo__title { +.platform-desktop-shell--workbench .platform-brand-logo__title { font-size: 1.38rem; color: #4a220f; } -.creation-home-stage .platform-brand-logo__subtitle { +.platform-desktop-shell--workbench .platform-brand-logo__subtitle { color: #9b5a35; font-size: 0.46rem; } -.creation-home-stage +.platform-desktop-shell--workbench .platform-desktop-topbar__brand-search > .platform-desktop-search { width: 100%; max-width: 20rem !important; } -.creation-home-stage .platform-desktop-topbar__actions { +.platform-desktop-shell--workbench .platform-desktop-topbar__actions { align-self: center; gap: 0.5rem !important; margin-left: auto; } -.creation-home-stage .platform-desktop-search { +.platform-desktop-shell--workbench .platform-desktop-search { position: relative; box-sizing: border-box; min-height: 0; @@ -11204,7 +11204,7 @@ button.image-canvas-editor__reference-chip:disabled { box-shadow: none; } -.creation-home-stage +.platform-desktop-shell--workbench .platform-desktop-topbar__brand-search > .platform-desktop-search { padding-block: 0 !important; @@ -11212,14 +11212,14 @@ button.image-canvas-editor__reference-chip:disabled { gap: 0.48rem !important; } -.creation-home-stage .platform-desktop-create-wallet-chip { +.platform-desktop-shell--workbench .platform-desktop-create-wallet-chip { position: relative; min-height: 2.28rem; border-radius: 999px !important; padding-inline: 0.42rem 0.58rem !important; } -.creation-home-stage .platform-desktop-create-wallet-chip::after { +.platform-desktop-shell--workbench .platform-desktop-create-wallet-chip::after { position: absolute; top: 50%; right: -0.52rem; @@ -11230,7 +11230,7 @@ button.image-canvas-editor__reference-chip:disabled { transform: translateY(-50%); } -.creation-home-stage .creation-home-topbar-art { +.platform-desktop-shell--workbench .creation-home-topbar-art { display: block; flex: 0 0 auto; object-fit: cover; @@ -11239,29 +11239,29 @@ button.image-canvas-editor__reference-chip:disabled { mix-blend-mode: multiply; } -.creation-home-stage .creation-home-topbar-art--search { +.platform-desktop-shell--workbench .creation-home-topbar-art--search { width: 1.34rem; height: 1.34rem; margin: -0.16rem -0.26rem -0.16rem -0.34rem; } -.creation-home-stage .platform-desktop-search > svg { +.platform-desktop-shell--workbench .platform-desktop-search > svg { display: none; } -.creation-home-stage .creation-home-topbar-art--wallet { +.platform-desktop-shell--workbench .creation-home-topbar-art--wallet { width: 1.32rem; height: 1.32rem; margin: -0.14rem -0.18rem -0.14rem -0.3rem; } -.creation-home-stage +.platform-desktop-shell--workbench .platform-desktop-create-wallet-chip .platform-icon-badge { display: none; } -.creation-home-stage .platform-desktop-topbar button.platform-desktop-search { +.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search { width: auto; max-width: none !important; min-width: 7.35rem; @@ -11272,17 +11272,17 @@ button.image-canvas-editor__reference-chip:disabled { gap: 0.38rem !important; } -.creation-home-stage .platform-desktop-topbar button.platform-desktop-search:hover { +.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search:hover { border-color: rgba(214, 184, 159, 0.52); background: rgba(255, 250, 244, 0.56); } -.creation-home-stage .platform-desktop-topbar button.platform-desktop-search > span:first-child { +.platform-desktop-shell--workbench .platform-desktop-topbar button.platform-desktop-search > span:first-child { width: 2.06rem; height: 2.06rem; } -.creation-home-stage .platform-desktop-rail { +.platform-desktop-shell--workbench .platform-desktop-rail { align-self: flex-start; width: 7.05rem; margin: 1.12rem auto 0; @@ -11295,7 +11295,7 @@ button.image-canvas-editor__reference-chip:disabled { 0 18px 34px rgba(112, 57, 30, 0.08); } -.creation-home-stage .platform-desktop-rail__button { +.platform-desktop-shell--workbench .platform-desktop-rail__button { min-height: 2.88rem; display: flex; flex-direction: row; @@ -11306,11 +11306,11 @@ button.image-canvas-editor__reference-chip:disabled { text-align: left; } -.creation-home-stage .platform-desktop-rail__button--primary { +.platform-desktop-shell--workbench .platform-desktop-rail__button--primary { min-height: 2.88rem; } -.creation-home-stage .platform-desktop-rail__icon-shell { +.platform-desktop-shell--workbench .platform-desktop-rail__icon-shell { width: 2.08rem; height: 2.08rem; flex: 0 0 auto; @@ -11319,7 +11319,7 @@ button.image-canvas-editor__reference-chip:disabled { box-shadow: none; } -.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell { +.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell { background: radial-gradient(circle at 35% 22%, rgba(255, 196, 133, 0.72), transparent 42%), linear-gradient(180deg, #df884b 0%, #c6652a 100%); @@ -11328,11 +11328,11 @@ button.image-canvas-editor__reference-chip:disabled { inset 0 1px 0 rgba(255, 255, 255, 0.5); } -.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon { +.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon { color: #fffaf3; } -.creation-home-stage .creation-home-nav-art { +.platform-desktop-shell--workbench .creation-home-nav-art { display: block; width: 1.62rem; height: 1.62rem; @@ -11342,7 +11342,7 @@ button.image-canvas-editor__reference-chip:disabled { mix-blend-mode: multiply; } -.creation-home-stage +.platform-desktop-shell--workbench .platform-desktop-rail__button--active .creation-home-nav-art { filter: @@ -11353,38 +11353,38 @@ button.image-canvas-editor__reference-chip:disabled { mix-blend-mode: screen; } -.creation-home-stage .platform-desktop-rail__icon { +.platform-desktop-shell--workbench .platform-desktop-rail__icon { display: none; } -.creation-home-stage .platform-desktop-rail__button--active { +.platform-desktop-shell--workbench .platform-desktop-rail__button--active { border-color: transparent; background: transparent; box-shadow: none; } -.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label { +.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label { color: #c15f28; } -.creation-home-stage .platform-desktop-rail__label { +.platform-desktop-shell--workbench .platform-desktop-rail__label { font-size: 0.78rem; font-weight: 820; letter-spacing: 0; } -.creation-home-stage .platform-tab-panel-stack { +.platform-desktop-shell--workbench .platform-tab-panel-stack { min-height: 0; background: transparent; } -.creation-home-stage .platform-tab-panel { +.platform-desktop-shell--workbench .platform-tab-panel { height: 100%; overflow: auto; padding-right: 0; } -.creation-home-stage .creation-landing { +.platform-desktop-shell--workbench .creation-landing { background: transparent; } diff --git a/src/index.test.ts b/src/index.test.ts index ac3c42f19..5e3ff9310 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -35,16 +35,16 @@ describe('index stylesheet unread dots', () => { const css = readIndexCss(); expect(css).not.toContain( - '.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell,\n.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell', + '.platform-desktop-shell--workbench .platform-desktop-rail__button--primary .platform-desktop-rail__icon-shell,\n.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell', ); expect(css).not.toContain( - '.creation-home-stage .platform-desktop-rail__button--primary .platform-desktop-rail__label,\n.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label', + '.platform-desktop-shell--workbench .platform-desktop-rail__button--primary .platform-desktop-rail__label,\n.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label', ); expect(css).toContain( - '.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell', + '.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__icon-shell', ); expect(css).toContain( - '.creation-home-stage .platform-desktop-rail__button--active .platform-desktop-rail__label', + '.platform-desktop-shell--workbench .platform-desktop-rail__button--active .platform-desktop-rail__label', ); }); From 8b18a654ebe35460bf8ca61a75457ca774eab461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Mon, 22 Jun 2026 17:22:01 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=85=83=E7=B4=A0=E5=90=B8=E9=99=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画布元素边缘、中心线与等距吸附计算。 接入图层与生成占位拖拽吸附,并保持多选队形同步偏移。 补充画布吸附模型和交互测试。 更新图片画布编辑器方案文档中的吸附验收口径。 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +- .../ImageCanvasEditorModel.test.ts | 75 ++++++ .../image-editor/ImageCanvasEditorModel.ts | 244 ++++++++++++++++-- .../image-editor/ImageCanvasEditorTypes.ts | 9 + .../ImageCanvasInteractionModel.test.ts | 71 ++++- .../ImageCanvasInteractionModel.ts | 142 +++++++++- .../useImageCanvasStageInteractions.test.tsx | 46 +++- .../useImageCanvasStageInteractions.ts | 20 +- 8 files changed, 566 insertions(+), 47 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 9019770a5..b27a1be23 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -15,7 +15,7 @@ - 画布中的图片可展示、悬浮显示图片 Resolution 尺寸与边框,点击后在图片上方显示浮动工具栏;浮动工具栏只保留当前可执行的编辑动作,不放调整 / 复制 / 删除 / 查看信息占位按钮。图片右上角素材类型标签、图片信息角标和悬浮尺寸标签在画布缩小时必须按 viewport 反向缩放,保持屏幕可读尺寸;无 `assetKind` 的素材右上角显示 `未知` 标签,点击标签弹出独立标签选择菜单并可写回图层 `assetKind`,不能触发图层选择 / 拖拽事件;图片信息角标使用圆形 `i` 图标,不使用中括号或花括号样式。图片不再维护独立展示 `Size` 字段,画布显示宽高统一取 `originalWidth/originalHeight`(图片信息中的 `Resolution`)。 - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 -- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 +- 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 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` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `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;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 @@ -29,7 +29,7 @@ - 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。 - 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。 - 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。 -- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。 +- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 画布自动保存使用防抖策略:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 @@ -87,7 +87,7 @@ - `显示画布所有元素` 按可见图层外接矩形计算 viewport。 - 左下角小地图可展示当前图层分布和视口范围,开关按钮可隐藏 / 恢复小地图,背景色菜单可切换白色、浅灰、暖灰和冷蓝工作区底色。 - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 -- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 +- 拖拽图片或生成占位框接近其它图片 / 生成占位框边缘、中心或等距分布位置时显示吸附线,并保存吸附后的最终布局。 - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 52a820a13..9841871d0 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -589,4 +589,79 @@ describe('ImageCanvasEditorModel', () => { horizontal: anchorLayer.y, }); }); + + it('snaps moving layers to equal horizontal and vertical spacing', () => { + const movingLayer: CanvasLayer = { + id: 'moving', + resourceId: 'resource-moving', + title: '移动图', + src: 'data:image/png;base64,moving', + x: 0, + y: 0, + width: 80, + height: 80, + originalWidth: 80, + originalHeight: 80, + zIndex: 1, + sourceType: 'uploaded', + }; + const firstLayer: CanvasLayer = { + ...movingLayer, + id: 'first', + resourceId: 'resource-first', + x: 100, + y: 160, + zIndex: 2, + }; + const secondLayer: CanvasLayer = { + ...movingLayer, + id: 'second', + resourceId: 'resource-second', + x: 260, + y: 160, + zIndex: 3, + }; + const topLayer: CanvasLayer = { + ...movingLayer, + id: 'top', + resourceId: 'resource-top', + x: 520, + y: 120, + zIndex: 4, + }; + const bottomLayer: CanvasLayer = { + ...movingLayer, + id: 'bottom', + resourceId: 'resource-bottom', + x: 520, + y: 300, + zIndex: 5, + }; + + const horizontalSnap = resolveSnappedLayerPosition( + movingLayer, + 417, + 160, + [movingLayer, firstLayer, secondLayer], + 1, + ); + expect(horizontalSnap.x).toBe(420); + expect(horizontalSnap.guide).toEqual({ + vertical: 460, + horizontal: 160, + }); + + const verticalSnap = resolveSnappedLayerPosition( + movingLayer, + 520, + 207, + [movingLayer, topLayer, bottomLayer], + 1, + ); + expect(verticalSnap.y).toBe(210); + expect(verticalSnap.guide).toEqual({ + vertical: 520, + horizontal: 250, + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 17e7dcd7c..359e54825 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -9,6 +9,7 @@ import type { CanvasGenerationDialogState, CanvasGenerationInputs, CanvasLayer, + CanvasSnapItem, CanvasMediaType, CanvasViewport, CharacterReferenceImage, @@ -34,6 +35,7 @@ export const MAX_SCALE = 3.2; export const TOOLBAR_HALF_WIDTH = 132; export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; export const SNAP_THRESHOLD_SCREEN_PX = 18; +export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1; export const FIT_VIEW_PADDING = 10; export const MINIMAP_SIZE = { width: 132, height: 84 }; export const MINIMAP_PADDING = 8; @@ -1086,43 +1088,78 @@ export function resolveSnappedLayerPosition( proposedY: number, layers: CanvasLayer[], scale: number, +) { + return resolveSnappedItemPosition( + movingLayer, + proposedX, + proposedY, + layers, + scale, + ); +} + +export function resolveSnappedItemPosition( + movingItem: CanvasSnapItem, + proposedX: number, + proposedY: number, + items: CanvasSnapItem[], + scale: number, ) { const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE); + const visibleItems = items.filter( + (item) => item.id !== movingItem.id && !item.hidden, + ); const verticalTargets = [ 0, CANVAS_WORLD_ORIGIN, - ...layers - .filter((layer) => layer.id !== movingLayer.id) - .flatMap((layer) => [ - layer.x, - layer.x + layer.width / 2, - layer.x + layer.width, - ]), + ...visibleItems.flatMap((item) => [ + item.x, + item.x + item.width / 2, + item.x + item.width, + ]), ]; const horizontalTargets = [ 0, CANVAS_WORLD_ORIGIN, - ...layers - .filter((layer) => layer.id !== movingLayer.id) - .flatMap((layer) => [ - layer.y, - layer.y + layer.height / 2, - layer.y + layer.height, - ]), + ...visibleItems.flatMap((item) => [ + item.y, + item.y + item.height / 2, + item.y + item.height, + ]), ]; - const xSnap = findNearestSnap( + const xAlignmentSnap = findNearestSnap( proposedX, - [0, movingLayer.width / 2, movingLayer.width], + [0, movingItem.width / 2, movingItem.width], verticalTargets, threshold, ); - const ySnap = findNearestSnap( + const yAlignmentSnap = findNearestSnap( proposedY, - [0, movingLayer.height / 2, movingLayer.height], + [0, movingItem.height / 2, movingItem.height], horizontalTargets, threshold, ); + const xDistributionSnap = findNearestEqualSpacingSnap({ + axis: 'x', + proposedStart: proposedX, + proposedCrossStart: proposedY, + movingSize: movingItem.width, + movingCrossSize: movingItem.height, + items: visibleItems, + threshold, + }); + const yDistributionSnap = findNearestEqualSpacingSnap({ + axis: 'y', + proposedStart: proposedY, + proposedCrossStart: proposedX, + movingSize: movingItem.height, + movingCrossSize: movingItem.width, + items: visibleItems, + threshold, + }); + const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap); + const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap); return { x: xSnap ? xSnap.position : proposedX, @@ -1137,6 +1174,177 @@ export function resolveSnappedLayerPosition( }; } +function chooseNearestSnap( + first: SnapCandidate | null, + second: SnapCandidate | null, +) { + if (!first) { + return second; + } + if (!second) { + return first; + } + return second.distance < first.distance ? second : first; +} + +function findNearestEqualSpacingSnap({ + axis, + proposedStart, + proposedCrossStart, + movingSize, + movingCrossSize, + items, + threshold, +}: { + axis: 'x' | 'y'; + proposedStart: number; + proposedCrossStart: number; + movingSize: number; + movingCrossSize: number; + items: CanvasSnapItem[]; + threshold: number; +}): SnapCandidate | null { + const orderedItems = [...items].sort( + (firstItem, secondItem) => + getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis), + ); + let nearest: SnapCandidate | null = null; + + for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) { + const firstItem = orderedItems[firstIndex]; + if (!firstItem) { + continue; + } + for ( + let secondIndex = firstIndex + 1; + secondIndex < orderedItems.length; + secondIndex += 1 + ) { + const secondItem = orderedItems[secondIndex]; + if (!secondItem) { + continue; + } + if ( + !snapItemsOverlapOnCrossAxis( + proposedCrossStart, + movingCrossSize, + firstItem, + axis, + ) || + !snapItemsOverlapOnCrossAxis( + proposedCrossStart, + movingCrossSize, + secondItem, + axis, + ) + ) { + continue; + } + + const firstStart = getSnapItemStart(firstItem, axis); + const firstEnd = getSnapItemEnd(firstItem, axis); + const secondStart = getSnapItemStart(secondItem, axis); + const secondEnd = getSnapItemEnd(secondItem, axis); + const pairGap = secondStart - firstEnd; + if (pairGap < 0) { + continue; + } + + nearest = chooseNearestSnap( + nearest, + createDistributionSnapCandidate({ + position: firstStart - pairGap - movingSize, + proposedStart, + movingSize, + threshold, + }), + ); + nearest = chooseNearestSnap( + nearest, + createDistributionSnapCandidate({ + position: secondEnd + pairGap, + proposedStart, + movingSize, + threshold, + }), + ); + + const betweenGap = (pairGap - movingSize) / 2; + if (betweenGap >= 0) { + nearest = chooseNearestSnap( + nearest, + createDistributionSnapCandidate({ + position: firstEnd + betweenGap, + proposedStart, + movingSize, + threshold, + }), + ); + } + } + } + + return nearest; +} + +function createDistributionSnapCandidate({ + position, + proposedStart, + movingSize, + threshold, +}: { + position: number; + proposedStart: number; + movingSize: number; + threshold: number; +}): SnapCandidate | null { + const distance = Math.abs(position - proposedStart); + if (distance > threshold) { + return null; + } + return { + position, + guide: position + movingSize / 2, + distance, + }; +} + +function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.x : item.y; +} + +function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.width : item.height; +} + +function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') { + return getSnapItemStart(item, axis) + getSnapItemSize(item, axis); +} + +function getSnapItemCrossStart(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.y : item.x; +} + +function getSnapItemCrossSize(item: CanvasSnapItem, axis: 'x' | 'y') { + return axis === 'x' ? item.height : item.width; +} + +function snapItemsOverlapOnCrossAxis( + proposedCrossStart: number, + movingCrossSize: number, + item: CanvasSnapItem, + axis: 'x' | 'y', +) { + const movingCrossEnd = proposedCrossStart + movingCrossSize; + const itemCrossStart = getSnapItemCrossStart(item, axis); + const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis); + return ( + proposedCrossStart <= + itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE && + movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE + ); +} + export function findNearestSnap( origin: number, offsets: number[], diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 7fcb13f5b..0b7183f11 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -359,6 +359,15 @@ export type SnapCandidate = { distance: number; }; +export type CanvasSnapItem = { + id: string; + x: number; + y: number; + width: number; + height: number; + hidden?: boolean; +}; + export type AssetMarqueeState = { pointerId: number; startX: number; diff --git a/src/components/image-editor/ImageCanvasInteractionModel.test.ts b/src/components/image-editor/ImageCanvasInteractionModel.test.ts index 31995d2f6..d461ca4cc 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.test.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.test.ts @@ -263,15 +263,19 @@ describe('ImageCanvasInteractionModel', () => { expect(result?.layers.find((layer) => layer.id === 'moving')).toMatchObject({ x: 200, - y: 90, + y: 100, }); expect(result?.layers.find((layer) => layer.id === 'follower')).toMatchObject({ x: 330, - y: 90, + y: 100, }); expect(result?.snapGuide).toEqual({ vertical: 300, - horizontal: 90, + horizontal: 100, + }); + expect(result?.snapOffset).toEqual({ + x: 0, + y: 10, }); const generationDialogs = [ @@ -292,6 +296,7 @@ describe('ImageCanvasInteractionModel', () => { moveGenerationFramesFromDrag({ generationDialogs, pointer: { x: 140, y: 170 }, + snapOffset: { x: 6, y: -4 }, dragState: { kind: 'generation-frame', dialogId: 'dialog-1', @@ -309,15 +314,20 @@ describe('ImageCanvasInteractionModel', () => { startLayers: [{ id: 'moving', x: 90, y: 90 }], startScale: 2, }, - }).map((dialog) => [dialog.id, dialog.placeholder?.x, dialog.placeholder?.y]), + }).generationDialogs.map((dialog) => [ + dialog.id, + dialog.placeholder?.x, + dialog.placeholder?.y, + ]), ).toEqual([ - ['dialog-1', 120, 105], - ['dialog-2', 420, 105], + ['dialog-1', 126, 101], + ['dialog-2', 426, 101], ]); expect( moveDragSelectedLayers({ layers, pointer: { x: 140, y: 170 }, + snapOffset: { x: 6, y: -4 }, dragState: { kind: 'generation-frame', dialogId: 'dialog-1', @@ -333,7 +343,54 @@ describe('ImageCanvasInteractionModel', () => { startScale: 2, }, }).find((layer) => layer.id === 'moving'), - ).toMatchObject({ x: 110, y: 75 }); + ).toMatchObject({ x: 116, y: 71 }); + }); + + it('snaps generation frames to nearby layer guides while dragging', () => { + const [anchorLayer] = [ + createLayer({ id: 'anchor', x: 420, y: 300, width: 120, height: 100 }), + ]; + const [movingDialog] = [ + createGenerationDialog({ + id: 'dialog-moving', + placeholder: { + x: 100, + y: 120, + width: 120, + height: 100, + originalWidth: 120, + originalHeight: 100, + }, + }), + ]; + const result = moveGenerationFramesFromDrag({ + layers: [anchorLayer], + generationDialogs: [movingDialog], + pointer: { x: 414, y: 292 }, + dragState: { + kind: 'generation-frame', + dialogId: 'dialog-moving', + dialogIds: ['dialog-moving'], + layerIds: [], + pointerId: 1, + startClientX: 100, + startClientY: 120, + startFrameX: 100, + startFrameY: 120, + startFrames: [{ id: 'dialog-moving', x: 100, y: 120 }], + startLayers: [], + startScale: 1, + }, + }); + + expect(result.generationDialogs[0]?.placeholder).toMatchObject({ + x: 420, + y: 300, + }); + expect(result.snapGuide).toEqual({ + vertical: 420, + horizontal: 300, + }); }); it('builds minimap projection and moves the viewport from minimap interactions', () => { diff --git a/src/components/image-editor/ImageCanvasInteractionModel.ts b/src/components/image-editor/ImageCanvasInteractionModel.ts index b4eb3ca3d..692210672 100644 --- a/src/components/image-editor/ImageCanvasInteractionModel.ts +++ b/src/components/image-editor/ImageCanvasInteractionModel.ts @@ -17,7 +17,7 @@ import { MINIMAP_SIZE, clamp, getLayerBounds, - resolveSnappedLayerPosition, + resolveSnappedItemPosition, } from './ImageCanvasEditorModel'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; @@ -40,7 +40,14 @@ export type CanvasRect = { export type CanvasLayerMoveResult = { layers: CanvasLayer[]; - snapGuide: ReturnType['guide']; + snapGuide: ReturnType['guide']; + snapOffset: CanvasPoint; +}; + +export type CanvasGenerationFrameMoveResult = { + generationDialogs: CanvasGenerationDialogState[]; + snapGuide: ReturnType['guide']; + snapOffset: CanvasPoint; }; export type StageMinimapModel = { @@ -269,6 +276,47 @@ function intersectsScreenRect({ ); } +function getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds = [], + excludedGenerationDialogIds = [], +}: { + layers: CanvasLayer[]; + generationDialogs: CanvasGenerationDialogState[]; + excludedLayerIds?: string[]; + excludedGenerationDialogIds?: string[]; +}) { + const layerSnapItems = layers + .filter((layer) => !excludedLayerIds.includes(layer.id)) + .map((layer) => ({ + id: layer.id, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + hidden: layer.hidden, + })); + const generationSnapItems = generationDialogs.flatMap((dialog) => { + if ( + !dialog.placeholder || + excludedGenerationDialogIds.includes(dialog.id) + ) { + return []; + } + return [ + { + id: getCanvasGenerationSelectionId(dialog.id), + x: dialog.placeholder.x, + y: dialog.placeholder.y, + width: dialog.placeholder.width, + height: dialog.placeholder.height, + }, + ]; + }); + return [...layerSnapItems, ...generationSnapItems]; +} + export function selectLayersInsideMarquee({ marquee, currentPoint, @@ -338,10 +386,12 @@ export function selectCanvasObjectsInsideMarquee({ export function moveLayersFromDrag({ dragState, layers, + generationDialogs = [], pointer, }: { dragState: Extract; layers: CanvasLayer[]; + generationDialogs?: CanvasGenerationDialogState[]; pointer: CanvasPoint; }): CanvasLayerMoveResult | null { const movingLayer = layers.find((layer) => layer.id === dragState.layerId); @@ -352,16 +402,26 @@ export function moveLayersFromDrag({ const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; const proposedX = dragState.startLayerX + deltaX; const proposedY = dragState.startLayerY + deltaY; - const snapped = resolveSnappedLayerPosition( + const snapped = resolveSnappedItemPosition( movingLayer, proposedX, proposedY, - layers, + getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds: dragState.layerIds, + excludedGenerationDialogIds: dragState.generationDialogIds ?? [], + }), dragState.startScale, ); + const snapOffset = { + x: snapped.x - proposedX, + y: snapped.y - proposedY, + }; return { snapGuide: snapped.guide, + snapOffset, layers: layers.map((layer) => dragState.layerIds.includes(layer.id) ? (() => { @@ -392,12 +452,16 @@ export function moveLayersFromDrag({ export function moveGenerationFramesFromDrag({ dragState, generationDialogs, + layers = [], + snapOffset, pointer, }: { dragState: Extract; generationDialogs: CanvasGenerationDialogState[]; + layers?: CanvasLayer[]; + snapOffset?: CanvasPoint | null; pointer: CanvasPoint; -}) { +}): CanvasGenerationFrameMoveResult { const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale; const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale; const targetDialogIds = @@ -405,13 +469,64 @@ export function moveGenerationFramesFromDrag({ ? dragState.dialogIds : (dragState.generationDialogIds ?? []); if (!targetDialogIds.length) { - return generationDialogs; + return { + generationDialogs, + snapGuide: null, + snapOffset: { x: 0, y: 0 }, + }; } const startFrames = dragState.kind === 'generation-frame' ? dragState.startFrames : (dragState.startGenerationFrames ?? []); - return generationDialogs.map((dialog) => { + const movingDialogId = + dragState.kind === 'generation-frame' + ? dragState.dialogId + : targetDialogIds[0]; + const movingDialog = generationDialogs.find( + (dialog) => dialog.id === movingDialogId, + ); + const movingPlaceholder = movingDialog?.placeholder; + const movingStartFrame = startFrames.find( + (frame) => frame.id === movingDialogId, + ); + const proposedX = (movingStartFrame?.x ?? 0) + deltaX; + const proposedY = (movingStartFrame?.y ?? 0) + deltaY; + const snapped = + snapOffset || dragState.kind === 'layer' + ? { + x: proposedX + (snapOffset?.x ?? 0), + y: proposedY + (snapOffset?.y ?? 0), + guide: null, + } + : movingDialog && movingPlaceholder && movingStartFrame + ? resolveSnappedItemPosition( + { + id: getCanvasGenerationSelectionId(movingDialog.id), + x: movingPlaceholder.x, + y: movingPlaceholder.y, + width: movingPlaceholder.width, + height: movingPlaceholder.height, + }, + proposedX, + proposedY, + getCanvasSnapItems({ + layers, + generationDialogs, + excludedLayerIds: dragState.layerIds, + excludedGenerationDialogIds: targetDialogIds, + }), + dragState.startScale, + ) + : { x: proposedX, y: proposedY, guide: null }; + const appliedSnapOffset = { + x: snapped.x - proposedX, + y: snapped.y - proposedY, + }; + return { + snapGuide: snapped.guide, + snapOffset: appliedSnapOffset, + generationDialogs: generationDialogs.map((dialog) => { if (!targetDialogIds.includes(dialog.id) || !dialog.placeholder) { return dialog; } @@ -423,20 +538,23 @@ export function moveGenerationFramesFromDrag({ ...dialog, placeholder: { ...dialog.placeholder, - x: startFrame.x + deltaX, - y: startFrame.y + deltaY, + x: startFrame.x + deltaX + appliedSnapOffset.x, + y: startFrame.y + deltaY + appliedSnapOffset.y, }, }; - }); + }), + }; } export function moveDragSelectedLayers({ dragState, layers, + snapOffset = { x: 0, y: 0 }, pointer, }: { dragState: Extract; layers: CanvasLayer[]; + snapOffset?: CanvasPoint; pointer: CanvasPoint; }) { if (!dragState.layerIds.length) { @@ -454,8 +572,8 @@ export function moveDragSelectedLayers({ } return { ...layer, - x: startLayer.x + deltaX, - y: startLayer.y + deltaY, + x: startLayer.x + deltaX + snapOffset.x, + y: startLayer.y + deltaY + snapOffset.y, }; }); } diff --git a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx index edafe3af5..8fe34a582 100644 --- a/src/components/image-editor/useImageCanvasStageInteractions.test.tsx +++ b/src/components/image-editor/useImageCanvasStageInteractions.test.tsx @@ -524,6 +524,33 @@ function StageInteractionsHarness({ > 直接拖生成占位 +
+ void; deleteLayerById?: (layerId: string | null) => void; deleteSelectedCanvasObjects?: () => void; + selectAllCanvasObjects?: () => void; + clearCanvasSelection?: () => void; + copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void; + pasteCanvasClipboard?: () => void; + duplicateSelectedCanvasLayers?: () => void; + moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void; + nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void; requestRemoveCanvasGenerationDialog?: ( dialog: CanvasGenerationDialogState, ) => void; + switchTool?: (tool: CanvasTool) => void; + updateScaleFromCenter?: (nextScale: number) => void; + zoomViewportBy?: (scaleFactor: number) => void; + fitLayers?: () => void; + toggleSidebarPanel?: (panel: 'assets' | 'layers') => void; + toggleMinimap?: () => void; + startProjectRename?: () => void; + exportCanvasAssets?: () => void; closeEditorChromePanels?: () => void; }) { const [activeTool, setActiveTool] = useState(initialTool); @@ -128,8 +158,23 @@ function KeyboardShortcutsHarness({ undoCanvasChange, deleteLayerById, deleteSelectedCanvasObjects, + selectAllCanvasObjects, + clearCanvasSelection, + copySelectedCanvasLayers, + pasteCanvasClipboard, + duplicateSelectedCanvasLayers, + moveSelectedCanvasLayers, + nudgeSelectedCanvasObjects, requestRemoveCanvasGenerationDialog, setActiveTool: (tool) => setActiveTool(tool), + switchTool: switchTool ?? ((tool) => setActiveTool(tool)), + updateScaleFromCenter, + zoomViewportBy, + fitLayers, + toggleSidebarPanel, + toggleMinimap, + startProjectRename, + exportCanvasAssets, setGenerateDialog: (updater) => setGenerateDialogState((currentDialog) => applyGenerateDialogUpdater(updater, currentDialog), @@ -238,6 +283,168 @@ describe('useImageCanvasKeyboardShortcuts', () => { }); }); expect(undoCanvasChange).toHaveBeenCalledTimes(1); + + act(() => { + fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', metaKey: true }); + }); + expect(undoCanvasChange).toHaveBeenCalledTimes(1); + }); + + it('routes Windows edit shortcuts to selected layer commands', () => { + const selectAllCanvasObjects = vi.fn(); + const copySelectedCanvasLayers = vi.fn(); + const pasteCanvasClipboard = vi.fn(); + const duplicateSelectedCanvasLayers = vi.fn(); + const exportCanvasAssets = vi.fn(); + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'a', code: 'KeyA', ctrlKey: true }); + fireEvent.keyDown(window, { key: 'c', code: 'KeyC', ctrlKey: true }); + fireEvent.keyDown(window, { key: 'v', code: 'KeyV', ctrlKey: true }); + fireEvent.keyDown(window, { key: 'x', code: 'KeyX', ctrlKey: true }); + fireEvent.keyDown(window, { key: 'd', code: 'KeyD', ctrlKey: true }); + fireEvent.keyDown(window, { + key: 'S', + code: 'KeyS', + ctrlKey: true, + shiftKey: true, + }); + }); + + expect(selectAllCanvasObjects).toHaveBeenCalledTimes(1); + expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(1); + expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(2, { cut: true }); + expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1); + expect(duplicateSelectedCanvasLayers).toHaveBeenCalledTimes(1); + expect(exportCanvasAssets).toHaveBeenCalledTimes(1); + }); + + it('routes Windows view and layer ordering shortcuts', () => { + const fitLayers = vi.fn(); + const updateScaleFromCenter = vi.fn(); + const zoomViewportBy = vi.fn(); + const moveSelectedCanvasLayers = vi.fn(); + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: '0', code: 'Digit0', ctrlKey: true }); + fireEvent.keyDown(window, { key: '1', code: 'Digit1', ctrlKey: true }); + fireEvent.keyDown(window, { key: '=', code: 'Equal', ctrlKey: true }); + fireEvent.keyDown(window, { key: '-', code: 'Minus', ctrlKey: true }); + fireEvent.keyDown(window, { + key: ']', + code: 'BracketRight', + ctrlKey: true, + }); + fireEvent.keyDown(window, { + key: '}', + code: 'BracketRight', + ctrlKey: true, + shiftKey: true, + }); + fireEvent.keyDown(window, { + key: '[', + code: 'BracketLeft', + ctrlKey: true, + }); + fireEvent.keyDown(window, { + key: '{', + code: 'BracketLeft', + ctrlKey: true, + shiftKey: true, + }); + }); + + expect(fitLayers).toHaveBeenCalledTimes(1); + expect(updateScaleFromCenter).toHaveBeenCalledWith(1); + expect(zoomViewportBy).toHaveBeenCalledWith(1.16); + expect(zoomViewportBy).toHaveBeenCalledWith(0.86); + expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(1, 'up'); + expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(2, 'top'); + expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(3, 'down'); + expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(4, 'bottom'); + }); + + it('routes tool, panel and project shortcuts', () => { + const switchTool = vi.fn(); + const toggleSidebarPanel = vi.fn(); + const toggleMinimap = vi.fn(); + const startProjectRename = vi.fn(); + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'v', code: 'KeyV' }); + fireEvent.keyDown(window, { key: 'h', code: 'KeyH' }); + fireEvent.keyDown(window, { key: 'u', code: 'KeyU' }); + fireEvent.keyDown(window, { key: 'g', code: 'KeyG' }); + fireEvent.keyDown(window, { key: 'V', code: 'KeyV', shiftKey: true }); + fireEvent.keyDown(window, { key: 'm', code: 'KeyM' }); + fireEvent.keyDown(window, { key: '1', code: 'Digit1', altKey: true }); + fireEvent.keyDown(window, { key: '2', code: 'Digit2', altKey: true }); + fireEvent.keyDown(window, { key: 'm', code: 'KeyM', altKey: true }); + fireEvent.keyDown(window, { key: 'F2', code: 'F2' }); + }); + + expect(switchTool).toHaveBeenNthCalledWith(1, 'select'); + expect(switchTool).toHaveBeenNthCalledWith(2, 'hand'); + expect(switchTool).toHaveBeenNthCalledWith(3, 'upload'); + expect(switchTool).toHaveBeenNthCalledWith(4, 'generate'); + expect(switchTool).toHaveBeenNthCalledWith(5, 'video'); + expect(switchTool).toHaveBeenNthCalledWith(6, 'music'); + expect(toggleSidebarPanel).toHaveBeenNthCalledWith(1, 'assets'); + expect(toggleSidebarPanel).toHaveBeenNthCalledWith(2, 'layers'); + expect(toggleMinimap).toHaveBeenCalledTimes(1); + expect(startProjectRename).toHaveBeenCalledTimes(1); + }); + + it('nudges selected canvas objects with arrow keys', () => { + const nudgeSelectedCanvasObjects = vi.fn(); + render( + , + ); + + act(() => { + fireEvent.keyDown(window, { key: 'ArrowUp', code: 'ArrowUp' }); + fireEvent.keyDown(window, { + key: 'ArrowRight', + code: 'ArrowRight', + shiftKey: true, + }); + }); + + expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(1, { + x: 0, + y: -1, + }); + expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(2, { + x: 10, + y: 0, + }); }); it('deletes the selected layer with Backspace outside editable inputs', () => { diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts index a0aec9310..6d5abf70f 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.ts @@ -2,8 +2,10 @@ import { type RefObject, useEffect } from 'react'; import type { CanvasGenerationDialogState, + CanvasTool, GenerateDialogState, QuickEditPanelState, + SidebarPanel, } from './ImageCanvasEditorTypes'; type UseImageCanvasKeyboardShortcutsOptions = { @@ -14,10 +16,25 @@ type UseImageCanvasKeyboardShortcutsOptions = { undoCanvasChange: () => void; deleteLayerById: (layerId: string | null) => void; deleteSelectedCanvasObjects?: () => void; + selectAllCanvasObjects?: () => void; + clearCanvasSelection?: () => void; + copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void; + pasteCanvasClipboard?: () => void; + duplicateSelectedCanvasLayers?: () => void; + moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void; + nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void; requestRemoveCanvasGenerationDialog?: ( dialog: CanvasGenerationDialogState, ) => void; setActiveTool: (tool: 'select') => void; + switchTool?: (tool: CanvasTool) => void; + updateScaleFromCenter?: (nextScale: number) => void; + zoomViewportBy?: (scaleFactor: number) => void; + fitLayers?: () => void; + toggleSidebarPanel?: (panel: SidebarPanel) => void; + toggleMinimap?: () => void; + startProjectRename?: () => void; + exportCanvasAssets?: () => void; setGenerateDialog: ( updater: | GenerateDialogState @@ -30,9 +47,7 @@ type UseImageCanvasKeyboardShortcutsOptions = { updater: | QuickEditPanelState | null - | (( - panel: QuickEditPanelState | null, - ) => QuickEditPanelState | null), + | ((panel: QuickEditPanelState | null) => QuickEditPanelState | null), ) => void; closeEditorChromePanels: () => void; setIsSpecMenuOpen: (open: boolean) => void; @@ -93,6 +108,48 @@ function isCanvasGenerationDialogState( return Boolean(dialog?.id && isCanvasGenerationPlaceholderDialog(dialog)); } +function isCtrlShortcut(event: KeyboardEvent) { + return event.ctrlKey && !event.altKey && !event.metaKey; +} + +function isKey(event: KeyboardEvent, key: string, code?: string) { + return event.key.toLowerCase() === key || event.code === code; +} + +function isPlusKey(event: KeyboardEvent) { + return ( + event.key === '+' || + event.key === '=' || + event.code === 'Equal' || + event.code === 'NumpadAdd' + ); +} + +function isMinusKey(event: KeyboardEvent) { + return ( + event.key === '-' || + event.code === 'Minus' || + event.code === 'NumpadSubtract' + ); +} + +function getArrowDelta(event: KeyboardEvent) { + const step = event.shiftKey ? 10 : 1; + if (event.key === 'ArrowUp') { + return { x: 0, y: -step }; + } + if (event.key === 'ArrowDown') { + return { x: 0, y: step }; + } + if (event.key === 'ArrowLeft') { + return { x: -step, y: 0 }; + } + if (event.key === 'ArrowRight') { + return { x: step, y: 0 }; + } + return null; +} + export function useImageCanvasKeyboardShortcuts({ generateDialogRef, selectedLayerIdRef, @@ -101,8 +158,23 @@ export function useImageCanvasKeyboardShortcuts({ undoCanvasChange, deleteLayerById, deleteSelectedCanvasObjects, + selectAllCanvasObjects, + clearCanvasSelection, + copySelectedCanvasLayers, + pasteCanvasClipboard, + duplicateSelectedCanvasLayers, + moveSelectedCanvasLayers, + nudgeSelectedCanvasObjects, requestRemoveCanvasGenerationDialog, setActiveTool, + switchTool, + updateScaleFromCenter, + zoomViewportBy, + fitLayers, + toggleSidebarPanel, + toggleMinimap, + startProjectRename, + exportCanvasAssets, setGenerateDialog, setImageContextMenu, setContextMenu, @@ -177,7 +249,7 @@ export function useImageCanvasKeyboardShortcuts({ const handleKeyDown = (event: KeyboardEvent) => { if ( - (event.ctrlKey || event.metaKey) && + isCtrlShortcut(event) && event.code === 'KeyZ' && !isEditableTarget(event) ) { @@ -189,6 +261,138 @@ export function useImageCanvasKeyboardShortcuts({ } return; } + if (isCtrlShortcut(event) && !isEditableTarget(event)) { + if (event.code === 'KeyA') { + event.preventDefault(); + selectAllCanvasObjects?.(); + return; + } + if (event.code === 'KeyC') { + event.preventDefault(); + copySelectedCanvasLayers?.(); + return; + } + if (event.code === 'KeyV') { + event.preventDefault(); + pasteCanvasClipboard?.(); + return; + } + if (event.code === 'KeyX') { + event.preventDefault(); + copySelectedCanvasLayers?.({ cut: true }); + return; + } + if (event.code === 'KeyD') { + event.preventDefault(); + duplicateSelectedCanvasLayers?.(); + return; + } + if (event.code === 'KeyS' && event.shiftKey) { + event.preventDefault(); + exportCanvasAssets?.(); + return; + } + if (event.key === '0' || event.code === 'Digit0') { + event.preventDefault(); + fitLayers?.(); + return; + } + if (event.key === '1' || event.code === 'Digit1') { + event.preventDefault(); + updateScaleFromCenter?.(1); + return; + } + if (isPlusKey(event)) { + event.preventDefault(); + zoomViewportBy?.(1.16); + return; + } + if (isMinusKey(event)) { + event.preventDefault(); + zoomViewportBy?.(0.86); + return; + } + if (event.code === 'BracketRight') { + event.preventDefault(); + moveSelectedCanvasLayers?.(event.shiftKey ? 'top' : 'up'); + return; + } + if (event.code === 'BracketLeft') { + event.preventDefault(); + moveSelectedCanvasLayers?.(event.shiftKey ? 'bottom' : 'down'); + return; + } + } + if ( + event.altKey && + !event.ctrlKey && + !event.metaKey && + !isEditableTarget(event) + ) { + if (event.code === 'Digit1') { + event.preventDefault(); + toggleSidebarPanel?.('assets'); + return; + } + if (event.code === 'Digit2') { + event.preventDefault(); + toggleSidebarPanel?.('layers'); + return; + } + if (event.code === 'KeyM') { + event.preventDefault(); + toggleMinimap?.(); + return; + } + } + if ( + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !isEditableTarget(event) + ) { + const arrowDelta = getArrowDelta(event); + if (arrowDelta) { + event.preventDefault(); + nudgeSelectedCanvasObjects?.(arrowDelta); + return; + } + if (event.key === 'F2') { + event.preventDefault(); + startProjectRename?.(); + return; + } + if (!event.repeat && event.code === 'KeyV' && event.shiftKey) { + event.preventDefault(); + switchTool?.('video'); + return; + } + if (!event.repeat && !event.shiftKey && isKey(event, 'v', 'KeyV')) { + event.preventDefault(); + switchTool?.('select'); + return; + } + if (!event.repeat && !event.shiftKey && isKey(event, 'h', 'KeyH')) { + event.preventDefault(); + switchTool?.('hand'); + return; + } + if (!event.repeat && !event.shiftKey && isKey(event, 'u', 'KeyU')) { + event.preventDefault(); + switchTool?.('upload'); + return; + } + if (!event.repeat && !event.shiftKey && isKey(event, 'g', 'KeyG')) { + event.preventDefault(); + switchTool?.('generate'); + return; + } + if (!event.repeat && !event.shiftKey && isKey(event, 'm', 'KeyM')) { + event.preventDefault(); + switchTool?.('music'); + return; + } + } if (event.key === 'Shift') { setShiftPressed(true); } @@ -238,6 +442,7 @@ export function useImageCanvasKeyboardShortcuts({ } if (event.key === 'Escape') { closeTransientEditorPanels(); + clearCanvasSelection?.(); return; } if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) { @@ -265,13 +470,23 @@ export function useImageCanvasKeyboardShortcuts({ }; }, [ closeEditorChromePanels, + clearCanvasSelection, + copySelectedCanvasLayers, + duplicateSelectedCanvasLayers, deleteSelectedCanvasObjects, deleteLayerById, + exportCanvasAssets, + fitLayers, generateDialogRef, + moveSelectedCanvasLayers, + nudgeSelectedCanvasObjects, + pasteCanvasClipboard, requestRemoveCanvasGenerationDialog, redoCanvasChange, + selectAllCanvasObjects, selectedLayerIdRef, selectedLayerIdsRef, + startProjectRename, setActiveTool, setContextMenu, setGenerateDialog, @@ -293,6 +508,11 @@ export function useImageCanvasKeyboardShortcuts({ setIsUiDesignSpecMenuOpen, setQuickEditPanel, setShiftPressed, + switchTool, + toggleMinimap, + toggleSidebarPanel, undoCanvasChange, + updateScaleFromCenter, + zoomViewportBy, ]); } diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 1af39cbe0..99ee9ebd9 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -139,12 +139,21 @@ function LayerCommandsHarness({ + + + + @@ -258,6 +273,40 @@ describe('useImageCanvasLayerCommands', () => { ); }); + it('copies, cuts, duplicates, and moves selected layers for keyboard shortcuts', () => { + const onDeleteLayerSideEffects = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '复制选中' })); + expect(screen.getByTestId('clipboard').textContent).toBe('copy:2'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('1'); + + fireEvent.click(screen.getByRole('button', { name: '创建选中副本' })); + expect(screen.getByTestId('layers').textContent).toContain( + 'first 副本:42:4', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'second 副本:192:5', + ); + expect(screen.getByTestId('selection').textContent).toContain( + 'layer-copy-', + ); + + fireEvent.click(screen.getByRole('button', { name: '只选第三层' })); + fireEvent.click(screen.getByRole('button', { name: '选中置顶' })); + expect(screen.getByTestId('layers').textContent).toContain('third:third'); + + fireEvent.click(screen.getByRole('button', { name: '剪切选中' })); + expect(screen.getByTestId('clipboard').textContent).toBe('cut:1'); + expect(screen.getByTestId('layers').textContent).not.toContain('third'); + expect(screen.getByTestId('selection').textContent).toBe('-:'); + expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third'); + }); + it('applies layer commands and clears menus without owning menu positioning', () => { const exportLayerImage = vi.fn(); render(); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 2bc968522..47aac3c18 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -85,6 +85,14 @@ export function useImageCanvasLayerCommands({ resolveContextTargetLayerIds(menu, selectedLayerIds), [contextMenu, selectedLayerIds], ); + const getSelectedTargetLayerIds = useCallback(() => { + const targetIds = selectedLayerIds.length + ? selectedLayerIds + : selectedLayerId + ? [selectedLayerId] + : []; + return getSelectedLayerIds(targetIds); + }, [selectedLayerId, selectedLayerIds]); const duplicateLayersToPoint = useCallback( ( @@ -172,6 +180,47 @@ export function useImageCanvasLayerCommands({ ], ); + const copySelectedLayers = useCallback( + (options: { cut?: boolean } = {}) => { + const targetIds = getSelectedTargetLayerIds(); + const clipboard = createCanvasLayerClipboard( + layers, + targetIds, + options.cut ? 'cut' : 'copy', + ); + if (!clipboard) { + return; + } + setCanvasClipboard(clipboard); + if (options.cut) { + captureCanvasHistory(); + setLayers((currentLayers) => + removeCanvasLayers(currentLayers, targetIds), + ); + selectSingleLayer(null); + setMetadataLayer((currentLayer) => + currentLayer && targetIds.includes(currentLayer.id) + ? null + : currentLayer, + ); + targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId)); + } + setContextMenu(null); + setImageContextMenu(null); + }, + [ + captureCanvasHistory, + getSelectedTargetLayerIds, + layers, + onDeleteLayerSideEffects, + selectSingleLayer, + setContextMenu, + setImageContextMenu, + setLayers, + setMetadataLayer, + ], + ); + const duplicateContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); @@ -195,6 +244,31 @@ export function useImageCanvasLayerCommands({ setSelectedLayerIds, ]); + const duplicateSelectedLayers = useCallback(() => { + const targetIds = getSelectedTargetLayerIds(); + const targetLayers = getCanvasLayersByIds(layers, targetIds); + const nextLayers = duplicateLayersToPoint(targetLayers); + if (!nextLayers.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => [...currentLayers, ...nextLayers]); + setSelectedLayerIds(nextLayers.map((layer) => layer.id)); + setSelectedLayerId(nextLayers[0]?.id ?? null); + setContextMenu(null); + setImageContextMenu(null); + }, [ + captureCanvasHistory, + duplicateLayersToPoint, + getSelectedTargetLayerIds, + layers, + setContextMenu, + setImageContextMenu, + setLayers, + setSelectedLayerId, + setSelectedLayerIds, + ]); + const updateContextLayers = useCallback( (updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer) => { const targetIds = getContextTargetLayerIds(); @@ -225,6 +299,28 @@ export function useImageCanvasLayerCommands({ [captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers], ); + const moveSelectedLayers = useCallback( + (mode: CanvasLayerMoveMode) => { + const targetIds = getSelectedTargetLayerIds(); + if (!targetIds.length) { + return; + } + captureCanvasHistory(); + setLayers((currentLayers) => + moveCanvasLayers(currentLayers, targetIds, mode), + ); + setContextMenu(null); + setImageContextMenu(null); + }, + [ + captureCanvasHistory, + getSelectedTargetLayerIds, + setContextMenu, + setImageContextMenu, + setLayers, + ], + ); + const groupContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { @@ -439,9 +535,12 @@ export function useImageCanvasLayerCommands({ getContextTargetLayerIds, pasteCanvasClipboard, copyContextLayers, + copySelectedLayers, duplicateContextLayers, + duplicateSelectedLayers, updateContextLayers, moveContextLayers, + moveSelectedLayers, groupContextLayers, ungroupContextLayers, toggleContextLayerVisibility, diff --git a/src/index.css b/src/index.css index 995366226..fdae31e92 100644 --- a/src/index.css +++ b/src/index.css @@ -4793,6 +4793,105 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { gap: 0.45rem; } +.image-canvas-editor__shortcut-modal { + border-radius: 1.05rem; +} + +.image-canvas-editor__shortcut-modal-body { + background: #f8fafc; +} + +.image-canvas-editor__shortcut-sections { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.image-canvas-editor__shortcut-section { + display: grid; + min-width: 0; + gap: 0.48rem; + border: 1px solid #dbe4f0; + border-radius: 0.65rem; + background: #ffffff; + padding: 0.72rem; +} + +.image-canvas-editor__shortcut-section h3 { + margin: 0; + color: #0f172a; + font-size: 0.86rem; + font-weight: 900; +} + +.image-canvas-editor__shortcut-list { + display: grid; + gap: 0.36rem; + margin: 0; +} + +.image-canvas-editor__shortcut-row { + display: grid; + grid-template-columns: minmax(7rem, 1fr) minmax(6rem, auto); + align-items: center; + gap: 0.6rem; + min-height: 2rem; +} + +.image-canvas-editor__shortcut-row dt { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 0.35rem; + color: #334155; + font-size: 0.78rem; + font-weight: 780; +} + +.image-canvas-editor__shortcut-row dt > span { + flex: 0 0 auto; + border-radius: 999px; + background: #e0f2fe; + padding: 0.08rem 0.34rem; + color: #0369a1; + font-size: 0.62rem; + font-weight: 900; +} + +.image-canvas-editor__shortcut-row dd { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 0.2rem; + margin: 0; +} + +.image-canvas-editor__shortcut-row kbd { + display: inline-grid; + min-width: 1.65rem; + min-height: 1.45rem; + place-items: center; + border: 1px solid #cbd5e1; + border-bottom-color: #94a3b8; + border-radius: 0.35rem; + background: linear-gradient(#ffffff, #eef2f7); + padding: 0 0.42rem; + color: #0f172a; + font-family: inherit; + font-size: 0.72rem; + font-weight: 850; + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.16); + white-space: nowrap; +} + +.image-canvas-editor__shortcut-plus { + margin-right: 0.2rem; + color: #64748b; + font-size: 0.72rem; + font-weight: 900; +} + .image-canvas-editor__project-back-button { width: 2.25rem; height: 2.25rem; @@ -7966,6 +8065,19 @@ button.image-canvas-editor__reference-chip:disabled { min-height: 3.2rem; } + .image-canvas-editor__shortcut-sections { + grid-template-columns: 1fr; + } + + .image-canvas-editor__shortcut-row { + grid-template-columns: minmax(0, 1fr); + align-items: start; + } + + .image-canvas-editor__shortcut-row dd { + justify-content: flex-start; + } + .image-canvas-editor__title-block h1 { font-size: 0.84rem; }