feat: add frame 172 avatar styles

This commit is contained in:
Наер Михаил Александрович 2026-08-26 23:29:43 +03:00
parent 734a99e4fb
commit 288a6ce31d
13 changed files with 403 additions and 73 deletions

View File

@ -1224,6 +1224,22 @@ input {
opacity: 1; opacity: 1;
stroke: none !important; stroke: none !important;
} }
.is-stroke-vector .avatar-head,
.is-stroke-vector .avatar-eye {
fill: none;
stroke: var(--avatar-body-color, #ff9d45);
stroke-width: var(--avatar-outline-width, 12);
}
.is-stroke-vector .avatar-headset {
fill: none !important;
stroke: var(--avatar-body-color, #ff9d45) !important;
stroke-width: var(--avatar-outline-width, 12) !important;
vector-effect: non-scaling-stroke;
}
.is-source-artwork .avatar-head,
.is-source-artwork .avatar-eye {
vector-effect: non-scaling-stroke;
}
.avatar-eye, .avatar-eye,
.preview-eye { .preview-eye {
fill: var(--avatar-eye-color, #ffffff); fill: var(--avatar-eye-color, #ffffff);

View File

@ -0,0 +1,13 @@
<svg width="1145" height="936" viewBox="0 0 1145 936" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_111_103)">
<path d="M575.918 77.6582C790.091 77.6582 957.918 219.971 957.918 421.873V750.155C957.918 869.96 889.26 936.3 829.358 936.3C796.972 936.3 769.759 929.42 750.564 908.05C731.311 886.61 719.742 850.188 719.742 790.155V752.506C719.742 746.913 717.841 742.54 714.827 739.563C711.834 736.607 707.861 735.146 703.918 735.146C699.975 735.146 696.002 736.607 693.009 739.563C689.995 742.54 688.094 746.913 688.094 752.506V790.155C688.094 850.165 675.903 886.59 656.025 908.03C636.192 929.42 608.323 936.3 575.918 936.3C543.513 936.3 515.644 929.42 495.811 908.03C475.933 886.59 463.742 850.165 463.742 790.155V752.506C463.742 746.913 461.841 742.54 458.827 739.563C455.834 736.607 451.861 735.146 447.918 735.146C443.975 735.146 440.002 736.607 437.009 739.563C433.995 742.54 432.094 746.913 432.094 752.506V790.155C432.094 850.188 420.525 886.61 401.271 908.05C382.077 929.42 354.864 936.3 322.478 936.3C262.575 936.3 193.918 869.96 193.918 750.155V421.873C193.918 219.971 361.745 77.6582 575.918 77.6582Z" fill="#FFA200"/>
<path d="M513.851 328.033L517.727 328.679L521.5 329.743L525.144 331.258L528.564 333.163L531.787 335.451L534.687 338.093L537.309 341.105L539.599 344.361L541.472 347.883L542.991 351.631L544.058 355.543L544.671 359.548L544.835 363.674L544.768 369.436L544.699 375.238L544.631 381.075L544.563 386.912L544.495 392.783V392.795L544.461 398.666L544.427 404.56L544.358 410.465V410.476L544.324 416.381V422.298L544.29 428.226L544.256 434.131V463.85L544.29 469.789V475.693L544.323 481.576L544.126 485.812L543.445 489.931L542.313 493.974L540.762 497.822L538.753 501.451L536.382 504.862L533.662 507.97L530.63 510.712L527.298 513.106L523.704 515.113L519.965 516.66L516.017 517.794L512.004 518.474L507.931 518.7L503.867 518.442L499.812 517.729L495.864 516.563L492.073 514.976L488.479 512.969L485.078 510.538L481.994 507.746L479.207 504.666L476.778 501.267L474.701 497.6L473.079 493.739L471.846 489.715L471.101 485.602L470.771 481.391L470.67 475.557L470.603 469.651L470.568 463.758V463.747L470.5 457.842V446.066L470.534 440.127L470.568 434.233L470.637 428.328L470.705 422.457L470.704 422.456L470.807 416.575L470.943 410.705L471.079 404.833L471.25 398.975L471.421 393.104L471.42 393.103L471.625 387.279L471.829 381.476L472.068 375.697L472.341 369.896L472.61 364.192L473.069 360.059L473.946 356.03L475.243 352.139L476.959 348.382L479.104 344.807L481.57 341.528L484.404 338.466L487.484 335.776L490.854 333.443L494.425 331.463L498.147 329.909L501.991 328.778L505.943 328.098L509.886 327.84L513.851 328.033Z" fill="#222222"/>
<path d="M607.512 416.381L607.478 410.476V410.465L607.409 404.56L607.375 398.666L607.342 392.795L607.341 392.783L607.272 386.912L607.205 381.075L607.137 375.238L607.068 369.436L607 363.674L607.164 359.548L607.777 355.543L608.844 351.631L610.363 347.883L612.236 344.361L614.526 341.105L617.148 338.093L620.048 335.451L623.271 333.163L626.689 331.26L630.336 329.742L634.108 328.679L637.984 328.033L641.949 327.84L645.892 328.098L649.844 328.778L653.691 329.91L657.41 331.463L660.98 333.443L664.351 335.776L667.431 338.466L670.265 341.528L672.73 344.807L674.876 348.382L676.592 352.139L677.889 356.03L678.765 360.05L679.224 364.178L679.495 369.896L679.768 375.685V375.686L680.006 381.476V381.477L680.211 387.279L680.415 393.103V393.104L680.586 398.975L680.756 404.833V404.834L680.893 410.705L681.029 416.575L681.131 422.456V422.457L681.199 428.328L681.268 434.233L681.302 440.127L681.336 446.066V457.864L681.268 463.747V463.758L681.233 469.651L681.165 475.557L681.064 481.391L680.734 485.602L679.989 489.715L678.756 493.739L677.134 497.6L675.057 501.267L672.624 504.671L669.841 507.746L666.757 510.538L663.355 512.969L659.762 514.976L655.969 516.564L652.022 517.729L647.968 518.442L643.904 518.7L639.831 518.474L635.82 517.794L631.87 516.66L628.131 515.113L624.537 513.106L621.205 510.712L618.173 507.97L615.453 504.862L613.082 501.451L611.073 497.822L609.521 493.974L608.39 489.931L607.709 485.812L607.512 481.576L607.546 475.693V469.789L607.58 463.85V434.131L607.546 428.226L607.512 422.286V416.381Z" fill="#222222"/>
<path d="M573.131 2.5C766.59 2.5 935.345 106.87 1005.23 263.626L1005.72 264.727L1006.89 265.028C1037.19 272.866 1065.07 287.453 1087.74 307.729C1123 339.28 1142.76 382.009 1142.76 426.496C1142.76 470.983 1123 513.712 1087.74 545.264C1069.62 561.472 1048.17 574.045 1024.81 582.441L1023.68 582.848L1023.29 583.983C975.403 723.655 837.05 785.334 640.44 785.334C629.118 785.334 618.308 781.308 610.377 774.214C602.455 767.128 598.067 757.581 598.067 747.693C598.067 737.806 602.455 728.258 610.377 721.172C618.308 714.078 629.119 710.052 640.44 710.052C736.047 710.052 815.095 692.436 870.329 658.212C925.687 623.911 957.052 572.947 957.052 506.778V426.496C957.052 221.734 786.475 77.7812 573.131 77.7812C359.786 77.7812 189.21 221.734 189.21 426.496V594.578C140.114 593.996 93.2163 576.298 58.5244 545.264C23.2533 513.712 3.50003 470.983 3.5 426.496C3.5 382.009 23.2533 339.28 58.5244 307.729C81.1908 287.452 109.069 272.865 139.376 265.027L140.543 264.726L141.033 263.625C210.918 106.869 379.672 2.5 573.131 2.5Z" fill="#222222"/>
</g>
<defs>
<clipPath id="clip0_111_103">
<rect width="1145" height="936" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -0,0 +1,6 @@
<svg width="1145" height="1079" viewBox="0 0 1145 1079" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M574.918 77.6582C789.091 77.6582 956.918 219.971 956.918 421.873V890.155C956.918 1009.96 888.26 1076.3 828.358 1076.3C795.972 1076.3 768.759 1069.42 749.564 1048.05C730.311 1026.61 718.742 990.188 718.742 930.155V892.506C718.742 886.913 716.841 882.54 713.827 879.563C710.834 876.607 706.861 875.146 702.918 875.146C698.975 875.146 695.002 876.607 692.009 879.563C688.995 882.54 687.094 886.913 687.094 892.506V930.155C687.094 990.165 674.903 1026.59 655.025 1048.03C635.192 1069.42 607.323 1076.3 574.918 1076.3C542.513 1076.3 514.644 1069.42 494.811 1048.03C474.933 1026.59 462.742 990.165 462.742 930.155V892.506C462.742 886.913 460.841 882.54 457.827 879.563C454.834 876.607 450.861 875.146 446.918 875.146C442.975 875.146 439.002 876.607 436.009 879.563C432.995 882.54 431.094 886.913 431.094 892.506V930.155C431.094 990.188 419.525 1026.61 400.271 1048.05C381.077 1069.42 353.864 1076.3 321.478 1076.3C261.575 1076.3 192.918 1009.96 192.918 890.155V421.873C192.918 219.971 360.745 77.6582 574.918 77.6582Z" stroke="white" stroke-width="4"/>
<path d="M512.851 330.032L516.727 330.678L520.5 331.742L524.144 333.257L527.564 335.162L530.787 337.45L533.687 340.092L536.309 343.104L538.599 346.36L540.472 349.882L541.991 353.63L543.058 357.542L543.671 361.547L543.835 365.673L543.768 371.435L543.699 377.237L543.631 383.074L543.563 388.911L543.495 394.782V394.794L543.461 400.665L543.427 406.559L543.358 412.464V412.475L543.324 418.38V424.297L543.29 430.225L543.256 436.13V465.849L543.29 471.788V477.692L543.323 483.575L543.126 487.811L542.445 491.93L541.313 495.973L539.762 499.821L537.753 503.45L535.382 506.861L532.662 509.969L529.63 512.711L526.298 515.105L522.704 517.112L518.965 518.659L515.017 519.793L511.004 520.473L506.931 520.699L502.867 520.441L498.812 519.728L494.864 518.562L491.073 516.975L487.479 514.968L484.078 512.537L480.994 509.745L478.207 506.665L475.778 503.266L473.701 499.599L472.079 495.738L470.846 491.714L470.101 487.601L469.771 483.39L469.67 477.556H469.671L469.603 471.65L469.568 465.757V465.746L469.5 459.841V448.065L469.534 442.126L469.568 436.232L469.637 430.327L469.705 424.456L469.704 424.455L469.807 418.574L469.943 412.704L470.079 406.832L470.25 400.974L470.421 395.103L470.42 395.102L470.625 389.278L470.829 383.475L471.068 377.696L471.341 371.895L471.61 366.191L472.069 362.058L472.946 358.029L474.243 354.138L475.959 350.381L478.104 346.806L480.57 343.527L483.404 340.465L486.484 337.775L489.854 335.442L493.425 333.462L497.147 331.908L500.991 330.777L504.943 330.097L508.886 329.839L512.851 330.032Z" stroke="white" stroke-width="4"/>
<path d="M606.512 418.38L606.478 412.475V412.464L606.409 406.559L606.375 400.665L606.342 394.794L606.341 394.782L606.272 388.911L606.205 383.074L606.137 377.237L606.068 371.435L606 365.673L606.164 361.547L606.777 357.542L607.844 353.63L609.363 349.882L611.236 346.36L613.526 343.104L616.148 340.092L619.048 337.45L622.271 335.162L625.689 333.259L629.336 331.741L633.108 330.678L636.984 330.032L640.949 329.839L644.892 330.097L648.844 330.777L652.691 331.909L656.41 333.462L659.98 335.442L663.351 337.775L666.431 340.465L669.265 343.527L671.73 346.806L673.876 350.381L675.592 354.138L676.889 358.029L677.765 362.049L678.224 366.177L678.495 371.895L678.768 377.684V377.685L679.006 383.475V383.476L679.211 389.278L679.415 395.102V395.103L679.586 400.974L679.756 406.832V406.833L679.893 412.704L680.029 418.574L680.131 424.455V424.456L680.199 430.327L680.268 436.232L680.302 442.126L680.336 448.065V459.863L680.268 465.746V465.757L680.233 471.65L680.165 477.556L680.064 483.39L679.734 487.601L678.989 491.714L677.756 495.738L676.134 499.599L674.057 503.266L671.624 506.67L668.841 509.745L665.757 512.537L662.355 514.968L658.762 516.975L654.969 518.563L651.022 519.728L646.968 520.441L642.904 520.699L638.831 520.473L634.82 519.793L630.87 518.659L627.131 517.112L623.537 515.105L620.205 512.711L617.173 509.969L614.453 506.861L612.082 503.45L610.073 499.821L608.521 495.973L607.39 491.93L606.709 487.811L606.512 483.575L606.546 477.692V471.788L606.58 465.849V436.13L606.546 430.225L606.512 424.285V418.38Z" stroke="white" stroke-width="4"/>
<path d="M572.131 2.5C765.59 2.5 934.345 106.87 1004.23 263.626L1004.72 264.727L1005.89 265.028C1036.19 272.866 1064.07 287.453 1086.74 307.729C1122 339.28 1141.76 382.009 1141.76 426.496C1141.76 470.983 1122 513.712 1086.74 545.264C1068.62 561.472 1047.17 574.045 1023.81 582.441L1022.68 582.848L1022.29 583.983C974.403 723.655 836.05 785.334 639.44 785.334C628.118 785.334 617.308 781.308 609.377 774.214C601.455 767.128 597.067 757.581 597.067 747.693C597.067 737.806 601.455 728.258 609.377 721.172C617.308 714.078 628.119 710.052 639.44 710.052C735.047 710.052 814.095 692.436 869.329 658.212C924.687 623.911 956.052 572.947 956.052 506.778V426.496C956.052 221.734 785.475 77.7812 572.131 77.7812C358.786 77.7812 188.21 221.734 188.21 426.496V594.578C139.114 593.996 92.2163 576.298 57.5244 545.264C22.2533 513.712 2.50003 470.983 2.5 426.496C2.5 382.009 22.2533 339.28 57.5244 307.729C80.1908 287.452 108.069 272.865 138.376 265.027L139.543 264.726L140.033 263.625C209.918 106.869 378.672 2.5 572.131 2.5Z" stroke="white" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -5,10 +5,7 @@ import { render } from '@testing-library/react'
import { LiveExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' import { LiveExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { poseFromExpression } from '@/features/avatar/geometry' import { poseFromExpression } from '@/features/avatar/geometry'
import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories' import { renderAvatarByStyle } from '@/features/rendering/avatarAccessories'
import { import { createRenderedColors, createRenderedScene } from '@/features/rendering/renderedScene'
createRenderedColors,
createRenderedScene,
} from '@/features/rendering/renderedScene'
import { loadStudioDocument } from '@/features/studio/studioDocument' import { loadStudioDocument } from '@/features/studio/studioDocument'
it('keeps the headset visible and transformed in a live animation preview', () => { it('keeps the headset visible and transformed in a live animation preview', () => {
@ -40,3 +37,39 @@ it('keeps the headset visible and transformed in a live animation preview', () =
expect(headset?.getAttribute('d')).toBe(geometry.frontPaths.at(-1)) expect(headset?.getAttribute('d')).toBe(geometry.frontPaths.at(-1))
expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1)) expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1))
}) })
it('keeps the stroked Frame 172 skin unfilled in a live animation preview', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(item => item.id === 'primitive-ghost-headset-stroke')!
const expression = studio.expressions.find(item => item.semanticKey === 'front-facing')!
const geometry = renderAvatarByStyle(
poseFromExpression(expression),
avatar.body.primary,
1,
avatar.renderStyle,
{ bodyNodes: avatar.body.nodes }
)
const scene = createRenderedScene(geometry)
const view = render(
<LiveExpressionPreview
scene={scene}
colors={createRenderedColors(avatar.colors)}
baseBodyColor={avatar.colors.body}
bodyNodes={avatar.body.nodes}
renderStyle={avatar.renderStyle}
id="animated-stroke-headset"
/>
)
const bodyPaths = view.container.querySelectorAll<SVGPathElement>('.preview-head')
const body = bodyPaths.item(0)
const eyes = view.container.querySelectorAll<SVGPathElement>('.preview-eye')
const headset = Array.from(bodyPaths).find(
path => path.getAttribute('d') === geometry.frontPaths.at(-1)
)!
expect(body.style.fill).toBe('none')
expect(Array.from(eyes).every(eye => eye.style.fill === 'none')).toBe(true)
expect(headset.style.fill).toBe('none')
expect(headset?.getAttribute('transform')).toBe(geometry.pathTransforms?.front.at(-1))
})

View File

@ -35,7 +35,9 @@ export type VectorAccessory = 'headset'
export type VectorRenderStyle = { export type VectorRenderStyle = {
type: 'vector' type: 'vector'
filled?: boolean filled?: boolean
strokeOnly?: boolean
accessory?: VectorAccessory accessory?: VectorAccessory
artwork?: 'frame-172-colored' | 'frame-172-stroke'
} }
export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle export type AvatarRenderStyle = VectorRenderStyle | PixelRenderStyle
export type AvatarEyeDefaults = Pick< export type AvatarEyeDefaults = Pick<
@ -78,6 +80,18 @@ export const headsetAvatarRenderStyle: VectorRenderStyle = {
filled: true, filled: true,
accessory: 'headset', accessory: 'headset',
} }
export const frame172ColoredAvatarRenderStyle: VectorRenderStyle = {
type: 'vector',
filled: true,
artwork: 'frame-172-colored',
}
export const frame172StrokeAvatarRenderStyle: VectorRenderStyle = {
type: 'vector',
strokeOnly: true,
artwork: 'frame-172-stroke',
}
export const isStrokeOnlyRenderStyle = (style: AvatarRenderStyle) =>
style.type === 'vector' && style.strokeOnly === true
const filledAvatarEyeColor = '#111316' const filledAvatarEyeColor = '#111316'
export const defaultPixelRenderStyle: PixelRenderStyle = { export const defaultPixelRenderStyle: PixelRenderStyle = {
type: 'pixel', type: 'pixel',
@ -165,6 +179,7 @@ const bundledAvatarColorMigrations: Record<string, { from: AvatarColors; to: Ava
export const simpleBundledAvatarIds = [ export const simpleBundledAvatarIds = [
'primitive-ghost', 'primitive-ghost',
'primitive-ghost-headset', 'primitive-ghost-headset',
'primitive-ghost-headset-stroke',
'primitive-apple-bite', 'primitive-apple-bite',
'primitive-lock', 'primitive-lock',
'oneworks-cat', 'oneworks-cat',
@ -220,6 +235,7 @@ const retiredBundledAvatarIds = new Set([
]) ])
const legacyBundledAvatarNames: Record<string, string> = { const legacyBundledAvatarNames: Record<string, string> = {
'primitive-ghost-headset-stroke': 'Memento · Colored',
'oneworks-cat': 'OneWorks Cat', 'oneworks-cat': 'OneWorks Cat',
'oneworks-dog': 'OneWorks Dog', 'oneworks-dog': 'OneWorks Dog',
'oneworks-bear': 'OneWorks Bear', 'oneworks-bear': 'OneWorks Bear',
@ -237,6 +253,7 @@ const primitiveBundledAvatarSpecs = [
['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9], ['primitive-apple-bite', 'Apple Bite', 'apple-bite', deslopAccentColors.red, 0.9],
['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9], ['primitive-ghost', 'Memento', 'ghost', deslopAccentColors.orange, 0.9],
['primitive-ghost-headset', 'Memento · Headset', 'ghost', deslopAccentColors.orange, 0.9], ['primitive-ghost-headset', 'Memento · Headset', 'ghost', deslopAccentColors.orange, 0.9],
['primitive-ghost-headset-stroke', 'Memento · Stroke', 'ghost', deslopAccentColors.orange, 0.9],
['primitive-lock', 'Lock', 'lock', deslopAccentColors.blue, 0.9], ['primitive-lock', 'Lock', 'lock', deslopAccentColors.blue, 0.9],
] as const ] as const
@ -830,7 +847,11 @@ const primitiveBundledAvatars: StudioAvatar[] = [
colors: { colors: {
body: color, body: color,
eyes: eyes:
surface === 'ghost' || surface === 'apple-bite' || surface === 'lock' id === 'primitive-ghost-headset'
? '#222222'
: id === 'primitive-ghost-headset-stroke'
? deslopAccentColors.orange
: surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? filledAvatarEyeColor ? filledAvatarEyeColor
: color, : color,
}, },
@ -856,7 +877,9 @@ const primitiveBundledAvatars: StudioAvatar[] = [
: { ...defaultAvatarEyes }, : { ...defaultAvatarEyes },
renderStyle: renderStyle:
id === 'primitive-ghost-headset' id === 'primitive-ghost-headset'
? { ...headsetAvatarRenderStyle } ? { ...frame172ColoredAvatarRenderStyle }
: id === 'primitive-ghost-headset-stroke'
? { ...frame172StrokeAvatarRenderStyle }
: surface === 'ghost' || surface === 'apple-bite' || surface === 'lock' : surface === 'ghost' || surface === 'apple-bite' || surface === 'lock'
? { ...filledAvatarRenderStyle } ? { ...filledAvatarRenderStyle }
: { ...defaultAvatarRenderStyle }, : { ...defaultAvatarRenderStyle },
@ -952,7 +975,11 @@ const filledBundledAvatarIds = new Set([
const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors)) const oneWorksOriginalAnimalIds = new Set(Object.keys(oneWorksBundledAnimalColors))
const filledPrimitiveAvatarColors: Record<string, AvatarColors> = { const filledPrimitiveAvatarColors: Record<string, AvatarColors> = {
'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor }, 'primitive-ghost': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor },
'primitive-ghost-headset': { body: deslopAccentColors.orange, eyes: filledAvatarEyeColor }, 'primitive-ghost-headset': { body: deslopAccentColors.orange, eyes: '#222222' },
'primitive-ghost-headset-stroke': {
body: deslopAccentColors.orange,
eyes: deslopAccentColors.orange,
},
'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor }, 'primitive-apple-bite': { body: deslopAccentColors.red, eyes: filledAvatarEyeColor },
'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor }, 'primitive-lock': { body: deslopAccentColors.blue, eyes: filledAvatarEyeColor },
} }
@ -991,7 +1018,11 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
) )
if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) { if (!visibleLibraryAvatars.some(avatar => originalSimpleBundledAvatarIds.has(avatar.id))) {
const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => { const avatarsWithCurrentColorBehavior = visibleLibraryAvatars.map(avatar => {
if (['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id)) { if (
['primitive-ghost', 'primitive-ghost-headset', 'primitive-ghost-headset-stroke'].includes(
avatar.id
)
) {
return { ...avatar, behavior: mementoBehavior(avatar.behavior) } return { ...avatar, behavior: mementoBehavior(avatar.behavior) }
} }
if (filledBundledAvatarIds.has(avatar.id)) { if (filledBundledAvatarIds.has(avatar.id)) {
@ -1122,7 +1153,9 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
? { ...avatarWithCurrentNodes, name: bundled.name } ? { ...avatarWithCurrentNodes, name: bundled.name }
: avatarWithCurrentNodes : avatarWithCurrentNodes
const avatarWithCurrentEyes = const avatarWithCurrentEyes =
['primitive-ghost', 'primitive-ghost-headset'].includes(avatarWithCurrentName.id) && ['primitive-ghost', 'primitive-ghost-headset', 'primitive-ghost-headset-stroke'].includes(
avatarWithCurrentName.id
) &&
[defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes( [defaultAvatarEyes.positionYLeft, defaultAvatarEyes.positionYLeft - 12].includes(
avatarWithCurrentName.eyes.positionYLeft avatarWithCurrentName.eyes.positionYLeft
) && ) &&
@ -1137,9 +1170,11 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight avatarWithCurrentEyes.eyes.positionXRight === defaultAvatarEyes.positionXRight
? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } } ? { ...avatarWithCurrentEyes, eyes: { ...bundled.eyes } }
: avatarWithCurrentEyes : avatarWithCurrentEyes
const avatarWithFixedColorBehavior = ['primitive-ghost', 'primitive-ghost-headset'].includes( const avatarWithFixedColorBehavior = [
avatarWithAppleBiteEyes.id 'primitive-ghost',
) 'primitive-ghost-headset',
'primitive-ghost-headset-stroke',
].includes(avatarWithAppleBiteEyes.id)
? { ? {
...avatarWithAppleBiteEyes, ...avatarWithAppleBiteEyes,
behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior), behavior: mementoBehavior(avatarWithAppleBiteEyes.behavior),
@ -1152,7 +1187,9 @@ export const ensurePrimitiveBundledAvatars = (library: AvatarLibrary): AvatarLib
: avatarWithAppleBiteEyes : avatarWithAppleBiteEyes
const avatarWithCurrentRenderStyle = const avatarWithCurrentRenderStyle =
avatarWithFixedColorBehavior.id === 'primitive-ghost-headset' avatarWithFixedColorBehavior.id === 'primitive-ghost-headset'
? { ...avatarWithFixedColorBehavior, renderStyle: { ...headsetAvatarRenderStyle } } ? { ...avatarWithFixedColorBehavior, renderStyle: { ...frame172ColoredAvatarRenderStyle } }
: avatarWithFixedColorBehavior.id === 'primitive-ghost-headset-stroke'
? { ...avatarWithFixedColorBehavior, renderStyle: { ...frame172StrokeAvatarRenderStyle } }
: filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id) : filledBundledAvatarIds.has(avatarWithFixedColorBehavior.id)
? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } } ? { ...avatarWithFixedColorBehavior, renderStyle: { ...filledAvatarRenderStyle } }
: avatarWithFixedColorBehavior : avatarWithFixedColorBehavior
@ -1238,14 +1275,22 @@ export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
const candidate = value as { const candidate = value as {
type?: unknown type?: unknown
filled?: unknown filled?: unknown
strokeOnly?: unknown
accessory?: unknown accessory?: unknown
artwork?: unknown
resolution?: unknown resolution?: unknown
} | null } | null
if (candidate?.type === 'vector') { if (candidate?.type === 'vector') {
return { return {
type: 'vector', type: 'vector',
...(candidate.filled === true ? { filled: true } : {}), ...(candidate.filled === true ? { filled: true } : {}),
...(candidate.strokeOnly === true ? { strokeOnly: true } : {}),
...(candidate.accessory === 'headset' ? { accessory: 'headset' as const } : {}), ...(candidate.accessory === 'headset' ? { accessory: 'headset' as const } : {}),
...(candidate.artwork === 'frame-172-colored'
? { artwork: 'frame-172-colored' as const }
: candidate.artwork === 'frame-172-stroke'
? { artwork: 'frame-172-stroke' as const }
: {}),
} }
} }
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') { if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {

View File

@ -22,6 +22,7 @@ import {
type AvatarColors, type AvatarColors,
type AvatarEyeDefaults, type AvatarEyeDefaults,
type AvatarRenderStyle, type AvatarRenderStyle,
isStrokeOnlyRenderStyle,
} from '@/features/avatar/avatars' } from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body' import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing' import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
@ -30,6 +31,7 @@ import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces' import { type SurfaceConfig } from '@/features/avatar/surfaces'
import { import {
avatarBodyOutlineWidth, avatarBodyOutlineWidth,
avatarOutlineWidth,
resolveAvatarNodeColor, resolveAvatarNodeColor,
useAvatarBodyColorOverride, useAvatarBodyColorOverride,
useRenderedSceneNodeOrder, useRenderedSceneNodeOrder,
@ -84,11 +86,17 @@ export function ExpressionPreview({
id: string id: string
}) { }) {
const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes, renderStyle) const geometry = getPreviewGeometry(expression, surface, bodyNodes, avatarEyes, renderStyle)
const resolvedColors = resolveColors(expression, colors) const outlineWidth = avatarOutlineWidth(renderStyle)
const outlineWidth = avatarBodyOutlineWidth()
const filled = renderStyle.type === 'vector' && renderStyle.filled === true const filled = renderStyle.type === 'vector' && renderStyle.filled === true
const bodyFill = filled ? resolvedColors.body : 'var(--avatar-interior-color, #ffffff)' const strokeOnly = isStrokeOnlyRenderStyle(renderStyle)
const eyeFill = resolvedColors.eyes const sourceArtwork = renderStyle.type === 'vector' && Boolean(renderStyle.artwork)
const resolvedColors = strokeOnly ? colors : resolveColors(expression, colors)
const bodyFill = strokeOnly
? 'none'
: filled
? resolvedColors.body
: 'var(--avatar-interior-color, #ffffff)'
const eyeFill = strokeOnly ? 'none' : resolvedColors.eyes
const nodeColor = (id: string | null | undefined) => const nodeColor = (id: string | null | undefined) =>
resolveAvatarNodeColor({ resolveAvatarNodeColor({
nodeColor: bodyNodes.find(node => node.id === id)?.color, nodeColor: bodyNodes.find(node => node.id === id)?.color,
@ -121,7 +129,7 @@ export function ExpressionPreview({
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true"> <svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs> <defs>
<clipPath id={clipId}> <clipPath id={clipId}>
<path d={geometry.headPath} /> <path d={geometry.headPath} transform={geometry.pathTransforms?.head} />
</clipPath> </clipPath>
</defs> </defs>
{geometry.backPaths.map((pathValue, index) => { {geometry.backPaths.map((pathValue, index) => {
@ -144,40 +152,58 @@ export function ExpressionPreview({
<path <path
className="preview-head" className="preview-head"
d={geometry.headPath} d={geometry.headPath}
transform={geometry.pathTransforms?.head}
style={{ style={{
fill: bodyFill, fill: bodyFill,
stroke: resolvedColors.body, stroke: resolvedColors.body,
strokeWidth: outlineWidth, strokeWidth: outlineWidth,
strokeLinejoin: 'round', strokeLinejoin: 'round',
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}} }}
/> />
<g clipPath={`url(#${clipId})`}> <g clipPath={`url(#${clipId})`}>
<path <path
className="preview-eye" className="preview-eye"
d={geometry.leftPath} d={geometry.leftPath}
transform={geometry.pathTransforms?.left}
opacity={geometry.leftVisible ? 1 : 0} opacity={geometry.leftVisible ? 1 : 0}
style={{ fill: eyeFill }} style={{
fill: eyeFill,
...(strokeOnly
? { stroke: resolvedColors.eyes, strokeWidth: outlineWidth, strokeLinejoin: 'round' }
: {}),
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}}
/> />
<path <path
className="preview-eye" className="preview-eye"
d={geometry.rightPath} d={geometry.rightPath}
transform={geometry.pathTransforms?.right}
opacity={geometry.rightVisible ? 1 : 0} opacity={geometry.rightVisible ? 1 : 0}
style={{ fill: eyeFill }} style={{
fill: eyeFill,
...(strokeOnly
? { stroke: resolvedColors.eyes, strokeWidth: outlineWidth, strokeLinejoin: 'round' }
: {}),
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}}
/> />
</g> </g>
{geometry.frontPaths.map((pathValue, index) => { {geometry.frontPaths.map((pathValue, index) => {
const color = nodeColor(geometry.frontNodeIds[index]) const color = nodeColor(geometry.frontNodeIds[index])
const headset = geometry.headsetFrontIndex === index
return ( return (
<path <path
className={`preview-head${geometry.headsetFrontIndex === index ? ' avatar-headset' : ''}`} className={`preview-head${headset && !strokeOnly ? ' avatar-headset' : ''}`}
d={pathValue} d={pathValue}
transform={geometry.pathTransforms?.front[index]} transform={geometry.pathTransforms?.front[index]}
key={`front-${index}`} key={`front-${index}`}
style={{ style={{
fill: filled ? color : bodyFill, fill: headset && strokeOnly ? 'none' : filled ? color : bodyFill,
stroke: color, stroke: color,
strokeWidth: outlineWidth, strokeWidth: outlineWidth,
strokeLinejoin: 'round', strokeLinejoin: 'round',
...(headset && strokeOnly ? { vectorEffect: 'non-scaling-stroke' } : {}),
}} }}
/> />
) )
@ -219,10 +245,15 @@ export function LiveExpressionPreview({
const clipId = `live-preview-${id}` const clipId = `live-preview-${id}`
const filled = renderStyle.filled === true const filled = renderStyle.filled === true
const outlineWidth = avatarBodyOutlineWidth() const strokeOnly = isStrokeOnlyRenderStyle(renderStyle)
const outlineWidth = avatarOutlineWidth(renderStyle)
const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden const inheritBodyColor = Boolean(expressionBodyColor) || bodyColorIsOverridden
const strokeColor = strokeOnly ? baseBodyColor : colors.body
const sourceArtwork = Boolean(renderStyle.artwork)
const nodeColor = (nodeId: string | null | undefined) => const nodeColor = (nodeId: string | null | undefined) =>
inheritBodyColor strokeOnly
? baseBodyColor
: inheritBodyColor
? colors.body ? colors.body
: (bodyNodes.find(node => node.id === nodeId)?.color ?? colors.body) : (bodyNodes.find(node => node.id === nodeId)?.color ?? colors.body)
@ -230,7 +261,7 @@ export function LiveExpressionPreview({
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true"> <svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs> <defs>
<clipPath id={clipId}> <clipPath id={clipId}>
<motion.path d={scene.headPath} /> <SvgTransformPath d={scene.headPath} svgTransform={scene.headTransform} />
</clipPath> </clipPath>
</defs> </defs>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}> <motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
@ -244,7 +275,11 @@ export function LiveExpressionPreview({
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
style={{ style={{
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)', fill: strokeOnly
? 'none'
: filled
? color
: 'var(--avatar-interior-color, #ffffff)',
stroke: color, stroke: color,
strokeWidth: outlineWidth, strokeWidth: outlineWidth,
}} }}
@ -252,41 +287,66 @@ export function LiveExpressionPreview({
/> />
) )
})} })}
<motion.path <SvgTransformPath
d={scene.headPath} d={scene.headPath}
svgTransform={scene.headTransform}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
style={{ style={{
fill: filled ? colors.body : 'var(--avatar-interior-color, #ffffff)', fill: strokeOnly
stroke: colors.body, ? 'none'
: filled
? colors.body
: 'var(--avatar-interior-color, #ffffff)',
stroke: strokeColor,
strokeWidth: outlineWidth, strokeWidth: outlineWidth,
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}} }}
/> />
<g clipPath={`url(#${clipId})`}> <g clipPath={`url(#${clipId})`}>
<motion.path <SvgTransformPath
d={scene.leftPath} d={scene.leftPath}
svgTransform={scene.leftTransform}
opacity={scene.leftOpacity} opacity={scene.leftOpacity}
style={{ fill: colors.eyes }} style={{
fill: strokeOnly ? 'none' : colors.eyes,
...(strokeOnly ? { stroke: baseBodyColor, strokeWidth: outlineWidth } : {}),
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}}
/> />
<motion.path <SvgTransformPath
d={scene.rightPath} d={scene.rightPath}
svgTransform={scene.rightTransform}
opacity={scene.rightOpacity} opacity={scene.rightOpacity}
style={{ fill: colors.eyes }} style={{
fill: strokeOnly ? 'none' : colors.eyes,
...(strokeOnly ? { stroke: baseBodyColor, strokeWidth: outlineWidth } : {}),
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}}
/> />
</g> </g>
{scene.frontPaths.map((pathValue, index) => { {scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index]) const color = nodeColor(scene.frontNodeIds.current[index])
const headset = scene.headsetFrontIndex.current === index
return ( return (
<SvgTransformPath <SvgTransformPath
className={`preview-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''}`} className={`preview-head${headset && !strokeOnly ? ' avatar-headset' : ''}`}
d={pathValue} d={pathValue}
svgTransform={scene.frontTransforms[index]} svgTransform={scene.frontTransforms[index]}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
style={{ style={{
fill: filled ? color : 'var(--avatar-interior-color, #ffffff)', fill:
headset && strokeOnly
? 'none'
: strokeOnly
? 'none'
: filled
? color
: 'var(--avatar-interior-color, #ffffff)',
stroke: color, stroke: color,
strokeWidth: outlineWidth, strokeWidth: outlineWidth,
...(headset && strokeOnly ? { vectorEffect: 'non-scaling-stroke' } : {}),
}} }}
key={`front-${index}`} key={`front-${index}`}
/> />

View File

@ -55,10 +55,15 @@ describe('rendered avatar scene', () => {
) )
expect(avatar.name).toBe('Memento · Headset') expect(avatar.name).toBe('Memento · Headset')
expect(avatar.colors).toEqual({ body: '#ff9d45', eyes: '#111316' }) expect(avatar.colors).toEqual({ body: '#ff9d45', eyes: '#222222' })
expect(avatar.renderStyle).toEqual({ type: 'vector', filled: true, accessory: 'headset' }) expect(avatar.renderStyle).toEqual({
type: 'vector',
filled: true,
artwork: 'frame-172-colored',
})
expect(neutral.headsetFrontIndex).toBe(neutral.frontPaths.length - 1) expect(neutral.headsetFrontIndex).toBe(neutral.frontPaths.length - 1)
expect(neutral.frontPaths.at(-1)).toContain('M12 1.875C17.7 1.875') expect(neutral.headPath).toContain('M575.918 77.6582')
expect(neutral.frontPaths.at(-1)).toContain('M573.131 2.5')
expect(neutral.pathTransforms?.front.at(-1)).not.toBe(turned.pathTransforms?.front.at(-1)) expect(neutral.pathTransforms?.front.at(-1)).not.toBe(turned.pathTransforms?.front.at(-1))
const scene = createRenderedScene(neutral) const scene = createRenderedScene(neutral)
@ -69,6 +74,31 @@ describe('rendered avatar scene', () => {
) )
}) })
it('keeps the stroked Frame 172 skin as a separate animated avatar', () => {
const studio = loadStudioDocument({ getItem: () => null })
const avatar = studio.library.avatars.find(
item => item.id === 'primitive-ghost-headset-stroke'
)!
const geometry = renderAvatarByStyle(
poseFromExpression(defaultExpression),
avatar.body.primary as SurfaceConfig,
1,
avatar.renderStyle,
{ bodyNodes: avatar.body.nodes as BodyNode[] }
)
expect(avatar.name).toBe('Memento · Stroke')
expect(avatar.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' })
expect(avatar.renderStyle).toEqual({
type: 'vector',
strokeOnly: true,
artwork: 'frame-172-stroke',
})
expect(geometry.headsetFrontIndex).toBe(geometry.frontPaths.length - 1)
expect(geometry.headPath).toContain('M574.918 77.6582')
expect(geometry.frontPaths.at(-1)).toContain('M572.131 2.5')
})
it('keeps layer identity and hit mapping behind the scene seam', () => { it('keeps layer identity and hit mapping behind the scene seam', () => {
const node = createBodyNode('sphere', 0) const node = createBodyNode('sphere', 0)
const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, { const first = renderAvatar(poseFromExpression(defaultExpression), surfacePresets.sphere, 1, {

View File

@ -1,4 +1,6 @@
import headsetSvg from '@/assets/headset-filled.svg?raw' import headsetSvg from '@/assets/headset-filled.svg?raw'
import frame172ColoredSvg from '@/assets/frame-172-colored.svg?raw'
import frame172StrokeSvg from '@/assets/frame-172-stroke.svg?raw'
import type { AvatarRenderStyle } from '@/features/avatar/avatars' import type { AvatarRenderStyle } from '@/features/avatar/avatars'
import { import {
@ -31,6 +33,19 @@ if (pathValues.length !== 1) {
} }
const [headsetPath] = pathValues const [headsetPath] = pathValues
const svgPathValues = (svg: string) =>
[...svg.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)].map(match => match[1])
const frame172ColoredPathValues = svgPathValues(frame172ColoredSvg)
const frame172StrokePathValues = svgPathValues(frame172StrokeSvg)
if (frame172ColoredPathValues.length !== 4 || frame172StrokePathValues.length !== 4) {
throw new Error(
`Expected four paths in each Frame 172 SVG, received ${frame172ColoredPathValues.length} and ${frame172StrokePathValues.length}`
)
}
const [frame172ColoredBodyPath, , , frame172ColoredHeadsetPath] = frame172ColoredPathValues
const [frame172StrokeBodyPath, , , frame172StrokeHeadsetPath] = frame172StrokePathValues
export const identityTransform = 'matrix(1 0 0 1 0 0)' export const identityTransform = 'matrix(1 0 0 1 0 0)'
const identity: Matrix = [1, 0, 0, 1, 0, 0] const identity: Matrix = [1, 0, 0, 1, 0, 0]
@ -80,6 +95,26 @@ const headsetTransform = (pose: AvatarPose, surface: SurfaceConfig) => {
) )
} }
const frame172Transform = (pose: AvatarPose, artwork: 'frame-172-colored' | 'frame-172-stroke') => {
const expression = pose.expression
const horizontalTurn = clamp(expression.headY / 25, -1, 1)
const verticalTurn = clamp(expression.headX / 25, -1, 1)
const perspective = clamp(expression.perspective, 0.6, 1.4)
const horizontalScale = Math.max(0.38, Math.cos((expression.headY * Math.PI) / 180))
const verticalScale = Math.max(0.46, Math.cos((expression.headX * Math.PI) / 180))
const normalize =
artwork === 'frame-172-colored'
? compose(scale(0.2, 0.2), translate(-573.13, -468))
: compose(scale(0.2, 0.2), translate(-572.13, -539.5))
const poseTransform = compose(
rotate(expression.headZ),
skewY(verticalTurn * 3.2),
skewX(-horizontalTurn * 4.2),
scale(horizontalScale * perspective, verticalScale * perspective)
)
return matrixValue(compose(poseTransform, normalize))
}
export const renderAvatarByStyle = ( export const renderAvatarByStyle = (
pose: AvatarPose, pose: AvatarPose,
surface: SurfaceConfig, surface: SurfaceConfig,
@ -88,6 +123,27 @@ export const renderAvatarByStyle = (
options: RenderAvatarOptions = {} options: RenderAvatarOptions = {}
): StudioAvatarGeometry => { ): StudioAvatarGeometry => {
const geometry = renderAvatar(pose, surface, blink, options) const geometry = renderAvatar(pose, surface, blink, options)
if (renderStyle.type === 'vector' && renderStyle.artwork) {
const colored = renderStyle.artwork === 'frame-172-colored'
const artworkTransform = frame172Transform(pose, renderStyle.artwork)
return {
...geometry,
backPaths: [],
frontPaths: [colored ? frame172ColoredHeadsetPath : frame172StrokeHeadsetPath],
backNodeIds: [],
frontNodeIds: [null],
headPath: colored ? frame172ColoredBodyPath : frame172StrokeBodyPath,
wirePaths: [],
headsetFrontIndex: 0,
pathTransforms: {
back: [],
head: artworkTransform,
left: identityTransform,
right: identityTransform,
front: [artworkTransform],
},
}
}
if (renderStyle.type !== 'vector' || renderStyle.accessory !== 'headset') return geometry if (renderStyle.type !== 'vector' || renderStyle.accessory !== 'headset') return geometry
return { return {

View File

@ -1,9 +1,16 @@
import { useMotionValueEvent, type MotionValue } from 'motion/react' import { useMotionValueEvent, type MotionValue } from 'motion/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
import type { RenderedScene } from './renderedScene' import type { RenderedScene } from './renderedScene'
export const avatarBodyOutlineWidth = () => 12 export const avatarBodyOutlineWidth = () => 12
export const avatarOutlineWidth = (renderStyle: AvatarRenderStyle) =>
renderStyle.type === 'vector' && renderStyle.artwork === 'frame-172-stroke'
? 1
: renderStyle.type === 'vector' && Boolean(renderStyle.artwork)
? 2.4
: avatarBodyOutlineWidth()
const colorsMatch = (left: string, right: string) => const colorsMatch = (left: string, right: string) =>
left.trim().toLowerCase() === right.trim().toLowerCase() left.trim().toLowerCase() === right.trim().toLowerCase()

View File

@ -18,6 +18,7 @@ import {
applyAvatarEyeDefaults, applyAvatarEyeDefaults,
type AvatarEyeDefaults, type AvatarEyeDefaults,
type AvatarRenderStyle, type AvatarRenderStyle,
isStrokeOnlyRenderStyle,
} from '@/features/avatar/avatars' } from '@/features/avatar/avatars'
import { type BodyNode } from '@/features/avatar/body' import { type BodyNode } from '@/features/avatar/body'
import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing' import { scaleEye, updateEyeDimension } from '@/features/avatar/expressionEditing'
@ -579,8 +580,11 @@ export function AvatarCanvas({
backNodeIds, backNodeIds,
frontNodeIds, frontNodeIds,
headPath, headPath,
headTransform,
leftPath, leftPath,
rightPath, rightPath,
leftTransform,
rightTransform,
leftOpacity, leftOpacity,
rightOpacity, rightOpacity,
offsetX, offsetX,
@ -806,7 +810,7 @@ export function AvatarCanvas({
useEscapeToCancel(cancelDrag) useEscapeToCancel(cancelDrag)
return ( return (
<div <div
className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}${renderStyle.type === 'vector' && renderStyle.filled ? ' is-filled-vector' : ''}`} className={`avatar-wrap${renderStyle.type === 'pixel' ? ' is-pixel-rendered' : ''}${renderStyle.type === 'vector' && renderStyle.filled ? ' is-filled-vector' : ''}${isStrokeOnlyRenderStyle(renderStyle) ? ' is-stroke-vector' : ''}${renderStyle.type === 'vector' && renderStyle.artwork ? ' is-source-artwork' : ''}`}
> >
{playback && ( {playback && (
<motion.div <motion.div
@ -838,7 +842,7 @@ export function AvatarCanvas({
> >
<defs> <defs>
<clipPath id="avatar-head-clip"> <clipPath id="avatar-head-clip">
<motion.path d={headPath} /> <SvgTransformPath d={headPath} svgTransform={headTransform} />
</clipPath> </clipPath>
</defs> </defs>
<motion.g style={{ x: offsetX, y: offsetY }}> <motion.g style={{ x: offsetX, y: offsetY }}>
@ -852,9 +856,10 @@ export function AvatarCanvas({
onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])} onPointerDown={event => selectBodyPath(event, backNodeIds.current[index])}
/> />
))} ))}
<motion.path <SvgTransformPath
className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`} className={`avatar-head ${highlight === 'head' ? 'cyan-outline' : ''}`}
d={headPath} d={headPath}
svgTransform={headTransform}
onPointerDown={event => { onPointerDown={event => {
onBodyNodeSelect('primary') onBodyNodeSelect('primary')
startDrag(event) startDrag(event)
@ -865,15 +870,17 @@ export function AvatarCanvas({
wirePaths.map((pathValue, index) => ( wirePaths.map((pathValue, index) => (
<motion.path className="wire" d={pathValue} key={index} /> <motion.path className="wire" d={pathValue} key={index} />
))} ))}
<motion.path <SvgTransformPath
className={`avatar-eye ${selectedSide === -1 || highlight === 'left' || highlight === 'both' ? 'cyan-outline' : ''}`} className={`avatar-eye ${selectedSide === -1 || highlight === 'left' || highlight === 'both' ? 'cyan-outline' : ''}`}
d={leftPath} d={leftPath}
svgTransform={leftTransform}
opacity={leftOpacity} opacity={leftOpacity}
onPointerDown={event => selectEye(-1, event)} onPointerDown={event => selectEye(-1, event)}
/> />
<motion.path <SvgTransformPath
className={`avatar-eye ${selectedSide === 1 || highlight === 'right' || highlight === 'both' ? 'cyan-outline' : ''}`} className={`avatar-eye ${selectedSide === 1 || highlight === 'right' || highlight === 'both' ? 'cyan-outline' : ''}`}
d={rightPath} d={rightPath}
svgTransform={rightTransform}
opacity={rightOpacity} opacity={rightOpacity}
onPointerDown={event => selectEye(1, event)} onPointerDown={event => selectEye(1, event)}
/> />

View File

@ -31,6 +31,7 @@ describe('Studio document', () => {
const bundledAvatarNames = [ const bundledAvatarNames = [
'Memento', 'Memento',
'Memento · Headset', 'Memento · Headset',
'Memento · Stroke',
'Apple Bite', 'Apple Bite',
'Lock', 'Lock',
'Siamese', 'Siamese',
@ -80,7 +81,8 @@ describe('Studio document', () => {
.filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled) .filter(avatar => avatar.renderStyle.type !== 'vector' || !avatar.renderStyle.filled)
.every(avatar => avatar.colors.body === avatar.colors.eyes) .every(avatar => avatar.colors.body === avatar.colors.eyes)
).toBe(true) ).toBe(true)
expect(document.library.avatars.slice(0, 14).map(avatar => avatar.body.primary.type)).toEqual([ expect(document.library.avatars.slice(0, 15).map(avatar => avatar.body.primary.type)).toEqual([
'ghost',
'ghost', 'ghost',
'ghost', 'ghost',
'apple-bite', 'apple-bite',
@ -121,6 +123,13 @@ describe('Studio document', () => {
type: 'vector', type: 'vector',
filled: true, filled: true,
}) })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost-headset')?.renderStyle
).toEqual({ type: 'vector', filled: true, artwork: 'frame-172-colored' })
expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-ghost-headset-stroke')
?.renderStyle
).toEqual({ type: 'vector', strokeOnly: true, artwork: 'frame-172-stroke' })
expect( expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.renderStyle
).toEqual({ type: 'vector', filled: true }) ).toEqual({ type: 'vector', filled: true })
@ -203,7 +212,18 @@ describe('Studio document', () => {
?.body.nodes.map(node => node.color) ?.body.nodes.map(node => node.color)
).toEqual(['#a95f47', '#a95f47']) ).toEqual(['#a95f47', '#a95f47'])
const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost') const memento = document.library.avatars.find(avatar => avatar.id === 'primitive-ghost')
const headsetMemento = document.library.avatars.find(
avatar => avatar.id === 'primitive-ghost-headset'
)
const strokeMemento = document.library.avatars.find(
avatar => avatar.id === 'primitive-ghost-headset-stroke'
)
expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#111316' }) expect(memento?.colors).toEqual({ body: '#ff9d45', eyes: '#111316' })
expect(headsetMemento?.colors).toEqual({ body: '#ff9d45', eyes: '#222222' })
expect(strokeMemento?.colors).toEqual({ body: '#ff9d45', eyes: '#ff9d45' })
expect(
strokeMemento?.behavior?.sequences.some(sequence => sequence.semanticKey === 'onboarding')
).toBe(true)
expect( expect(
document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors document.library.avatars.find(avatar => avatar.id === 'primitive-apple-bite')?.colors
).toEqual({ body: '#ff5558', eyes: '#111316' }) ).toEqual({ body: '#ff5558', eyes: '#111316' })
@ -226,7 +246,14 @@ describe('Studio document', () => {
]) ])
expect( expect(
document.library.avatars document.library.avatars
.filter(avatar => !['primitive-ghost', 'primitive-ghost-headset'].includes(avatar.id)) .filter(
avatar =>
![
'primitive-ghost',
'primitive-ghost-headset',
'primitive-ghost-headset-stroke',
].includes(avatar.id)
)
.every(avatar => avatar.colors.body !== '#ff9d45') .every(avatar => avatar.colors.body !== '#ff9d45')
).toBe(true) ).toBe(true)
expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20) expect(memento?.eyes.positionYLeft).toBe(defaultAvatarEyes.positionYLeft - 20)

View File

@ -9,11 +9,11 @@ import {
ContextMenuSeparator, ContextMenuSeparator,
ContextMenuTrigger, ContextMenuTrigger,
} from '@/components/ui/context-menu' } from '@/components/ui/context-menu'
import { defaultAvatarEyes } from '@/features/avatar/avatars' import { defaultAvatarEyes, isStrokeOnlyRenderStyle } from '@/features/avatar/avatars'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace' import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { import {
avatarBodyOutlineWidth, avatarOutlineWidth,
useRenderedSceneNodeOrder, useRenderedSceneNodeOrder,
} from '@/features/rendering/avatarAppearance' } from '@/features/rendering/avatarAppearance'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas' import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
@ -33,16 +33,26 @@ function LiveAvatarPreview({
expressionBodyColor?: string expressionBodyColor?: string
}) { }) {
useRenderedSceneNodeOrder(scene) useRenderedSceneNodeOrder(scene)
const outlineWidth = avatarBodyOutlineWidth() const outlineWidth = avatarOutlineWidth(avatar.renderStyle)
const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true const filled = avatar.renderStyle.type === 'vector' && avatar.renderStyle.filled === true
const bodyColor = expressionBodyColor ? colors.body : avatar.colors.body const strokeOnly = isStrokeOnlyRenderStyle(avatar.renderStyle)
const eyeColor = expressionBodyColor ? colors.eyes : avatar.colors.eyes const sourceArtwork = avatar.renderStyle.type === 'vector' && Boolean(avatar.renderStyle.artwork)
const bodyColor = strokeOnly
? avatar.colors.body
: expressionBodyColor
? colors.body
: avatar.colors.body
const eyeColor = strokeOnly
? avatar.colors.eyes
: expressionBodyColor
? colors.eyes
: avatar.colors.eyes
const inheritBodyColor = Boolean(expressionBodyColor) const inheritBodyColor = Boolean(expressionBodyColor)
const nodeColor = (id: string | null | undefined) => const nodeColor = (id: string | null | undefined) =>
inheritBodyColor inheritBodyColor
? bodyColor ? bodyColor
: (avatar.body.nodes.find(node => node.id === id)?.color ?? bodyColor) : (avatar.body.nodes.find(node => node.id === id)?.color ?? bodyColor)
const bodyFill = filled ? bodyColor : '#ffffff' const bodyFill = strokeOnly ? 'none' : filled ? bodyColor : '#ffffff'
const clipId = `live-avatar-${avatar.id}` const clipId = `live-avatar-${avatar.id}`
if (avatar.renderStyle.type === 'pixel') { if (avatar.renderStyle.type === 'pixel') {
@ -64,7 +74,7 @@ function LiveAvatarPreview({
> >
<defs> <defs>
<clipPath id={clipId}> <clipPath id={clipId}>
<motion.path d={scene.headPath} /> <SvgTransformPath d={scene.headPath} svgTransform={scene.headTransform} />
</clipPath> </clipPath>
</defs> </defs>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}> <motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
@ -80,38 +90,56 @@ function LiveAvatarPreview({
/> />
) )
})} })}
<motion.path <SvgTransformPath
className="preview-head" className="preview-head"
d={scene.headPath} d={scene.headPath}
svgTransform={scene.headTransform}
style={{ style={{
fill: bodyFill, fill: bodyFill,
stroke: bodyColor, stroke: bodyColor,
strokeWidth: outlineWidth, strokeWidth: outlineWidth,
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}} }}
/> />
<g clipPath={`url(#${clipId})`}> <g clipPath={`url(#${clipId})`}>
<motion.path <SvgTransformPath
className="preview-eye" className="preview-eye"
d={scene.leftPath} d={scene.leftPath}
svgTransform={scene.leftTransform}
opacity={scene.leftOpacity} opacity={scene.leftOpacity}
style={{ fill: eyeColor }} style={{
fill: strokeOnly ? 'none' : eyeColor,
...(strokeOnly ? { stroke: eyeColor, strokeWidth: outlineWidth } : {}),
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}}
/> />
<motion.path <SvgTransformPath
className="preview-eye" className="preview-eye"
d={scene.rightPath} d={scene.rightPath}
svgTransform={scene.rightTransform}
opacity={scene.rightOpacity} opacity={scene.rightOpacity}
style={{ fill: eyeColor }} style={{
fill: strokeOnly ? 'none' : eyeColor,
...(strokeOnly ? { stroke: eyeColor, strokeWidth: outlineWidth } : {}),
...(sourceArtwork ? { vectorEffect: 'non-scaling-stroke' } : {}),
}}
/> />
</g> </g>
{scene.frontPaths.map((pathValue, index) => { {scene.frontPaths.map((pathValue, index) => {
const color = nodeColor(scene.frontNodeIds.current[index]) const color = nodeColor(scene.frontNodeIds.current[index])
const headset = scene.headsetFrontIndex.current === index
return ( return (
<SvgTransformPath <SvgTransformPath
className={`preview-head${scene.headsetFrontIndex.current === index ? ' avatar-headset' : ''}`} className={`preview-head${headset && !strokeOnly ? ' avatar-headset' : ''}`}
d={pathValue} d={pathValue}
svgTransform={scene.frontTransforms[index]} svgTransform={scene.frontTransforms[index]}
key={`front-${index}`} key={`front-${index}`}
style={{ fill: filled ? color : bodyFill, stroke: color, strokeWidth: outlineWidth }} style={{
fill: strokeOnly ? 'none' : filled ? color : bodyFill,
stroke: color,
strokeWidth: outlineWidth,
...(headset && strokeOnly ? { vectorEffect: 'non-scaling-stroke' } : {}),
}}
/> />
) )
})} })}

View File

@ -6,7 +6,8 @@ import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { defaultExpression } from '@/features/avatar/presets' import { defaultExpression } from '@/features/avatar/presets'
import { avatarBodyOutlineWidth } from '@/features/rendering/avatarAppearance' import { isStrokeOnlyRenderStyle } from '@/features/avatar/avatars'
import { avatarOutlineWidth } from '@/features/rendering/avatarAppearance'
import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas' import { AvatarCanvas } from '@/features/rendering/components/AvatarCanvas'
import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame' import { PhotoStageFrame } from '@/features/studio/components/PhotoStageFrame'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity' import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
@ -61,6 +62,7 @@ export function StudioStage({ controller }: { controller: StudioController }) {
updateImmediate, updateImmediate,
} = controller } = controller
const photoMode = mode === 'photo' const photoMode = mode === 'photo'
const fixedStrokeColor = isStrokeOnlyRenderStyle(activeAvatar.renderStyle)
const activatePhotoTool = (tool: typeof photoTool) => { const activatePhotoTool = (tool: typeof photoTool) => {
setPhotoTool(tool) setPhotoTool(tool)
setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool])) setPhotoPanelSections(current => (current.includes(tool) ? current : [...current, tool]))
@ -116,9 +118,9 @@ export function StudioStage({ controller }: { controller: StudioController }) {
className={`stage-column${photoMode ? ' photo-mode-active' : ''}`} className={`stage-column${photoMode ? ' photo-mode-active' : ''}`}
style={ style={
{ {
'--avatar-body-color': renderedColors.body, '--avatar-body-color': fixedStrokeColor ? activeAvatar.colors.body : renderedColors.body,
'--avatar-eye-color': renderedColors.eyes, '--avatar-eye-color': fixedStrokeColor ? activeAvatar.colors.eyes : renderedColors.eyes,
'--avatar-outline-width': avatarBodyOutlineWidth(), '--avatar-outline-width': avatarOutlineWidth(activeAvatar.renderStyle),
} as CSSProperties } as CSSProperties
} }
> >