@maravilla-labs/platform 0.15.0 → 0.17.0

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.
@@ -151,6 +151,48 @@ interface ResizeOpts {
151
151
  * the Rust `ResizeOpts` exactly.
152
152
  */
153
153
  focal?: FocalPoint;
154
+ /**
155
+ * Face-box-relative crop zoom for `crop: 'focal'` — a scale factor
156
+ * (`0.4..=100.0`) or `'auto'`. A factor scales the smallest output-aspect
157
+ * frame around the (padded) subject: `1.0` = subject fills the frame; `> 1`
158
+ * pulls back for more context; `< 1` crops tighter, *into* the subject;
159
+ * large enough saturates to plain scale-to-cover. `'auto'` frames the subject
160
+ * to a comfortable, consistent occupancy. Only meaningful with `crop: 'focal'`
161
+ * on a source with stored face data (from `detectFaces`). Omit for the legacy
162
+ * point-only crop — unset it does not affect the derived key.
163
+ */
164
+ zoom?: number | 'auto';
165
+ /**
166
+ * Extra margin around the subject box before framing, as a fraction of the
167
+ * box size added to every side (`0.15` = 15% breathing room). `crop: 'focal'`
168
+ * framing knob; default `0`. Like `zoom`, omitting it preserves the legacy key.
169
+ */
170
+ padding?: number;
171
+ /**
172
+ * Which detected face(s) to frame for `crop: 'focal'`: `'union'` (all faces,
173
+ * default — never clips a face) or `'primary'` (just the highest-confidence
174
+ * face, so a group photo can crop to one person). `'primary'` engages framing
175
+ * even without an explicit `zoom`.
176
+ */
177
+ subject?: 'union' | 'primary';
178
+ /**
179
+ * The resolved subject box (union or primary face), normalized `0..=1`.
180
+ * Server-injected (like {@link ResizeOpts.focal}) and only when the framing
181
+ * path is engaged — callers never pass this. Present so the wire type mirrors
182
+ * the Rust `ResizeOpts` exactly.
183
+ */
184
+ subject_box?: SubjectBox;
185
+ }
186
+ /**
187
+ * Union bounding box of a source's detected faces, normalized `0..=1`
188
+ * (top-left `x`/`y` + `w`/`h`). Sizes the `crop: 'focal'` {@link
189
+ * ResizeOpts.zoom} window; server-injected only.
190
+ */
191
+ interface SubjectBox {
192
+ x: number;
193
+ y: number;
194
+ w: number;
195
+ h: number;
154
196
  }
155
197
  /** Options for `transforms.ocr`. */
156
198
  interface OcrOpts {
@@ -443,9 +485,92 @@ interface TransformsService {
443
485
  * `crates/platform/tests/derive_key_vectors.json`.
444
486
  */
445
487
  declare function keyFor(srcKey: string, spec: TransformSpec): string;
488
+ /** Inputs for {@link focalCssFrame}. */
489
+ interface FocalCssFrameInput {
490
+ /** The source's focal point (from `detectFaces`/`getFocalPoint` or manual). */
491
+ focal: FocalPoint;
492
+ /** Detected face boxes (from the same record). Empty/absent → plain cover. */
493
+ faces?: FaceBox[] | null;
494
+ /**
495
+ * Source image intrinsic pixel dimensions — only their ratio is used. In the
496
+ * browser these are free from a loaded `<img>` (`naturalWidth`/`Height`);
497
+ * `detectFaces`' output artifact also carries them.
498
+ */
499
+ imgWidth: number;
500
+ imgHeight: number;
501
+ /** Target container aspect ratio, `width / height`. */
502
+ containerAspect: number;
503
+ /**
504
+ * Frame zoom — a factor or `'auto'`. `1` frames tightly around the face(s);
505
+ * `> 1` pulls back toward cover; `< 1` crops tighter, into the face; `'auto'`
506
+ * targets a comfortable occupancy. Defaults to `1`. Mirrors the server-side
507
+ * `resize({ crop: 'focal', zoom })` semantics.
508
+ */
509
+ zoom?: number | 'auto';
510
+ /**
511
+ * Extra margin around the subject box, fraction per side (`0.15` = 15%).
512
+ * Defaults to `0`. Mirrors the server `padding` knob.
513
+ */
514
+ padding?: number;
515
+ /**
516
+ * Frame the `'union'` of all faces (default) or just the `'primary'`
517
+ * (highest-confidence) face — for a group photo, crop to one person.
518
+ */
519
+ subject?: 'union' | 'primary';
520
+ }
521
+ /** CSS values produced by {@link focalCssFrame}. */
522
+ interface FocalCssFrame {
523
+ /** `object-position` value, e.g. `"44.0% 27.0%"`. */
524
+ objectPosition: string;
525
+ /** `transform: scale(...)` factor to apply on top of `object-fit: cover`. */
526
+ scale: number;
527
+ /** `transform-origin` — same anchor as `objectPosition`. */
528
+ transformOrigin: string;
529
+ }
530
+ /**
531
+ * Union bounding box of detected faces, clamped to `0..1`; `null` for an empty
532
+ * list or a degenerate (zero-area) union. Sizes the {@link focalCssFrame} zoom
533
+ * window; mirrors the server-side union used by `resize({ crop:'focal', zoom })`.
534
+ */
535
+ declare function unionFaceBox(faces?: FaceBox[] | null): SubjectBox | null;
536
+ /**
537
+ * The primary (highest-confidence) face as a subject box plus a focal point
538
+ * centred on it (eye-line biased), clamped to `0..1`; `null` if none. Mirrors
539
+ * the server's `subject: 'primary'` — lets a group photo crop to one person.
540
+ */
541
+ declare function primaryFaceBox(faces?: FaceBox[] | null): {
542
+ box: SubjectBox;
543
+ focal: FocalPoint;
544
+ } | null;
545
+ /**
546
+ * Compute CSS values that frame a source image on its detected face(s) for a
547
+ * container of ANY aspect ratio — the browser-side counterpart to the server's
548
+ * `resize({ crop: 'focal', zoom })`. Instead of a pre-rendered crop per shape,
549
+ * apply the result to a single `<img>`:
550
+ *
551
+ * ```html
552
+ * <div style="aspect-ratio: 3/1; overflow: hidden">
553
+ * <img
554
+ * style="width:100%; height:100%; object-fit:cover;
555
+ * object-position: {objectPosition}; transform-origin: {transformOrigin};
556
+ * transform: scale({scale})" />
557
+ * </div>
558
+ * ```
559
+ *
560
+ * `object-position` pans the cover image onto the focal point; the extra
561
+ * `transform: scale` tightens the frame around the face(s) per `zoom`, scaling
562
+ * about the same anchor so the face stays put. Framework-agnostic: it returns
563
+ * plain CSS values — apply them however your framework applies styles.
564
+ *
565
+ * With no detected faces it degrades to a plain focal-point cover (`scale: 1`).
566
+ */
567
+ declare function focalCssFrame(input: FocalCssFrameInput): FocalCssFrame;
446
568
  /** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
447
569
  declare const transforms: {
448
570
  keyFor: typeof keyFor;
571
+ focalCssFrame: typeof focalCssFrame;
572
+ unionFaceBox: typeof unionFaceBox;
573
+ primaryFaceBox: typeof primaryFaceBox;
449
574
  };
450
575
  /**
451
576
  * Per-pattern transforms declaration used inside the `transforms` block of
@@ -500,4 +625,4 @@ interface TransformsPatternSpec {
500
625
  */
501
626
  type TransformsConfig = Record<string, TransformsPatternSpec>;
502
627
 
503
- export { type DetachAudioOpts as D, type ExtractFramesOpts as E, type FramesetManifest as F, type ImageFormat as I, type JobStatus as J, type MediaInfo as M, type OcrOpts as O, type QrCodeSpec as Q, type ResizeOpts as R, type TransformsConfig as T, type VideoFormat as V, type TransformsPatternSpec as a, type TranscodeOpts as b, type ThumbnailOpts as c, type ExtractAudioOpts as d, type DetectFacesOpts as e, type FocalPoint as f, type FaceBox as g, type FocalPointRecord as h, type DocFormat as i, type DocToPdfOpts as j, type DocThumbnailOpts as k, type DocConvertOpts as l, type DocToMarkdownOpts as m, type DocToHtmlOpts as n, type ImageRef as o, type DocReplaceImagesOpts as p, type DocInsertQrCodeOpts as q, type QrPayload as r, type DocTemplateMergeOpts as s, type TransformSpec as t, type JobHandle as u, type JobStatusResponse as v, type TransformsService as w, keyFor as x, transforms as y };
628
+ export { unionFaceBox as A, primaryFaceBox as B, focalCssFrame as C, type DetachAudioOpts as D, type ExtractFramesOpts as E, type FramesetManifest as F, transforms as G, type ImageFormat as I, type JobStatus as J, type MediaInfo as M, type OcrOpts as O, type QrCodeSpec as Q, type ResizeOpts as R, type SubjectBox as S, type TransformsConfig as T, type VideoFormat as V, type TransformsPatternSpec as a, type TranscodeOpts as b, type ThumbnailOpts as c, type ExtractAudioOpts as d, type DetectFacesOpts as e, type FocalPoint as f, type FaceBox as g, type FocalPointRecord as h, type DocFormat as i, type DocToPdfOpts as j, type DocThumbnailOpts as k, type DocConvertOpts as l, type DocToMarkdownOpts as m, type DocToHtmlOpts as n, type ImageRef as o, type DocReplaceImagesOpts as p, type DocInsertQrCodeOpts as q, type QrPayload as r, type DocTemplateMergeOpts as s, type TransformSpec as t, type JobHandle as u, type JobStatusResponse as v, type TransformsService as w, keyFor as x, type FocalCssFrameInput as y, type FocalCssFrame as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maravilla-labs/platform",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Universal platform client for Maravilla runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/transforms.ts CHANGED
@@ -205,6 +205,49 @@ export interface ResizeOpts {
205
205
  * the Rust `ResizeOpts` exactly.
206
206
  */
207
207
  focal?: FocalPoint;
208
+ /**
209
+ * Face-box-relative crop zoom for `crop: 'focal'` — a scale factor
210
+ * (`0.4..=100.0`) or `'auto'`. A factor scales the smallest output-aspect
211
+ * frame around the (padded) subject: `1.0` = subject fills the frame; `> 1`
212
+ * pulls back for more context; `< 1` crops tighter, *into* the subject;
213
+ * large enough saturates to plain scale-to-cover. `'auto'` frames the subject
214
+ * to a comfortable, consistent occupancy. Only meaningful with `crop: 'focal'`
215
+ * on a source with stored face data (from `detectFaces`). Omit for the legacy
216
+ * point-only crop — unset it does not affect the derived key.
217
+ */
218
+ zoom?: number | 'auto';
219
+ /**
220
+ * Extra margin around the subject box before framing, as a fraction of the
221
+ * box size added to every side (`0.15` = 15% breathing room). `crop: 'focal'`
222
+ * framing knob; default `0`. Like `zoom`, omitting it preserves the legacy key.
223
+ */
224
+ padding?: number;
225
+ /**
226
+ * Which detected face(s) to frame for `crop: 'focal'`: `'union'` (all faces,
227
+ * default — never clips a face) or `'primary'` (just the highest-confidence
228
+ * face, so a group photo can crop to one person). `'primary'` engages framing
229
+ * even without an explicit `zoom`.
230
+ */
231
+ subject?: 'union' | 'primary';
232
+ /**
233
+ * The resolved subject box (union or primary face), normalized `0..=1`.
234
+ * Server-injected (like {@link ResizeOpts.focal}) and only when the framing
235
+ * path is engaged — callers never pass this. Present so the wire type mirrors
236
+ * the Rust `ResizeOpts` exactly.
237
+ */
238
+ subject_box?: SubjectBox;
239
+ }
240
+
241
+ /**
242
+ * Union bounding box of a source's detected faces, normalized `0..=1`
243
+ * (top-left `x`/`y` + `w`/`h`). Sizes the `crop: 'focal'` {@link
244
+ * ResizeOpts.zoom} window; server-injected only.
245
+ */
246
+ export interface SubjectBox {
247
+ x: number;
248
+ y: number;
249
+ w: number;
250
+ h: number;
208
251
  }
209
252
 
210
253
  /** Options for `transforms.ocr`. */
@@ -604,9 +647,172 @@ export function keyFor(srcKey: string, spec: TransformSpec): string {
604
647
  return `__derived/${srcHash}/${variantHash}.${ext}`;
605
648
  }
606
649
 
650
+ // ── CSS-only focal framing (isomorphic, framework-agnostic) ───────────────
651
+
652
+ /** Inputs for {@link focalCssFrame}. */
653
+ export interface FocalCssFrameInput {
654
+ /** The source's focal point (from `detectFaces`/`getFocalPoint` or manual). */
655
+ focal: FocalPoint;
656
+ /** Detected face boxes (from the same record). Empty/absent → plain cover. */
657
+ faces?: FaceBox[] | null;
658
+ /**
659
+ * Source image intrinsic pixel dimensions — only their ratio is used. In the
660
+ * browser these are free from a loaded `<img>` (`naturalWidth`/`Height`);
661
+ * `detectFaces`' output artifact also carries them.
662
+ */
663
+ imgWidth: number;
664
+ imgHeight: number;
665
+ /** Target container aspect ratio, `width / height`. */
666
+ containerAspect: number;
667
+ /**
668
+ * Frame zoom — a factor or `'auto'`. `1` frames tightly around the face(s);
669
+ * `> 1` pulls back toward cover; `< 1` crops tighter, into the face; `'auto'`
670
+ * targets a comfortable occupancy. Defaults to `1`. Mirrors the server-side
671
+ * `resize({ crop: 'focal', zoom })` semantics.
672
+ */
673
+ zoom?: number | 'auto';
674
+ /**
675
+ * Extra margin around the subject box, fraction per side (`0.15` = 15%).
676
+ * Defaults to `0`. Mirrors the server `padding` knob.
677
+ */
678
+ padding?: number;
679
+ /**
680
+ * Frame the `'union'` of all faces (default) or just the `'primary'`
681
+ * (highest-confidence) face — for a group photo, crop to one person.
682
+ */
683
+ subject?: 'union' | 'primary';
684
+ }
685
+
686
+ /** `zoom: 'auto'` as a factor — mirrors the Rust `AUTO_ZOOM` (subject ~82%). */
687
+ const AUTO_ZOOM = 1.22;
688
+
689
+ /** CSS values produced by {@link focalCssFrame}. */
690
+ export interface FocalCssFrame {
691
+ /** `object-position` value, e.g. `"44.0% 27.0%"`. */
692
+ objectPosition: string;
693
+ /** `transform: scale(...)` factor to apply on top of `object-fit: cover`. */
694
+ scale: number;
695
+ /** `transform-origin` — same anchor as `objectPosition`. */
696
+ transformOrigin: string;
697
+ }
698
+
699
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
700
+
701
+ /**
702
+ * Union bounding box of detected faces, clamped to `0..1`; `null` for an empty
703
+ * list or a degenerate (zero-area) union. Sizes the {@link focalCssFrame} zoom
704
+ * window; mirrors the server-side union used by `resize({ crop:'focal', zoom })`.
705
+ */
706
+ export function unionFaceBox(faces?: FaceBox[] | null): SubjectBox | null {
707
+ if (!faces || faces.length === 0) return null;
708
+ let x0 = Infinity,
709
+ y0 = Infinity,
710
+ x1 = -Infinity,
711
+ y1 = -Infinity;
712
+ for (const f of faces) {
713
+ x0 = Math.min(x0, f.x);
714
+ y0 = Math.min(y0, f.y);
715
+ x1 = Math.max(x1, f.x + f.w);
716
+ y1 = Math.max(y1, f.y + f.h);
717
+ }
718
+ x0 = clamp01(x0);
719
+ y0 = clamp01(y0);
720
+ x1 = clamp01(x1);
721
+ y1 = clamp01(y1);
722
+ const w = x1 - x0;
723
+ const h = y1 - y0;
724
+ if (w <= 0 || h <= 0) return null;
725
+ return { x: x0, y: y0, w, h };
726
+ }
727
+
728
+ /**
729
+ * The primary (highest-confidence) face as a subject box plus a focal point
730
+ * centred on it (eye-line biased), clamped to `0..1`; `null` if none. Mirrors
731
+ * the server's `subject: 'primary'` — lets a group photo crop to one person.
732
+ */
733
+ export function primaryFaceBox(
734
+ faces?: FaceBox[] | null,
735
+ ): { box: SubjectBox; focal: FocalPoint } | null {
736
+ if (!faces || faces.length === 0) return null;
737
+ const f = faces.reduce((a, b) => (b.score > a.score ? b : a));
738
+ const x0 = clamp01(f.x);
739
+ const y0 = clamp01(f.y);
740
+ const x1 = clamp01(f.x + f.w);
741
+ const y1 = clamp01(f.y + f.h);
742
+ const w = x1 - x0;
743
+ const h = y1 - y0;
744
+ if (w <= 0 || h <= 0) return null;
745
+ return {
746
+ box: { x: x0, y: y0, w, h },
747
+ focal: { x: x0 + w / 2, y: clamp01(y0 + 0.4 * h) },
748
+ };
749
+ }
750
+
751
+ /**
752
+ * Compute CSS values that frame a source image on its detected face(s) for a
753
+ * container of ANY aspect ratio — the browser-side counterpart to the server's
754
+ * `resize({ crop: 'focal', zoom })`. Instead of a pre-rendered crop per shape,
755
+ * apply the result to a single `<img>`:
756
+ *
757
+ * ```html
758
+ * <div style="aspect-ratio: 3/1; overflow: hidden">
759
+ * <img
760
+ * style="width:100%; height:100%; object-fit:cover;
761
+ * object-position: {objectPosition}; transform-origin: {transformOrigin};
762
+ * transform: scale({scale})" />
763
+ * </div>
764
+ * ```
765
+ *
766
+ * `object-position` pans the cover image onto the focal point; the extra
767
+ * `transform: scale` tightens the frame around the face(s) per `zoom`, scaling
768
+ * about the same anchor so the face stays put. Framework-agnostic: it returns
769
+ * plain CSS values — apply them however your framework applies styles.
770
+ *
771
+ * With no detected faces it degrades to a plain focal-point cover (`scale: 1`).
772
+ */
773
+ export function focalCssFrame(input: FocalCssFrameInput): FocalCssFrame {
774
+ const { faces, imgWidth, imgHeight, containerAspect } = input;
775
+
776
+ // Subject box (+ focal): primary face re-centres on that face; union keeps the
777
+ // supplied focal. Mirrors the server-side subject selection.
778
+ let box: SubjectBox | null;
779
+ let focal = input.focal;
780
+ if (input.subject === 'primary') {
781
+ const p = primaryFaceBox(faces);
782
+ box = p?.box ?? null;
783
+ if (p) focal = p.focal;
784
+ } else {
785
+ box = unionFaceBox(faces);
786
+ }
787
+
788
+ const objectPosition = `${(clamp01(focal.x) * 100).toFixed(1)}% ${(clamp01(focal.y) * 100).toFixed(1)}%`;
789
+ const base: FocalCssFrame = { objectPosition, scale: 1, transformOrigin: objectPosition };
790
+ if (!box || imgWidth <= 0 || imgHeight <= 0 || !(containerAspect > 0)) return base;
791
+
792
+ const iar = imgWidth / imgHeight; // source aspect
793
+ const car = containerAspect; // container aspect
794
+ const z = input.zoom === 'auto' ? AUTO_ZOOM : Math.max(0.01, input.zoom ?? 1);
795
+ const grow = 1 + 2 * Math.max(0, input.padding ?? 0);
796
+ const bw = box.w * grow;
797
+ const bh = box.h * grow;
798
+
799
+ // Normalized width of the (padded) output-aspect frame, scaled by zoom.
800
+ const targetW = z * Math.max(bw, (bh * car) / iar);
801
+ // Normalized width the browser's own `object-fit: cover` already shows.
802
+ const coverW = car >= iar ? 1 : car / iar;
803
+ // Extra scale to tighten from cover to the target window; floored at 1 (can't
804
+ // show more than cover) — the saturation floor matching the server crop.
805
+ const scale = Math.max(1, coverW / targetW);
806
+
807
+ return { objectPosition, scale: Math.round(scale * 1000) / 1000, transformOrigin: objectPosition };
808
+ }
809
+
607
810
  /** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
608
811
  export const transforms = {
609
812
  keyFor,
813
+ focalCssFrame,
814
+ unionFaceBox,
815
+ primaryFaceBox,
610
816
  };
611
817
 
612
818
  // ── Declarative `transforms` config (consumed by the adapter compiler) ──
@@ -0,0 +1,156 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { focalCssFrame, unionFaceBox, primaryFaceBox, type FaceBox } from '../src/transforms.js';
4
+
5
+ const face = (x: number, y: number, w: number, h: number, score = 0.9): FaceBox => ({
6
+ x,
7
+ y,
8
+ w,
9
+ h,
10
+ score,
11
+ });
12
+
13
+ describe('unionFaceBox', () => {
14
+ it('is null for no faces', () => {
15
+ expect(unionFaceBox([])).toBeNull();
16
+ expect(unionFaceBox(null)).toBeNull();
17
+ expect(unionFaceBox(undefined)).toBeNull();
18
+ });
19
+
20
+ it('returns the single face', () => {
21
+ const b = unionFaceBox([face(0.3, 0.2, 0.1, 0.15)])!;
22
+ expect(b.x).toBeCloseTo(0.3);
23
+ expect(b.y).toBeCloseTo(0.2);
24
+ expect(b.w).toBeCloseTo(0.1);
25
+ expect(b.h).toBeCloseTo(0.15);
26
+ });
27
+
28
+ it('covers disjoint faces', () => {
29
+ const b = unionFaceBox([face(0.1, 0.1, 0.1, 0.1), face(0.7, 0.6, 0.1, 0.2)])!;
30
+ expect(b.x).toBeCloseTo(0.1);
31
+ expect(b.y).toBeCloseTo(0.1);
32
+ expect(b.x + b.w).toBeCloseTo(0.8);
33
+ expect(b.y + b.h).toBeCloseTo(0.8);
34
+ });
35
+
36
+ it('clamps out-of-range coordinates to 0..1', () => {
37
+ const b = unionFaceBox([face(-0.1, -0.2, 0.4, 0.5)])!;
38
+ expect(b.x).toBe(0);
39
+ expect(b.y).toBe(0);
40
+ expect(b.x + b.w).toBeLessThanOrEqual(1);
41
+ expect(b.y + b.h).toBeLessThanOrEqual(1);
42
+ });
43
+ });
44
+
45
+ describe('focalCssFrame', () => {
46
+ const faces = [face(0.4, 0.4, 0.2, 0.2)];
47
+
48
+ it('anchors object-position on the focal point', () => {
49
+ const f = focalCssFrame({
50
+ focal: { x: 0.44, y: 0.27 },
51
+ faces,
52
+ imgWidth: 200,
53
+ imgHeight: 200,
54
+ containerAspect: 1,
55
+ zoom: 1,
56
+ });
57
+ expect(f.objectPosition).toBe('44.0% 27.0%');
58
+ expect(f.transformOrigin).toBe(f.objectPosition);
59
+ });
60
+
61
+ it('zoom=1 tightly frames the face (scale > 1)', () => {
62
+ const f = focalCssFrame({
63
+ focal: { x: 0.5, y: 0.5 },
64
+ faces,
65
+ imgWidth: 200,
66
+ imgHeight: 200,
67
+ containerAspect: 1,
68
+ zoom: 1,
69
+ });
70
+ // 0.2-wide face in a square container → scale 1/0.2 = 5.
71
+ expect(f.scale).toBeCloseTo(5, 3);
72
+ });
73
+
74
+ it('zoom pulls back linearly then saturates to cover (scale 1)', () => {
75
+ const at = (zoom: number) =>
76
+ focalCssFrame({ focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1, zoom }).scale;
77
+ expect(at(2)).toBeCloseTo(2.5, 3); // half the zoom=1 tightness
78
+ expect(at(100)).toBe(1); // saturates to plain cover
79
+ });
80
+
81
+ it('accounts for source aspect ratio (wide image, square container)', () => {
82
+ // 400x200 source (iar 2), square container: cover already shows half the
83
+ // width, so a 0.2-wide face needs scale 0.5/0.2 = 2.5, not 5.
84
+ const f = focalCssFrame({
85
+ focal: { x: 0.5, y: 0.5 },
86
+ faces,
87
+ imgWidth: 400,
88
+ imgHeight: 200,
89
+ containerAspect: 1,
90
+ zoom: 1,
91
+ });
92
+ expect(f.scale).toBeCloseTo(2.5, 3);
93
+ });
94
+
95
+ it('degrades to plain cover (scale 1) with no faces', () => {
96
+ const f = focalCssFrame({
97
+ focal: { x: 0.5, y: 0.5 },
98
+ faces: [],
99
+ imgWidth: 200,
100
+ imgHeight: 200,
101
+ containerAspect: 1.5,
102
+ zoom: 1,
103
+ });
104
+ expect(f.scale).toBe(1);
105
+ });
106
+
107
+ it('defaults zoom to 1 when omitted', () => {
108
+ const withDefault = focalCssFrame({ focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1 });
109
+ const explicit = focalCssFrame({ focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1, zoom: 1 });
110
+ expect(withDefault.scale).toBe(explicit.scale);
111
+ });
112
+
113
+ const sq = { focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1 };
114
+
115
+ it('zoom < 1 crops tighter (larger scale than zoom=1)', () => {
116
+ const one = focalCssFrame({ ...sq, zoom: 1 }).scale;
117
+ const tight = focalCssFrame({ ...sq, zoom: 0.5 }).scale;
118
+ expect(tight).toBeCloseTo(one * 2, 3); // 0.2 face, zoom 0.5 → scale 10 vs 5
119
+ });
120
+
121
+ it("zoom 'auto' sits between tight and cover", () => {
122
+ const auto = focalCssFrame({ ...sq, zoom: 'auto' }).scale;
123
+ const one = focalCssFrame({ ...sq, zoom: 1 }).scale;
124
+ // auto ≈ 1/1.22 of the zoom=1 tightness (a touch pulled back), still > 1.
125
+ expect(auto).toBeCloseTo(one / 1.22, 3);
126
+ expect(auto).toBeGreaterThan(1);
127
+ });
128
+
129
+ it('padding pulls back (smaller scale than no padding)', () => {
130
+ const none = focalCssFrame({ ...sq, zoom: 1 }).scale;
131
+ const padded = focalCssFrame({ ...sq, zoom: 1, padding: 0.5 }).scale;
132
+ expect(padded).toBeCloseTo(none / 2, 3); // box ×2 → half the scale
133
+ });
134
+
135
+ it("subject 'primary' frames the main face, not the union", () => {
136
+ const two = [face(0.05, 0.05, 0.1, 0.1, 0.6), face(0.6, 0.4, 0.2, 0.2, 0.95)];
137
+ const union = focalCssFrame({ focal: { x: 0.4, y: 0.3 }, faces: two, imgWidth: 200, imgHeight: 200, containerAspect: 1, zoom: 1 });
138
+ const primary = focalCssFrame({ focal: { x: 0.4, y: 0.3 }, faces: two, imgWidth: 200, imgHeight: 200, containerAspect: 1, zoom: 1, subject: 'primary' });
139
+ // Primary re-centres on the high-score face (x≈0.7) and frames a smaller box
140
+ // → different object-position and a tighter scale than the wide union.
141
+ expect(primary.objectPosition).not.toBe(union.objectPosition);
142
+ expect(primary.scale).toBeGreaterThan(union.scale);
143
+ });
144
+ });
145
+
146
+ describe('primaryFaceBox', () => {
147
+ it('picks the highest-score face and eye-line focal', () => {
148
+ const p = primaryFaceBox([face(0.05, 0.05, 0.1, 0.1, 0.6), face(0.6, 0.2, 0.2, 0.3, 0.95)])!;
149
+ expect(p.box.x).toBeCloseTo(0.6);
150
+ expect(p.focal.x).toBeCloseTo(0.7);
151
+ expect(p.focal.y).toBeCloseTo(0.2 + 0.4 * 0.3);
152
+ });
153
+ it('is null without faces', () => {
154
+ expect(primaryFaceBox([])).toBeNull();
155
+ });
156
+ });