@happyvertical/smrt-images 0.55.9 → 0.55.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,432 @@
1
+ <script lang="ts">
2
+ import {
3
+ mountPhotoCutout,
4
+ type PhotoCutoutHandle,
5
+ type PhotoCutoutRig,
6
+ } from '@happyvertical/animation';
7
+ import { detectFaceLandmarks } from '@happyvertical/images/segmentation';
8
+ import { FilePicker, Textarea } from '@happyvertical/smrt-ui/forms';
9
+ import { useI18n } from '@happyvertical/smrt-ui/i18n';
10
+ import { Button } from '@happyvertical/smrt-ui/ui';
11
+ import { onMount } from 'svelte';
12
+ import {
13
+ assembleCanadianSplitRig,
14
+ type FaceOutline,
15
+ } from '../../photo-cutout-setup.js';
16
+
17
+ import { isolatePhotoHead } from '../head-isolation.js';
18
+ import { M } from '../i18n.js';
19
+
20
+ const { t } = useI18n();
21
+
22
+ interface PersistedSetup {
23
+ pngDataUrl: string;
24
+ rig: PhotoCutoutRig;
25
+ savedAt: string;
26
+ }
27
+ interface Props {
28
+ /** @deprecated Mouth landmarks now run locally; retained for caller compatibility. */
29
+ endpoint?: string;
30
+ segmentationAssets?: string;
31
+ speechPreview?: (
32
+ text: string,
33
+ options: {
34
+ onLevel: (level: number) => void;
35
+ onStart?: () => void;
36
+ signal: AbortSignal;
37
+ },
38
+ ) => Promise<void>;
39
+ saveSetup?: (
40
+ setup: Pick<PersistedSetup, 'pngDataUrl' | 'rig'>,
41
+ ) => Promise<{ savedAt: string }>;
42
+ loadSetup?: () => Promise<PersistedSetup | null>;
43
+ }
44
+ interface Head {
45
+ file: File;
46
+ url: string;
47
+ width: number;
48
+ height: number;
49
+ outline: FaceOutline;
50
+ }
51
+ type Stage = 'outline' | 'mouth-landmarks';
52
+ let {
53
+ segmentationAssets = '/api/dev-image-segmentation',
54
+ speechPreview,
55
+ saveSetup,
56
+ loadSetup,
57
+ }: Props = $props();
58
+ let target: HTMLDivElement;
59
+ let source = $state<{ file: File; url: string } | null>(null);
60
+ let head = $state<Head | null>(null);
61
+ let busy = $state<Stage | null>(null);
62
+ let failed = $state<Stage | null>(null);
63
+ let ready = $state(false);
64
+ let open = $state(false);
65
+ let elapsed = $state(0);
66
+ let message = $state(
67
+ 'Choose a photo, then isolate its head on a transparent background.',
68
+ );
69
+ let request: AbortController | null = null;
70
+ let generation = 0;
71
+ let mounted: PhotoCutoutHandle | null = null;
72
+ let activeAsset = $state<File | null>(null);
73
+ let currentRig = $state<PhotoCutoutRig | null>(null);
74
+ let persisting = $state<'save' | 'load' | null>(null);
75
+ let timer: ReturnType<typeof setInterval> | null = null;
76
+ let speechText = $state('Hello, I am your HappyVertical assistant.');
77
+ let speaking = $state(false);
78
+ let speechStarted = $state(false);
79
+ let speechRequest: AbortController | null = null;
80
+
81
+ function stopTimer() {
82
+ if (timer) clearInterval(timer);
83
+ timer = null;
84
+ }
85
+ function invalidate() {
86
+ generation++;
87
+ request?.abort();
88
+ request = null;
89
+ stopTimer();
90
+ busy = null;
91
+ }
92
+ function neutralExpression() {
93
+ mounted?.setExpression({ headTiltDegrees: 0, jawTiltDegrees: 0 });
94
+ }
95
+ function stopSpeech(announce = true) {
96
+ const active = speechRequest;
97
+ active?.abort();
98
+ speechRequest = null;
99
+ speaking = false;
100
+ speechStarted = false;
101
+ open = false;
102
+ mounted?.setMouthOpen(0);
103
+ neutralExpression();
104
+ if (announce && active) message = 'Speech stopped.';
105
+ }
106
+ function destroyPreview() {
107
+ stopSpeech(false);
108
+ mounted?.destroy();
109
+ mounted = null;
110
+ activeAsset = null;
111
+ currentRig = null;
112
+ ready = false;
113
+ open = false;
114
+ }
115
+ onMount(() => () => {
116
+ invalidate();
117
+ destroyPreview();
118
+ if (source) URL.revokeObjectURL(source.url);
119
+ if (head) URL.revokeObjectURL(head.url);
120
+ });
121
+ function select(files: File[]) {
122
+ const file = files[0];
123
+ if (!file) return;
124
+ if (
125
+ !['image/png', 'image/jpeg', 'image/webp'].includes(file.type) ||
126
+ file.size > 8 * 1024 * 1024
127
+ ) {
128
+ message =
129
+ 'Choose a PNG, JPEG, or WebP below 8 MB. Your previous photo is still selected.';
130
+ return;
131
+ }
132
+ invalidate();
133
+ destroyPreview();
134
+ if (source) URL.revokeObjectURL(source.url);
135
+ if (head) URL.revokeObjectURL(head.url);
136
+ source = { file, url: URL.createObjectURL(file) };
137
+ head = null;
138
+ failed = null;
139
+ message =
140
+ 'Photo is local. Isolate head to remove its background on this device.';
141
+ }
142
+ const read = (file: File) =>
143
+ new Promise<string>((resolve, reject) => {
144
+ const reader = new FileReader();
145
+ reader.onload = () => resolve(String(reader.result));
146
+ reader.onerror = () =>
147
+ reject(reader.error ?? new Error('Could not read photo.'));
148
+ reader.readAsDataURL(file);
149
+ });
150
+ async function imageAt(url: string) {
151
+ const image = new Image();
152
+ image.src = url;
153
+ await image.decode();
154
+ return image;
155
+ }
156
+ function dataUrlFile(dataUrl: string, name: string) {
157
+ const match = /^data:image\/png;base64,([A-Za-z0-9+/]+={0,2})$/.exec(dataUrl);
158
+ if (!match) throw new Error('Saved character cutout is not a PNG.');
159
+ const binary = atob(match[1]);
160
+ const bytes = new Uint8Array(binary.length);
161
+ for (let index = 0; index < binary.length; index++)
162
+ bytes[index] = binary.charCodeAt(index);
163
+ return new File([bytes], name, { type: 'image/png' });
164
+ }
165
+ function mountPreview(rig: PhotoCutoutRig, asset: File, token: number) {
166
+ destroyPreview();
167
+ activeAsset = asset;
168
+ currentRig = rig;
169
+ mounted = mountPhotoCutout(rig, {
170
+ target,
171
+ resolveAsset: async () => asset,
172
+ onError: (error) => {
173
+ if (generation !== token) return;
174
+ ready = false;
175
+ failed = 'mouth-landmarks';
176
+ message = `Character preview failed. ${error.message}`;
177
+ },
178
+ });
179
+ ready = true;
180
+ }
181
+ async function run(stage: Stage) {
182
+ const selected = source;
183
+ const isolated = head;
184
+ if (!selected || (stage === 'mouth-landmarks' && !isolated)) return;
185
+ stopSpeech(false);
186
+ invalidate();
187
+ const token = generation;
188
+ const controller = new AbortController();
189
+ request = controller;
190
+ busy = stage;
191
+ failed = null;
192
+ elapsed = 0;
193
+ const started = Date.now();
194
+ timer = setInterval(() => {
195
+ elapsed = Math.floor((Date.now() - started) / 1000);
196
+ }, 1000);
197
+ message =
198
+ stage === 'outline'
199
+ ? 'Isolating head on a transparent background…'
200
+ : 'Locating mouth and chin on the transparent head…';
201
+ try {
202
+ if (stage === 'outline') {
203
+ const image = await imageAt(selected.url);
204
+ controller.signal.throwIfAborted();
205
+ const next = await isolatePhotoHead(image, {
206
+ assetBaseUrl: segmentationAssets,
207
+ signal: controller.signal,
208
+ onProgress: (stage) => {
209
+ if (generation === token)
210
+ message =
211
+ stage === 'loading'
212
+ ? 'Loading local head isolation…'
213
+ : 'Isolating original photo pixels on this device…';
214
+ },
215
+ });
216
+ if (generation !== token) return;
217
+ if (head) URL.revokeObjectURL(head.url);
218
+ destroyPreview();
219
+ head = { ...next, url: URL.createObjectURL(next.file) };
220
+ message =
221
+ 'Review the transparent head. Continue when the silhouette looks right.';
222
+ } else if (isolated) {
223
+ const image = await imageAt(isolated.url);
224
+ controller.signal.throwIfAborted();
225
+ const landmarks = await detectFaceLandmarks(image, {
226
+ assetBaseUrl: segmentationAssets,
227
+ signal: controller.signal,
228
+ onProgress: (progress) => {
229
+ if (generation === token)
230
+ message =
231
+ progress === 'loading'
232
+ ? 'Loading local face landmarks…'
233
+ : 'Locating lips and chin on this device…';
234
+ },
235
+ });
236
+ if (generation !== token) return;
237
+ const rig = assembleCanadianSplitRig({
238
+ outline: isolated.outline,
239
+ landmarks,
240
+ width: isolated.width,
241
+ height: isolated.height,
242
+ assetId: 'source',
243
+ });
244
+ mountPreview(rig, isolated.file, token);
245
+ message =
246
+ 'Character ready. Open the mouth to inspect the horizontal split and vertical gap.';
247
+ }
248
+ } catch (error) {
249
+ if (generation !== token) return;
250
+ failed = stage;
251
+ message = `${stage === 'outline' ? 'Head isolation' : 'Mouth segmentation'} failed. ${error instanceof Error ? error.message : 'Please retry.'}`;
252
+ } finally {
253
+ if (generation === token) {
254
+ stopTimer();
255
+ busy = null;
256
+ request = null;
257
+ }
258
+ }
259
+ }
260
+ function cancel() {
261
+ const stage = busy;
262
+ invalidate();
263
+ failed = null;
264
+ message =
265
+ stage === 'mouth-landmarks'
266
+ ? 'Mouth segmentation cancelled. Your transparent head is preserved.'
267
+ : 'Head isolation cancelled. Your photo is still selected.';
268
+ }
269
+ function toggleMouth() {
270
+ open = !open;
271
+ mounted?.setMouthOpen(open ? 1 : 0);
272
+ mounted?.setExpression({
273
+ headTiltDegrees: open ? 4 : 0,
274
+ jawTiltDegrees: open ? 7 : 0,
275
+ });
276
+ }
277
+ async function saveCharacter() {
278
+ if (!saveSetup || !activeAsset || !currentRig || persisting) return;
279
+ const token = generation;
280
+ const asset = activeAsset;
281
+ const rig = currentRig;
282
+ persisting = 'save';
283
+ try {
284
+ const result = await saveSetup({ pngDataUrl: await read(asset), rig });
285
+ if (generation === token)
286
+ message = `Character saved locally at ${new Date(result.savedAt).toLocaleTimeString()}.`;
287
+ } catch (cause) {
288
+ if (generation === token)
289
+ message = `Character save failed. ${cause instanceof Error ? cause.message : 'Please retry.'}`;
290
+ } finally {
291
+ persisting = null;
292
+ }
293
+ }
294
+ async function loadCharacter() {
295
+ if (!loadSetup || persisting) return;
296
+ const token = generation;
297
+ persisting = 'load';
298
+ try {
299
+ const saved = await loadSetup();
300
+ if (generation !== token) return;
301
+ if (!saved) {
302
+ message = 'No saved character setup is available yet.';
303
+ return;
304
+ }
305
+ invalidate();
306
+ const mountToken = generation;
307
+ const asset = dataUrlFile(saved.pngDataUrl, 'saved-character-cutout.png');
308
+ mountPreview(saved.rig, asset, mountToken);
309
+ message = `Saved character loaded from ${new Date(saved.savedAt).toLocaleTimeString()}.`;
310
+ } catch (cause) {
311
+ if (generation === token)
312
+ message = `Character load failed. ${cause instanceof Error ? cause.message : 'Please retry.'}`;
313
+ } finally {
314
+ persisting = null;
315
+ }
316
+ }
317
+ async function playSpeech() {
318
+ if (!speechPreview || !ready || speaking || !speechText.trim()) return;
319
+ const controller = new AbortController();
320
+ const token = generation;
321
+ speechRequest = controller;
322
+ speaking = true;
323
+ speechStarted = false;
324
+ open = false;
325
+ message = 'Preparing speech…';
326
+ const current = () =>
327
+ speechRequest === controller &&
328
+ !controller.signal.aborted &&
329
+ generation === token;
330
+ try {
331
+ await speechPreview(speechText, {
332
+ signal: controller.signal,
333
+ onStart: () => {
334
+ if (current()) {
335
+ speechStarted = true;
336
+ message = 'Speaking…';
337
+ }
338
+ },
339
+ onLevel: (level) => {
340
+ if (current()) {
341
+ const reduced = matchMedia(
342
+ '(prefers-reduced-motion: reduce)',
343
+ ).matches;
344
+ const gain = reduced
345
+ ? 0
346
+ : Math.min(1, Math.sqrt(Math.max(0, level)) * 1.25);
347
+ mounted?.setMouthOpen(gain);
348
+ mounted?.setExpression({
349
+ headTiltDegrees: Math.sin(Date.now() / 220) * 5 * gain,
350
+ jawTiltDegrees: gain * 7,
351
+ });
352
+ }
353
+ },
354
+ });
355
+ if (current()) message = 'Speech complete.';
356
+ } catch (cause) {
357
+ if (current())
358
+ message =
359
+ cause instanceof Error ? cause.message : 'Speech preview failed.';
360
+ } finally {
361
+ if (speechRequest === controller) {
362
+ speechRequest = null;
363
+ speaking = false;
364
+ speechStarted = false;
365
+ open = false;
366
+ mounted?.setMouthOpen(0);
367
+ neutralExpression();
368
+ }
369
+ }
370
+ }
371
+ </script>
372
+
373
+ <section class="setup" aria-busy={busy !== null} aria-labelledby="cutout-title">
374
+ <h2 id="cutout-title">{t(M['images.photo_cutout_setup.title'])}</h2>
375
+ <ol class="steps" aria-label={t(M['images.photo_cutout_setup.steps_label'])}>
376
+ <li class:current={!source && !ready} class:complete={source !== null || ready} aria-current={!source && !ready ? 'step' : undefined}>{t(M['images.photo_cutout_setup.step_choose'])}</li>
377
+ <li class:current={source !== null && !head && !ready} class:complete={head !== null || ready} aria-current={source !== null && !head && !ready ? 'step' : undefined}>{t(M['images.photo_cutout_setup.step_isolate'])}</li>
378
+ <li class:current={head !== null && !ready} class:complete={ready} aria-current={head !== null && !ready ? 'step' : undefined}>{t(M['images.photo_cutout_setup.step_mouth'])}</li>
379
+ <li class:current={ready} aria-current={ready ? 'step' : undefined}>{t(M['images.photo_cutout_setup.step_save'])}</li>
380
+ </ol>
381
+ <p class:error={failed !== null} class:success={ready} role={failed ? 'alert' : undefined} aria-live={failed ? 'assertive' : 'polite'}>{message}{#if busy} ({elapsed}s){/if}</p>
382
+ <FilePicker accept="image/png,image/jpeg,image/webp" label={t(M['images.photo_cutout_setup.choose_photo'])} description={t(M['images.photo_cutout_setup.photo_formats'])} aria-label={t(M['images.photo_cutout_setup.choose_photo'])} onchangefiles={select} disabled={persisting !== null} />
383
+ <div class="workflow-actions">
384
+ <div class="primary-action">
385
+ {#if ready && saveSetup}<Button onclick={saveCharacter} disabled={persisting !== null}>{persisting === 'save' ? 'Saving…' : 'Save character'}</Button>
386
+ {:else if ready}<Button onclick={toggleMouth}>{open ? 'Close mouth' : 'Open mouth'}</Button>
387
+ {:else if !head}<Button onclick={() => run('outline')} disabled={!source || busy !== null}>{failed === 'outline' ? 'Retry head isolation' : 'Isolate head'}</Button>
388
+ {:else if !ready}<Button onclick={() => run('mouth-landmarks')} disabled={busy !== null}>{failed === 'mouth-landmarks' ? 'Retry mouth segmentation' : 'Continue: segment mouth'}</Button>
389
+ {/if}
390
+ {#if busy}<span class="spinner" aria-label={busy === 'outline' ? 'Isolating head' : 'Segmenting mouth'}></span><Button variant="secondary" onclick={cancel}>Cancel</Button>{/if}
391
+ </div>
392
+ {#if ready && saveSetup}<p class="save-hint">{t(M['images.photo_cutout_setup.save_hint'])}</p>{/if}
393
+ <div class="secondary-actions" aria-label={t(M['images.photo_cutout_setup.options_label'])}>
394
+ {#if head}<Button variant="secondary" onclick={() => run('outline')} disabled={busy !== null || persisting !== null}>{t(M['images.photo_cutout_setup.redo_head'])}</Button>{/if}
395
+ {#if head && ready}<Button variant="secondary" onclick={() => run('mouth-landmarks')} disabled={busy !== null || persisting !== null}>{t(M['images.photo_cutout_setup.redo_mouth'])}</Button>{/if}
396
+ {#if ready && saveSetup}<Button variant="secondary" onclick={toggleMouth}>{open ? 'Close mouth' : 'Open mouth'}</Button>{/if}
397
+ {#if loadSetup}<Button variant="secondary" onclick={loadCharacter} disabled={busy !== null || persisting !== null}>{persisting === 'load' ? 'Loading…' : 'Load saved character'}</Button>{/if}
398
+ </div>
399
+ </div>
400
+ {#if ready && speechPreview}<div class="speech"><label>{t(M['images.photo_cutout_setup.preview_speech'])} <Textarea bind:value={speechText} maxlength={500} disabled={speaking} /></label><Button onclick={playSpeech} disabled={speaking || !speechText.trim()}>{speaking ? speechStarted ? 'Speaking…' : 'Preparing…' : 'Play speech'}</Button>{#if speaking}<Button onclick={() => stopSpeech()}>{t(M['images.photo_cutout_setup.stop_speech'])}</Button>{/if}</div>{/if}
401
+ <div class="previews">
402
+ {#if head}<figure><figcaption>{t(M['images.photo_cutout_setup.transparent_head'])}</figcaption><img class="checkerboard" src={head.url} alt={t(M['images.photo_cutout_setup.isolated_head_alt'])} /></figure>
403
+ {:else if source}<figure><figcaption>{t(M['images.photo_cutout_setup.step_choose'])}</figcaption><img src={source.url} alt={t(M['images.photo_cutout_setup.source_alt'])} /></figure>{/if}
404
+ <div bind:this={target} class="preview-target" class:preview={ready} class:checkerboard={ready} aria-label={ready ? t(M['images.photo_cutout_setup.preview_label']) : undefined} aria-hidden={!ready}></div>
405
+ </div>
406
+ </section>
407
+
408
+ <style>
409
+ .setup { max-width: 42rem; margin: var(--smrt-spacing-6, 1.5rem) auto; padding: var(--smrt-spacing-5, 1.25rem); border: 1px solid var(--smrt-color-outline-variant, #64748b); border-radius: var(--smrt-radius-xl, 12px); }
410
+ .steps { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--smrt-spacing-2, .5rem); padding: 0; margin: var(--smrt-spacing-4, 1rem) 0; list-style: none; color: var(--smrt-color-on-surface-variant, #475569); font-size: var(--smrt-typography-body-small-size, .875rem); }
411
+ .steps li { padding-bottom: var(--smrt-spacing-1, .25rem); border-bottom: 2px solid var(--smrt-color-outline-variant, #64748b); }
412
+ .steps .current { color: var(--smrt-color-primary, #1d4ed8); border-color: var(--smrt-color-primary, #1d4ed8); font-weight: var(--smrt-typography-weight-semibold, 600); }
413
+ .steps .complete { color: var(--smrt-color-success, #166534); }
414
+ .workflow-actions { margin: var(--smrt-spacing-4, 1rem) 0; }
415
+ .primary-action, .secondary-actions, .speech { display: flex; flex-wrap: wrap; align-items: center; gap: var(--smrt-spacing-3, .75rem); }
416
+ .secondary-actions { margin-top: var(--smrt-spacing-3, .75rem); }
417
+ .save-hint { margin: var(--smrt-spacing-2, .5rem) 0 0; color: var(--smrt-color-on-surface-variant, #475569); font-size: var(--smrt-typography-body-small-size, .875rem); }
418
+ .speech { margin-top: var(--smrt-spacing-4, 1rem); }
419
+ .previews { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--smrt-spacing-4, 1rem); align-items: start; }
420
+ figure { margin: 0; }
421
+ figure figcaption { margin-bottom: var(--smrt-spacing-2, .5rem); font-size: var(--smrt-typography-body-small-size, .875rem); }
422
+ .setup img, .preview :global(svg) { display: block; width: 100%; height: auto; max-height: 18rem; object-fit: contain; }
423
+ .preview, .preview-target { min-width: 0; }
424
+ .preview-target:not(.preview) { display: none; }
425
+ .checkerboard { background-color: var(--smrt-color-surface, #fff); background-image: linear-gradient(45deg, var(--smrt-color-surface-container-highest, #ddd) 25%, transparent 25%), linear-gradient(-45deg, var(--smrt-color-surface-container-highest, #ddd) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, var(--smrt-color-surface-container-highest, #ddd) 75%), linear-gradient(-45deg, transparent 75%, var(--smrt-color-surface-container-highest, #ddd) 75%); background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0; }
426
+ .error { color: var(--smrt-color-error, #b91c1c); font-weight: var(--smrt-typography-weight-semibold, 600); }
427
+ .success { color: var(--smrt-color-success, #166534); }
428
+ .spinner { width: 1rem; height: 1rem; border: 2px solid currentColor; border-right-color: transparent; border-radius: var(--smrt-radius-full, 9999px); animation: spin .7s linear infinite; }
429
+ @keyframes spin { to { transform: rotate(360deg); } }
430
+ @media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-right-color: currentColor; } }
431
+ @media (max-width: 40rem) { .steps { grid-template-columns: repeat(2, 1fr); } .previews { grid-template-columns: 1fr; } }
432
+ </style>
@@ -0,0 +1,24 @@
1
+ import { type PhotoCutoutRig } from '@happyvertical/animation';
2
+ interface PersistedSetup {
3
+ pngDataUrl: string;
4
+ rig: PhotoCutoutRig;
5
+ savedAt: string;
6
+ }
7
+ interface Props {
8
+ /** @deprecated Mouth landmarks now run locally; retained for caller compatibility. */
9
+ endpoint?: string;
10
+ segmentationAssets?: string;
11
+ speechPreview?: (text: string, options: {
12
+ onLevel: (level: number) => void;
13
+ onStart?: () => void;
14
+ signal: AbortSignal;
15
+ }) => Promise<void>;
16
+ saveSetup?: (setup: Pick<PersistedSetup, 'pngDataUrl' | 'rig'>) => Promise<{
17
+ savedAt: string;
18
+ }>;
19
+ loadSetup?: () => Promise<PersistedSetup | null>;
20
+ }
21
+ declare const PhotoCutoutSetup: import("svelte").Component<Props, {}, "">;
22
+ type PhotoCutoutSetup = ReturnType<typeof PhotoCutoutSetup>;
23
+ export default PhotoCutoutSetup;
24
+ //# sourceMappingURL=PhotoCutoutSetup.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PhotoCutoutSetup.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/PhotoCutoutSetup.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,0BAA0B,CAAC;AAelC,UAAU,cAAc;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,cAAc,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;CACjB;AACD,UAAU,KAAK;IACb,sFAAsF;IACtF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,CACd,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE;QACP,OAAO,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;QACjC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;QACrB,MAAM,EAAE,WAAW,CAAC;KACrB,KACE,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,SAAS,CAAC,EAAE,CACV,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,YAAY,GAAG,KAAK,CAAC,KAC9C,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CAClD;AAkYD,QAAA,MAAM,gBAAgB,2CAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
@@ -0,0 +1,13 @@
1
+ import type { FaceOutline } from '../photo-cutout-setup.js';
2
+ /** SMRT's photographic head policy: retain hair + facial skin, excluding neck and clothes. */
3
+ export declare function isolatePhotoHead(image: HTMLImageElement, options: {
4
+ assetBaseUrl: string;
5
+ signal?: AbortSignal;
6
+ onProgress?: (stage: 'loading' | 'segmenting') => void;
7
+ }): Promise<{
8
+ file: File;
9
+ width: number;
10
+ height: number;
11
+ outline: FaceOutline;
12
+ }>;
13
+ //# sourceMappingURL=head-isolation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"head-isolation.d.ts","sourceRoot":"","sources":["../../src/svelte/head-isolation.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAE5D,8FAA8F;AAC9F,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,gBAAgB,EACvB,OAAO,EAAE;IACP,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,GAAG,YAAY,KAAK,IAAI,CAAC;CACxD,GACA,OAAO,CAAC;IACT,IAAI,EAAE,IAAI,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,WAAW,CAAC;CACtB,CAAC,CA2ED"}
@@ -0,0 +1,83 @@
1
+ import { encodeRgbaPng, segmentationAlpha, segmentImage, } from '@happyvertical/images/segmentation';
2
+ /** SMRT's photographic head policy: retain hair + facial skin, excluding neck and clothes. */
3
+ export async function isolatePhotoHead(image, options) {
4
+ const width = image.naturalWidth, height = image.naturalHeight;
5
+ if (!width || !height || width * height > 16_777_216)
6
+ throw new Error('Choose a photo below 16 megapixels.');
7
+ const canvas = document.createElement('canvas');
8
+ canvas.width = width;
9
+ canvas.height = height;
10
+ const context = canvas.getContext('2d');
11
+ if (!context)
12
+ throw new Error('Image processing is unavailable in this browser.');
13
+ context.drawImage(image, 0, 0);
14
+ const source = context.getImageData(0, 0, width, height);
15
+ const mask = await segmentImage(source, options);
16
+ if (mask.width !== width || mask.height !== height)
17
+ throw new Error('Segmentation mask does not match the original photo.');
18
+ const alpha = segmentationAlpha(mask, {
19
+ classes: [1, 3],
20
+ lower: 0.15,
21
+ upper: 0.85,
22
+ largestComponent: true,
23
+ });
24
+ options.signal?.throwIfAborted();
25
+ let left = width, right = 0, top = height, bottom = 0;
26
+ for (let i = 0; i < alpha.length; i++)
27
+ if (alpha[i]) {
28
+ const x = i % width, y = Math.floor(i / width);
29
+ left = Math.min(left, x);
30
+ right = Math.max(right, x + 1);
31
+ top = Math.min(top, y);
32
+ bottom = Math.max(bottom, y + 1);
33
+ }
34
+ const cropWidth = right - left, cropHeight = bottom - top;
35
+ if (cropWidth < 16 || cropHeight < 16)
36
+ throw new Error('No clear head found. Try a closer, front-facing photo.');
37
+ const rgba = new Uint8ClampedArray(cropWidth * cropHeight * 4);
38
+ const boundary = [];
39
+ for (let y = top; y < bottom; y++) {
40
+ let first = right, last = left;
41
+ for (let x = left; x < right; x++) {
42
+ const from = (y * width + x) * 4, to = ((y - top) * cropWidth + x - left) * 4;
43
+ rgba.set(source.data.subarray(from, from + 3), to);
44
+ rgba[to + 3] = Math.round((alpha[y * width + x] * source.data[from + 3]) / 255);
45
+ if (alpha[y * width + x]) {
46
+ first = Math.min(first, x);
47
+ last = Math.max(last, x + 1);
48
+ }
49
+ }
50
+ if (first < last)
51
+ for (const x of [first, last])
52
+ for (const edgeY of [y, y + 1])
53
+ boundary.push({
54
+ x: ((x - left) / cropWidth) * 1000,
55
+ y: ((edgeY - top) / cropHeight) * 1000,
56
+ });
57
+ }
58
+ // The later rig needs an envelope only: actual silhouette remains the PNG's per-pixel alpha.
59
+ const points = convexEnvelope(boundary);
60
+ const blob = await encodeRgbaPng(cropWidth, cropHeight, rgba);
61
+ options.signal?.throwIfAborted();
62
+ return {
63
+ file: new File([blob], 'isolated-head.png', { type: 'image/png' }),
64
+ width: cropWidth,
65
+ height: cropHeight,
66
+ outline: { points },
67
+ };
68
+ }
69
+ function convexEnvelope(points) {
70
+ points.sort((a, b) => a.x - b.x || a.y - b.y);
71
+ const cross = (a, b, c) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
72
+ const half = (list) => {
73
+ const result = [];
74
+ for (const point of list) {
75
+ while (result.length > 1 &&
76
+ cross(result[result.length - 2], result[result.length - 1], point) <= 0)
77
+ result.pop();
78
+ result.push(point);
79
+ }
80
+ return result.slice(0, -1);
81
+ };
82
+ return [...half(points), ...half([...points].reverse())];
83
+ }
@@ -1,4 +1,22 @@
1
1
  export declare const M: {
2
+ readonly 'images.photo_cutout_setup.title': "images.photo_cutout_setup.title";
3
+ readonly 'images.photo_cutout_setup.choose_photo': "images.photo_cutout_setup.choose_photo";
4
+ readonly 'images.photo_cutout_setup.photo_formats': "images.photo_cutout_setup.photo_formats";
5
+ readonly 'images.photo_cutout_setup.preview_speech': "images.photo_cutout_setup.preview_speech";
6
+ readonly 'images.photo_cutout_setup.stop_speech': "images.photo_cutout_setup.stop_speech";
7
+ readonly 'images.photo_cutout_setup.transparent_head': "images.photo_cutout_setup.transparent_head";
8
+ readonly 'images.photo_cutout_setup.isolated_head_alt': "images.photo_cutout_setup.isolated_head_alt";
9
+ readonly 'images.photo_cutout_setup.source_alt': "images.photo_cutout_setup.source_alt";
10
+ readonly 'images.photo_cutout_setup.preview_label': "images.photo_cutout_setup.preview_label";
11
+ readonly 'images.photo_cutout_setup.steps_label': "images.photo_cutout_setup.steps_label";
12
+ readonly 'images.photo_cutout_setup.options_label': "images.photo_cutout_setup.options_label";
13
+ readonly 'images.photo_cutout_setup.redo_head': "images.photo_cutout_setup.redo_head";
14
+ readonly 'images.photo_cutout_setup.redo_mouth': "images.photo_cutout_setup.redo_mouth";
15
+ readonly 'images.photo_cutout_setup.step_choose': "images.photo_cutout_setup.step_choose";
16
+ readonly 'images.photo_cutout_setup.step_isolate': "images.photo_cutout_setup.step_isolate";
17
+ readonly 'images.photo_cutout_setup.step_mouth': "images.photo_cutout_setup.step_mouth";
18
+ readonly 'images.photo_cutout_setup.step_save': "images.photo_cutout_setup.step_save";
19
+ readonly 'images.photo_cutout_setup.save_hint': "images.photo_cutout_setup.save_hint";
2
20
  readonly 'images.assets_gallery.title': "images.assets_gallery.title";
3
21
  readonly 'images.assets_gallery.search_placeholder': "images.assets_gallery.search_placeholder";
4
22
  readonly 'images.assets_gallery.any_orientation': "images.assets_gallery.any_orientation";
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../../src/svelte/i18n.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuDZ,CAAC"}
1
+ {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../../src/svelte/i18n.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6EZ,CAAC"}
@@ -1,5 +1,24 @@
1
1
  import { defineMessages } from '@happyvertical/smrt-ui/i18n';
2
2
  export const M = defineMessages({
3
+ // PhotoCutoutSetup
4
+ 'images.photo_cutout_setup.title': 'Photographic character setup',
5
+ 'images.photo_cutout_setup.choose_photo': 'Choose character photo',
6
+ 'images.photo_cutout_setup.photo_formats': 'PNG, JPEG, or WebP below 8 MB',
7
+ 'images.photo_cutout_setup.preview_speech': 'Preview speech',
8
+ 'images.photo_cutout_setup.stop_speech': 'Stop speech',
9
+ 'images.photo_cutout_setup.transparent_head': 'Step 1: transparent head',
10
+ 'images.photo_cutout_setup.isolated_head_alt': 'Isolated head on transparent background',
11
+ 'images.photo_cutout_setup.source_alt': 'Selected character source',
12
+ 'images.photo_cutout_setup.preview_label': 'Animated character preview',
13
+ 'images.photo_cutout_setup.steps_label': 'Character setup steps',
14
+ 'images.photo_cutout_setup.options_label': 'Character setup options',
15
+ 'images.photo_cutout_setup.redo_head': 'Redo head isolation',
16
+ 'images.photo_cutout_setup.redo_mouth': 'Redo mouth segmentation',
17
+ 'images.photo_cutout_setup.step_choose': 'Choose photo',
18
+ 'images.photo_cutout_setup.step_isolate': 'Isolate head',
19
+ 'images.photo_cutout_setup.step_mouth': 'Set mouth',
20
+ 'images.photo_cutout_setup.step_save': 'Save to gallery',
21
+ 'images.photo_cutout_setup.save_hint': 'Saving adds this character to your gallery. It does not change your active helper.',
3
22
  // AssetsGallery
4
23
  'images.assets_gallery.title': 'Pictures',
5
24
  'images.assets_gallery.search_placeholder': 'Search pictures',
@@ -9,6 +9,7 @@
9
9
  import AssetsGallery from './components/AssetsGallery.svelte';
10
10
  import ImageEditor from './components/ImageEditor.svelte';
11
11
  import ImageUploader from './components/ImageUploader.svelte';
12
+ import PhotoCutoutSetup from './components/PhotoCutoutSetup.svelte';
12
13
  export type { ImageConvertRequest, ImageCropRequest, ImageEditorClient, ImageEditorResult, ImageEditRequest, ImageLike, ImageResizeRequest, ImagesGalleryClient, ImagesGalleryQuery, ImagesGalleryResult, } from './image-clients';
13
- export { AssetsGallery, ImageEditor, ImageUploader };
14
+ export { AssetsGallery, ImageEditor, ImageUploader, PhotoCutoutSetup };
14
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/svelte/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,aAAa,MAAM,mCAAmC,CAAC;AAC9D,OAAO,WAAW,MAAM,iCAAiC,CAAC;AAC1D,OAAO,aAAa,MAAM,mCAAmC,CAAC;AAE9D,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/svelte/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAMH,OAAO,aAAa,MAAM,mCAAmC,CAAC;AAC9D,OAAO,WAAW,MAAM,iCAAiC,CAAC;AAC1D,OAAO,aAAa,MAAM,mCAAmC,CAAC;AAC9D,OAAO,gBAAgB,MAAM,sCAAsC,CAAC;AAEpE,YAAY,EACV,mBAAmB,EACnB,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,kBAAkB,EAClB,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,gBAAgB,EAAE,CAAC"}
@@ -12,8 +12,9 @@ import { IMAGES_MODULE_META } from '../ui.js';
12
12
  import AssetsGallery from './components/AssetsGallery.svelte';
13
13
  import ImageEditor from './components/ImageEditor.svelte';
14
14
  import ImageUploader from './components/ImageUploader.svelte';
15
+ import PhotoCutoutSetup from './components/PhotoCutoutSetup.svelte';
15
16
  // Export components
16
- export { AssetsGallery, ImageEditor, ImageUploader };
17
+ export { AssetsGallery, ImageEditor, ImageUploader, PhotoCutoutSetup };
17
18
  // Auto-register with ModuleUIRegistry
18
19
  ModuleUIRegistry.registerModule(IMAGES_MODULE_META);
19
20
  ModuleUIRegistry.register('@happyvertical/smrt-images', 'assets-gallery', AssetsGallery);