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>
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
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>
|
||||
|
||||