feat(desktop): 办公室换 AI 立绘素材(告别手绘 SVG 小人)

用户三次反馈手绘 SVG 不好看 —— 是对的,我不是插画师,程序化矢量画不出
商用品质。技术栈换来换去(3D/Lottie/Spine)都绕不开这点:卡的是美术。

素材:用户用 Nano Banana 生成,链式参考锁风格(先出主管定基准,其余 5 个
+ 桌子 + 房间都拿它当参考图)。版权归用户账号,干净。

为什么最终没上 Spine(用户原本要求照搬 ai-office-react 的实现):
- Spine 运行时不是 MIT。原文要求"每个用户须自购 Spine Editor 授权",
  对要卖的产品等于不可行;走另一条要我们买 $69/$330 按座授权。
- 那套 chibi-stickers 素材是 Spine 官方示例(皮肤名里有 spineboy 和
  Spine 团队成员名),只授权用于评估学习。参考项目 README 自己写着
  "注意素材版权问题!"。
有了立绘就不需要骨骼动画:走位/时序/状态全是现成的 CSS 关键帧,
Chibi 那个口直接换贴图,零 Pixi 零 Spine 零授权。

抠像(scratchpad/art/key.py):只抠「与画面边缘连通」的绿。
绿衬衫角色实测 (41,184,60) vs 绿幕 (9,240,2) —— 两者都"绿压过红蓝",
按颜色阈值必然把衣服抠出洞(实测躯干 81.3% 被误杀);连通域一刀切干净,
对绿植/绿屏同样安全。含软边 + 去绿边,7 张残留绿毛均为 0 像素。

体积:PNG 540KB → WebP 117KB(-78%,平涂+透明是 WebP 强项)。

桌子按「桌面」锚点而非底边:底边贴地时桌宽 200 会让显示器顶到 y=-136
糊住人脸。按桌面对齐后桌子下半截自然超出脚线 —— 这是对的,桌子比人更
靠近镜头本就该更低。铭牌/副标题都压在挡板内(实测 local -27~+23),
掉出去会骑在下边线上被切半截。状态辉光改画在贴图之下、用径向渐变,
硬边色斑糊在显示器背壳上物理讲不通也像贴纸。

手绘版保留为 sprite 缺省时的回落,未删。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-16 10:34:05 +08:00
parent c2811e79a3
commit 8b19c572f4
8 changed files with 72 additions and 23 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -1,6 +1,16 @@
import { useMemo, type CSSProperties } from "react";
import { deriveTeam, teamNow, type RunPhase, type TeamSeat } from "../lib/run";
import type { ExecEvent } from "../lib/api";
import leadSprite from "../assets/office/lead.webp";
import deskSprite from "../assets/office/desk.webp";
import a0 from "../assets/office/a0.webp";
import a1 from "../assets/office/a1.webp";
import a2 from "../assets/office/a2.webp";
import a3 from "../assets/office/a3.webp";
import a4 from "../assets/office/a4.webp";
// 专家立绘:按工位序号循环取,同一次运行里每个专家稳定对应同一个人。
const ACTORS = [a0, a1, a2, a3, a4];
// 2D 卡通办公室:把多智能体协调演成一出办公室短剧。
// 专家被派发 → 从门口走进来 → 在工位敲键盘 → 干完起身走到协调者桌前递简报 → 回工位。
@@ -13,7 +23,7 @@ import type { ExecEvent } from "../lib/api";
// 动效=状态编码,不是装饰;并守 prefers-reduced-motion。
const VB_W = 720;
const VB_H = 460;
const VB_H = 510;
const FLOOR_Y = 200;
const OUTLINE = "#2f2a26";
@@ -103,6 +113,27 @@ function Hair({ color, style }: { color: string; style: number }) {
);
}
// 立绘贴图:AI 生成 + 抠绿幕的角色。给了 sprite 就用贴图,没给则回落到手绘。
// 贴图是扁的:手臂动不了、表情换不了 —— 状态改由显示器辉光 / 头顶气泡 / 铭牌圆点表达(本来就有)。
// 局部坐标与手绘版对齐:脚在原点、身高 150、宽约 72。
const SPRITE_H = 168;
const SPRITE_W = 81;
function ChibiSprite({ sprite, delay }: { sprite: string; delay: number }) {
return (
<g className="sdx-breathe" style={{ animationDelay: `${delay}s` }}>
<image
href={sprite}
x={-SPRITE_W / 2}
y={-SPRITE_H}
width={SPRITE_W}
height={SPRITE_H}
preserveAspectRatio="xMidYMax meet"
/>
</g>
);
}
// typing: undefined=不敲;{count,delay}=敲 count 次(count=Infinity 即一直敲)
function Chibi({
shirt,
@@ -112,6 +143,7 @@ function Chibi({
happy,
sad,
delay,
sprite,
}: {
shirt: string;
hair: string;
@@ -120,7 +152,9 @@ function Chibi({
happy?: boolean;
sad?: boolean;
delay: number;
sprite?: string;
}) {
if (sprite) return <ChibiSprite sprite={sprite} delay={delay} />;
const armStyle: Vars | undefined = typing
? { animationName: "sdx-type", animationDuration: `${TYPE_MS}ms`, animationTimingFunction: "ease-in-out", animationIterationCount: typing.count === Infinity ? "infinite" : typing.count, animationDelay: `${typing.delay}s` }
: undefined;
@@ -212,32 +246,35 @@ function Paper({ dur, delay }: { dur: number; delay: number }) {
}
// 工位(桌子层):白桌 + 显示器辉光(状态色)+ 桌面铭牌。桌子挡住下半身,人只露上半身。
// 桌子贴图的解剖(量出来的,单位=贴图内百分比):
// 显示器 x 56%~94% / y 0~37%;咖啡杯 x 13%~26%;桌面 y 42%~56%;挡板 y 58%~94%。
// 锚点按「桌面」而不是「底边」:底边贴地的话桌宽 200 时显示器会顶到 y=-136 糊住人脸。
// 按桌面对齐后桌子下半截自然超出脚线——这是对的,桌子比人更靠近镜头,本就该画得更低。
const DESK_RATIO = 376 / 260;
const DESK_SURFACE_FRAC = 0.44; // 桌面在贴图里的高度位置
const DESK_MON_CX = 0.75; // 显示器中心的横向位置(辉光叠在这儿)
const DESK_MON_CY = 0.2;
function Deskette({ name, sub, tone, glow, w = 200 }: { name: string; sub: string; tone: string; glow?: boolean; w?: number }) {
const hw = w / 2;
const dh = w / DESK_RATIO;
const top = -46 - DESK_SURFACE_FRAC * dh; // 让桌面落在 y=-46(与原手绘桌一致)
const monX = -hw + DESK_MON_CX * w;
const monY = top + DESK_MON_CY * dh;
return (
<g>
{/* 显示器(背对我们,屏幕光溢到桌面 */}
<g transform={`translate(${hw - 34}, 0)`}>
<ellipse cx={0} cy={-46} rx={26} ry={9} fill={tone} opacity={glow ? 0.35 : 0.18} className={glow ? "sdx-glow" : undefined} />
<rect x={-3} y={-58} width={6} height={12} fill="#c9c2b8" stroke={OUTLINE} strokeWidth={2} />
<rect x={-21} y={-84} width={42} height={28} rx={4} fill="#eceae6" stroke={OUTLINE} strokeWidth={2.4} />
<rect x={-17} y={-80} width={34} height={20} rx={2} fill={tone} opacity={glow ? 0.8 : 0.45} />
</g>
{/* 桌面 + 桌沿 */}
<rect x={-hw} y={-46} width={w} height={9} rx={4} fill={DESK_TOP} stroke={OUTLINE} strokeWidth={2.6} />
<rect x={-hw + 8} y={-38} width={w - 16} height={36} rx={3} fill={DESK_FRONT} stroke={OUTLINE} strokeWidth={2.4} />
{/* 咖啡杯 */}
<g transform={`translate(${-hw + 26}, -46)`}>
<rect x={-6} y={-13} width={12} height={13} rx={2.5} fill="#fff" stroke={OUTLINE} strokeWidth={2} />
<path d="M 6 -10 q 6 3 0 7" fill="none" stroke={OUTLINE} strokeWidth={2} />
</g>
{/* 铭牌 */}
<rect x={-hw + 18} y={-32} width={w - 36} height={24} rx={4} fill="#fff" stroke={OUTLINE} strokeWidth={2.2} />
<circle cx={-hw + 31} cy={-20} r={4.2} fill={tone} />
<text x={-hw + 42} y={-15} fontSize={17} fontWeight={600} fill="#2f2a26" style={{ fontFamily: "system-ui, sans-serif" }}>
{fitText(name, (w - 62) / 17)}
{/* 屏幕光溢到桌面:画在贴图「之下」,是光晕不是色斑。
贴图里的显示器背壳朝着我们,把状态色直接糊在背壳上物理讲不通、也像贴纸。 */}
<ellipse cx={monX} cy={monY + dh * 0.2} rx={w * 0.19} ry={dh * 0.22} fill={`url(#sdx-glow-${tone.slice(1)})`} opacity={glow ? 1 : 0.45} className={glow ? "sdx-glow" : undefined} />
<image href={deskSprite} x={-hw} y={top} width={w} height={dh} preserveAspectRatio="xMidYMid meet" />
{/* 铭牌 + 副标题:都放在挡板内(实测挡板 = local -27~+23),
掉到挡板外会骑在桌子下边线上、被切成半截。 */}
<rect x={-hw + 18} y={-24} width={w - 36} height={23} rx={4} fill="#fff" stroke={OUTLINE} strokeWidth={2.2} />
<circle cx={-hw + 31} cy={-12.5} r={4.2} fill={tone} />
<text x={-hw + 42} y={-7} fontSize={16} fontWeight={600} fill="#2f2a26" style={{ fontFamily: "system-ui, sans-serif" }}>
{fitText(name, (w - 62) / 16)}
</text>
<text x={0} y={11} fontSize={14} textAnchor="middle" fill="#6b6156" style={{ fontFamily: "system-ui, sans-serif" }}>
<text x={0} y={16} fontSize={13} textAnchor="middle" fill="#6b6156" style={{ fontFamily: "system-ui, sans-serif" }}>
{sub}
</text>
</g>
@@ -299,6 +336,7 @@ function Actor({ cue, reduced, hasLead }: { cue: Cue; reduced: boolean; hasLead:
happy={finished}
sad={seat.status === "error"}
delay={index * 0.23}
sprite={ACTORS[index % ACTORS.length]}
/>
{handoff && !reduced && <Paper dur={HANDOFF_MS} delay={cue.handoffAt} />}
{cue.bubble && <Bubble status={seat.status} delay={cue.bubbleAt} />}
@@ -388,7 +426,7 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
// 排位:≤5 人一排(前排),否则拆两排,后排小、前排大 —— 用缩放造纵深。
// 两排时后排不出头顶气泡:气泡很高,会顶到协调者的桌子;状态仍由显示器辉光和铭牌圆点表达。
const rows: TeamSeat[][] = seats.length <= 5 ? [seats] : [seats.slice(0, Math.ceil(seats.length / 2)), seats.slice(Math.ceil(seats.length / 2))];
const rowCfg = rows.length === 1 ? [{ y: 430, s: 1 }] : [{ y: 322, s: 0.6 }, { y: 438, s: 0.82 }];
const rowCfg = rows.length === 1 ? [{ y: 452, s: 1 }] : [{ y: 330, s: 0.58 }, { y: 460, s: 0.8 }];
// 档期:直播时事件到哪演到哪(延迟一律 0);复盘时按时间戳排,整段压进 PLAY_MS。
const rate = streaming ? 0 : Math.min(1, PLAY_MS / span);
@@ -472,6 +510,16 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
`}</style>
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="w-full shrink-0 rounded-lg" role="img" aria-label="办公室视图:协调者与专家工位">
{/* 屏幕光的柔性衰减:硬边椭圆看着像色斑,径向渐变才像光 */}
<defs>
{[RUN, OK, ERR].map((c) => (
<radialGradient key={c} id={`sdx-glow-${c.slice(1)}`}>
<stop offset="0%" stopColor={c} stopOpacity={0.85} />
<stop offset="55%" stopColor={c} stopOpacity={0.35} />
<stop offset="100%" stopColor={c} stopOpacity={0} />
</radialGradient>
))}
</defs>
<Room />
{/* 白板:协调者的思考/综合过程 */}
@@ -524,6 +572,7 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
happy={lead.status === "done"}
sad={lead.status === "error"}
delay={0}
sprite={leadSprite}
/>
<Deskette name={lead.label} sub="协调者" tone={leadTone} glow={streaming} w={230} />
</g>