diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 5d879b8e..47791407 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -146,7 +146,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 1. 创作端只保留主题输入,作品标题、简介、标签和地块提示词由系统派生; 2. v1 不再单独生成角色图片,运行态固定使用抠除白底后的陶泥儿 logo 透明 PNG 作为玩家角色; -3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key,不启用近白底扣除,也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;地块造型提示词要求以主题物体本身外轮廓为准,允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异,只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装;所有地块素材必须保持统一正面30度视角,相机位于物体正前方略高位置、镜头向下约30度,必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面,主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶;避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标;主题物体本身必须是唯一可落脚体,只能用自身切面、边缘厚度、花瓣层或果皮边表现承重,禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板;前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集,物体本身作为跳跃落点”的口径,不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词; +3. 地块只调用一次 image2,输出一张 `5行*5列`、`1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key,不启用近白底扣除,也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制;地块造型提示词要求以主题物体本身外轮廓为准,允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异,只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装;所有地块素材必须保持统一正面30度视角,相机位于物体正前方略高位置、镜头向下约30度,必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面,主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶;避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标;主题物体本身必须是唯一可落脚体,只能用自身切面、边缘厚度、花瓣层或果皮边表现承重,禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板;前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集,物体本身作为跳跃落点”的口径,不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词; 4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段,OSS 槽位固定为 `background/image.png`,不新增 SpacetimeDB 字段;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感,两侧允许更强立体层次和行进感;背景只作为底图,禁止生成跳板、地块、落脚物、角色、UI、文字、路径箭头或海报排版; 5. 后端按从上到下、从左到右均匀切分为 `tile-01` 到 `tile-25` 的透明 PNG,每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; 6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽。 diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 69ccf710..dcc06949 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -636,12 +636,12 @@ fn build_jump_hop_tile_atlas_prompt(theme_text: &str, tile_prompt: &str) -> Stri }; format!( - "生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳跃落点主题物体,按五行五列均匀摆放在纯洋红抠图画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为正面30度视角的跳跃游戏素材,画面内容是{subject_text}。所有落点素材都必须保持统一的正面30度视角:相机位于物体正前方略高位置,镜头向下约30度,能看到清晰正面、侧壁、下沿和少量上表面。\n构图验收标准:主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;不要让顶面占据主要视觉,不要画成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标。\n水果主题尤其要避免俯拍:橙瓣必须看到橙皮正面外侧和果肉厚度,椰子必须看到壳的正面侧壁和切口厚度,浆果不能只是一个从上往下看的圆形球顶。\n每一个落点都必须直接使用主题物体或合理发散物体做主体造型,主题要一眼可见;例如主题为水果时,可以是苹果切片、橙瓣、西瓜块、草莓、菠萝块、香蕉、葡萄串等水果物体,苹果可近似圆,香蕉可近似长条或长方形,西瓜可近似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一可落脚体:雪花落点就是一枚带厚度的雪花,向日葵落点就是一朵带厚度的向日葵,水果落点就是水果切片或水果本体;不要在主题物体下面再垫任何石头、土块、木板、圆台、底盘、托盘、岛屿、花盆、地面块或通用承托物。\n只画主题物体裸素材,不画外层面板、棋盘底座、菜单、UI按钮、标题、文字、角标、装饰边框、工具栏、装备栏、图标卡、角色或游戏界面。\n整体风格为清爽自然的休闲手游主题物体素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每个落点都是符合主题且有设计感的立体感物体,有清晰轮廓和明显自身厚度;不要把不同主题物体强行改造成统一地砖、统一按钮或统一抽象图标。\n造型规则完全由物体本身决定:允许圆形、长条、弧形、三角、扇形、块状、枝叶状、多件组合、轻微夸张和一定程度发散;只在同一2D/2.5D手绘风格、正面30度视角、材质包装、清晰轮廓、单格规格和安全留白上保持一致。\n25个落点应尽量选择不同主题物体或相关发散物体,差异主要来自物体种类和原生轮廓,不使用固定形状脚本;相邻格可以形状相似,只要物体不同且主题清楚。\n允许用主题物体自身的切面、边缘厚度、花瓣层、果皮边、雪花厚边或云朵体积表现可落脚感;禁止额外支撑层、承托底座、脚下地板、下方石台、下方土墩、下方圆盘、下方托盘或“物体摆在平台上”的画法。\n每个落点必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯洋红安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个落点只保留主体内部明暗、外轮廓和自身厚度,不绘制落地投影、接触阴影、方形阴影、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个落点同一材质体系、同一光向和同一正面30度视角,但物体类别、外轮廓和细节有变化;每个落点之间只能是纯洋红空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹理、无渐变、无阴影、无黑底;主体允许使用绿色、白色、雪地、云朵、草地和花朵,但主体自身不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板、图标集页面、物体下方额外底座或物体摆在地板上。\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons." + "生成一张1:1图片,主题为“{theme_text}”。\n画面只包含25个独立的跳跃落点主题物体,按五行五列均匀摆放在纯洋红抠图画布上;不要画成游戏界面、棋盘、背包、装备栏或图标集页面。\n视觉方向为正面30度视角的跳跃游戏素材,画面内容是{subject_text}。所有落点素材都必须保持统一的正面30度视角:相机位于物体正前方略高位置,镜头向下约30度,能看到清晰正面、侧壁、下沿和少量上表面。\n构图验收标准:主体正面或侧壁可见面积必须接近或大于顶面面积,顶面只能作为辅助可见面;不要让顶面占据主要视觉,不要画成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标。\n水果主题尤其要避免俯拍:橙瓣必须看到橙皮正面外侧和果肉厚度,椰子必须看到壳的正面侧壁和切口厚度,浆果不能只是一个从上往下看的圆形球顶。\n每一个落点都必须直接使用主题物体或合理发散物体做主体造型,主题要一眼可见;例如主题为水果时,可以是苹果切片、橙瓣、西瓜块、草莓、菠萝块、香蕉、葡萄串等水果物体,苹果可近似圆,香蕉可近似长条或长方形,西瓜可近似扇形,造型以物体本身外轮廓为准。\n主题物体本身就是唯一可落脚体:雪花落点就是一枚带厚度的雪花,向日葵落点就是一朵带厚度的向日葵,水果落点就是水果切片或水果本体;不要在主题物体下面再垫任何石头、土块、木板、圆台、底盘、托盘、岛屿、花盆、地面块或通用承托物。\n只画主题物体裸素材,不画外层面板、棋盘底座、菜单、UI按钮、标题、文字、角标、装饰边框、工具栏、装备栏、图标卡、角色或游戏界面。\n整体风格为清爽自然的休闲手游主题物体素材,偏2D/2.5D手绘质感,哑光材质,干净色块,轻微主体内部明暗,避免写实摄影、油亮高光、塑料感、暗黑幻想风和厚重CG渲染。\n每个落点都是符合主题且有设计感的立体感物体,有清晰轮廓和明显自身厚度;不要把不同主题物体强行改造成统一地砖、统一按钮或统一抽象图标。\n造型规则完全由物体本身决定:允许圆形、长条、弧形、三角、扇形、块状、枝叶状、多件组合、轻微夸张和一定程度发散;只在同一2D/2.5D手绘风格、正面30度视角、材质包装、清晰轮廓、单格规格和安全留白上保持一致。\n25个落点应尽量选择不同主题物体或相关发散物体,差异主要来自物体种类和原生轮廓,不使用固定形状脚本;相邻格可以形状相似,只要物体不同且主题清楚。\n允许用主题物体自身的切面、边缘厚度、花瓣层、果皮边、雪花厚边或云朵体积表现可落脚感;禁止额外支撑层、承托底座、脚下地板、下方石台、下方土墩、下方圆盘、下方托盘或“物体摆在平台上”的画法。\n每个落点必须居中,视觉尺寸只占单格56%-64%,四周至少保留18%纯洋红安全留白;任何叶片、装饰、轮廓和光影都不得贴边、跨格或越界。\n每个落点只保留主体内部明暗、外轮廓和自身厚度,不绘制落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、彩色光晕、发光底边、底板、白底、灰底、黑底或背景色块,运行态会统一添加阴影。\n25个落点同一材质体系、同一光向和同一正面30度视角,但物体类别、外轮廓和细节有变化;每个落点之间只能是纯洋红空白,不画分隔线、网格线、容器框或棋盘格。\n整张画布背景、格间空白和每格背景都必须是单一纯洋红 {JUMP_HOP_TILE_ATLAS_KEY_HEX},背景平整无纹理、无渐变、无阴影、无黑底;主体允许使用绿色、白色、雪地、云朵、草地和花朵,但主体自身不得使用接近 {JUMP_HOP_TILE_ATLAS_KEY_HEX} 的洋红色,主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影。\n禁止跨格、贴边、越界、文字、水印、UI、边框、网格线、角色、场景、游戏面板、图标集页面、物体下方额外底座或物体摆在地板上。\nEnglish guardrail: isolated front-facing 30-degree camera-pitch theme-object assets only, camera slightly above the object and looking down about 30 degrees from the front; every object must show a clear front face, side wall, lower rim, object thickness, and only a small top surface; visible front/side area must be close to or larger than the top area; never produce top-down, overhead, bird's-eye, flat icon, round top-view disk assets; the theme object itself is the only landing object, each object's native silhouette decides the shape, no extra base under the object, no pedestal, no plinth, no floor slab, no colored shadow or magenta fringe around objects, consistent 2D/2.5D style wrapper, solid magenta chroma key background {JUMP_HOP_TILE_ATLAS_KEY_HEX}, no text, no poster, no UI screen, no inventory icons." ) } fn build_jump_hop_tile_atlas_negative_prompt() -> &'static str { - "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、纯俯视角、正上方视角、鸟瞰视角、平铺俯拍、顶面占主画面、只看顶面、圆形顶视图、扁平图标、落地投影、接触阴影、方形阴影、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界" + "文字、Logo、水印、UI按钮、UI 字、游戏界面、棋盘、背包、装备栏、图标集页面、外层面板、菜单、工具栏、低清晰度、畸形肢体、多余角色、裁切主体、写实摄影、油亮高光、塑料质感、暗黑幻想风、厚重CG渲染、海报、UI图标卡、标题、说明文字、装饰边框、纯俯视角、正上方视角、鸟瞰视角、平铺俯拍、顶面占主画面、只看顶面、圆形顶视图、扁平图标、落地投影、接触阴影、方形阴影、洋红阴影、紫色底边、粉色脏边、洋红色描边、彩色光晕、发光底边、方形底板、额外底座、承托底座、台座、石台、土墩、木板底座、圆台、底盘、托盘、岛屿底座、花盆底座、地面块、脚下地板、物体摆在平台上、物体下方垫地板、白底、灰底、黑底、暗色背景、背景色块、贴边、跨格、越界" } fn sanitize_jump_hop_tile_prompt(tile_prompt: &str) -> String { @@ -1325,7 +1325,10 @@ mod tests { assert!(prompt.contains(JUMP_HOP_TILE_ATLAS_KEY_HEX)); assert!(prompt.contains("主体允许使用绿色、白色、雪地、云朵、草地和花朵")); assert!(prompt.contains("不绘制落地投影")); + assert!(prompt.contains("不绘制落地投影、接触阴影、方形阴影、洋红阴影")); + assert!(prompt.contains("紫色底边、彩色光晕、发光底边")); assert!(prompt.contains("不画分隔线、网格线、容器框或棋盘格")); + assert!(prompt.contains("主体边缘不得出现洋红色描边、紫色描边、粉色脏边或彩色阴影")); assert!(prompt.contains("English guardrail")); assert!(prompt.contains("front-facing 30-degree camera-pitch")); assert!(prompt.contains("camera slightly above the object")); @@ -1337,6 +1340,7 @@ mod tests { assert!(prompt.contains("no extra base under the object")); assert!(prompt.contains("no pedestal")); assert!(prompt.contains("no floor slab")); + assert!(prompt.contains("no colored shadow or magenta fringe around objects")); assert!(!prompt.contains("可落脚平台素材")); assert!(!prompt.contains("平台裸素材")); assert!(!prompt.contains("每格一个完整平台")); @@ -1460,6 +1464,12 @@ mod tests { assert!(negative_prompt.contains("圆形顶视图")); assert!(negative_prompt.contains("扁平图标")); assert!(negative_prompt.contains("方形阴影")); + assert!(negative_prompt.contains("洋红阴影")); + assert!(negative_prompt.contains("紫色底边")); + assert!(negative_prompt.contains("粉色脏边")); + assert!(negative_prompt.contains("洋红色描边")); + assert!(negative_prompt.contains("彩色光晕")); + assert!(negative_prompt.contains("发光底边")); assert!(negative_prompt.contains("方形底板")); assert!(negative_prompt.contains("额外底座")); assert!(negative_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 782460ca..d95b4675 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 @@ -385,7 +385,13 @@ fn remove_generated_asset_sheet_green_screen_background( let mut red = pixels[offset] as f32; let mut green = pixels[offset + 1] as f32; let mut blue = pixels[offset + 2] as f32; - let blend = clamp_generated_asset_sheet_unit(contamination.max(0.22)); + let blend = if options.key_color.is_green_screen() { + clamp_generated_asset_sheet_unit(contamination.max(0.22)) + } else { + // 中文注释:洋红 / 青色等非绿幕 key 的残留更容易表现成彩边, + // 需要比绿幕更强地向主体邻近色收敛,避免 PNG 边缘继续带 key 色。 + clamp_generated_asset_sheet_unit((key_score * 1.35).max(contamination).max(0.28)) + }; if let Some((sample_red, sample_green, sample_blue)) = sample { red = lerp_generated_asset_sheet_channel(red, sample_red as f32, blend); @@ -400,6 +406,17 @@ fn remove_generated_asset_sheet_green_screen_background( green = green.min(sample_green as f32 + 26.0); blue = blue.min(sample_blue as f32 + 26.0); } + if !options.key_color.is_green_screen() && key_score > 0.04 { + let defringed = suppress_generated_asset_sheet_key_color_fringe( + [red, green, blue], + [sample_red as f32, sample_green as f32, sample_blue as f32], + key_score, + options.key_color, + ); + red = defringed[0]; + green = defringed[1]; + blue = defringed[2]; + } } else { if options.key_color.is_green_screen() && key_score > 0.04 { let toned_green = (green - (green - red.max(blue)) * 0.78) @@ -417,10 +434,26 @@ fn remove_generated_asset_sheet_green_screen_background( blue = blue.min(toned_value); } } + if !options.key_color.is_green_screen() && key_score > 0.04 { + let neutral = (red + green + blue) / 3.0; + let defringed = suppress_generated_asset_sheet_key_color_fringe( + [red, green, blue], + [neutral, neutral, neutral], + key_score, + options.key_color, + ); + red = defringed[0]; + green = defringed[1]; + blue = defringed[2]; + } } let mut next_alpha = alpha; - let edge_fade = (key_score * 0.35).max(white_score * 0.28); + let edge_fade = if options.key_color.is_green_screen() { + (key_score * 0.35).max(white_score * 0.28) + } else { + (key_score * 0.48).max(white_score * 0.28) + }; if edge_fade > 0.08 { next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; if next_alpha < 10 { @@ -448,6 +481,37 @@ fn remove_generated_asset_sheet_green_screen_background( changed } +pub(super) fn suppress_generated_asset_sheet_key_color_fringe( + color: [f32; 3], + target: [f32; 3], + key_score: f32, + key_color: GeneratedAssetSheetKeyColor, +) -> [f32; 3] { + let strength = clamp_generated_asset_sheet_unit(key_score * 1.18); + let key_channels = [ + key_color.red as f32 / 255.0, + key_color.green as f32 / 255.0, + key_color.blue as f32 / 255.0, + ]; + let mut next = color; + + for index in 0..3 { + if key_channels[index] >= 0.66 { + let cap = target[index] + 18.0 + (1.0 - strength) * 28.0; + next[index] = next[index].min(lerp_generated_asset_sheet_channel( + next[index], + cap, + strength, + )); + } else if key_channels[index] <= 0.34 { + next[index] = + lerp_generated_asset_sheet_channel(next[index], target[index], strength * 0.72); + } + } + + next +} + fn compute_generated_asset_sheet_key_score( pixel: [u8; 4], key_color: GeneratedAssetSheetKeyColor, diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 6bfbf96f..740f4f43 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -1,13 +1,14 @@ use super::alpha::{ GeneratedAssetSheetAlphaOptions, apply_generated_asset_sheet_green_screen_alpha, + suppress_generated_asset_sheet_key_color_fringe, }; use super::color::{ - compute_generated_asset_sheet_key_color_score, + clamp_generated_asset_sheet_unit, compute_generated_asset_sheet_key_color_score, compute_generated_asset_sheet_white_screen_score, is_generated_asset_sheet_foreground_pixel, is_generated_asset_sheet_green_contaminated_edge_pixel, is_generated_asset_sheet_soft_edge_pixel, is_generated_asset_sheet_strong_green_contamination, is_generated_asset_sheet_view_background_pixel, is_generated_asset_sheet_visible_pixel, - touches_generated_asset_sheet_background_mask, + lerp_generated_asset_sheet_channel, touches_generated_asset_sheet_background_mask, }; use super::error::GeneratedAssetSheetError; use image::{GenericImageView, ImageFormat}; @@ -588,11 +589,54 @@ fn remove_generated_asset_sheet_view_edge_matte( pixels[offset + 1], pixels[offset + 2], )); - let next_red = replacement.0.max(pixels[offset]); - let next_blue = replacement.2.max(pixels[offset + 2]); - let next_green = replacement - .1 - .min(next_red.max(next_blue).saturating_add(12)); + let (next_red, next_green, next_blue) = if options.key_color.is_green_screen() { + let next_red = replacement.0.max(pixels[offset]); + let next_blue = replacement.2.max(pixels[offset + 2]); + let next_green = replacement + .1 + .min(next_red.max(next_blue).saturating_add(12)); + (next_red, next_green, next_blue) + } else { + let key_score = compute_generated_asset_sheet_key_color_score( + pixel, + [ + options.key_color.red, + options.key_color.green, + options.key_color.blue, + ], + ); + let blend = clamp_generated_asset_sheet_unit((key_score * 1.25).max(0.36)); + let red = lerp_generated_asset_sheet_channel( + pixels[offset] as f32, + replacement.0 as f32, + blend, + ); + let green = lerp_generated_asset_sheet_channel( + pixels[offset + 1] as f32, + replacement.1 as f32, + blend, + ); + let blue = lerp_generated_asset_sheet_channel( + pixels[offset + 2] as f32, + replacement.2 as f32, + blend, + ); + let defringed = suppress_generated_asset_sheet_key_color_fringe( + [red, green, blue], + [ + replacement.0 as f32, + replacement.1 as f32, + replacement.2 as f32, + ], + key_score, + options.key_color, + ); + ( + defringed[0].round().clamp(0.0, 255.0) as u8, + defringed[1].round().clamp(0.0, 255.0) as u8, + defringed[2].round().clamp(0.0, 255.0) as u8, + ) + }; if next_red != pixels[offset] || next_green != pixels[offset + 1] || next_blue != pixels[offset + 2] 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 6473f756..40a0c8f0 100644 --- a/server-rs/crates/platform-image/tests/generated_asset_sheets.rs +++ b/server-rs/crates/platform-image/tests/generated_asset_sheets.rs @@ -206,6 +206,78 @@ fn generated_asset_sheet_magenta_edge_matte_does_not_remove_white_subject() { ); } +#[test] +fn generated_asset_sheet_magenta_alpha_defringes_pink_halo() { + let mut sheet = RgbaImage::from_pixel(24, 24, Rgba([255, 0, 255, 255])); + for y in 7..17 { + for x in 7..17 { + sheet.put_pixel(x, y, Rgba([198, 170, 120, 255])); + } + } + for y in 6..18 { + sheet.put_pixel(6, y, Rgba([226, 26, 218, 220])); + sheet.put_pixel(17, y, Rgba([226, 26, 218, 220])); + } + for x in 6..18 { + sheet.put_pixel(x, 6, Rgba([226, 26, 218, 220])); + sheet.put_pixel(x, 17, Rgba([226, 26, 218, 220])); + } + + let cleaned = apply_generated_asset_sheet_alpha_with_options( + DynamicImage::ImageRgba8(sheet), + GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(), + ) + .to_rgba8(); + let edge = cleaned.get_pixel(6, 12).0; + + assert_eq!(cleaned.get_pixel(0, 0).0[3], 0); + assert_eq!(cleaned.get_pixel(12, 12).0, [198, 170, 120, 255]); + if edge[3] > 0 { + assert!( + edge[0].saturating_sub(edge[1]) <= 76, + "红色 key 通道残留过强:{edge:?}" + ); + assert!( + edge[2].saturating_sub(edge[1]) <= 76, + "蓝色 key 通道残留过强:{edge:?}" + ); + } +} + +#[test] +fn generated_asset_sheet_magenta_edge_matte_defringes_bottom_shadow() { + let mut sheet = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0])); + for y in 8..18 { + for x in 10..22 { + sheet.put_pixel(x, y, Rgba([202, 176, 126, 255])); + } + } + for y in 18..22 { + for x in 9..23 { + sheet.put_pixel(x, y, Rgba([224, 30, 220, 186])); + } + } + + let cleaned = crop_generated_asset_sheet_view_edge_matte_with_options( + DynamicImage::ImageRgba8(sheet), + GeneratedAssetSheetAlphaOptions::jump_hop_magenta_screen(), + ) + .to_rgba8(); + + assert!( + cleaned + .pixels() + .any(|pixel| pixel.0 == [202, 176, 126, 255]) + ); + assert!( + !cleaned.pixels().any(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha > 0 && red > 200 && blue > 200 && green < 96 + }), + "底部洋红残影应被删除或去彩边" + ); +} + #[test] fn generated_asset_sheet_view_edge_matte_trims_transparent_border() { let mut sheet = RgbaImage::from_pixel(20, 20, Rgba([0, 0, 0, 0]));