@maravilla-labs/platform 0.16.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.
@@ -152,21 +152,34 @@ interface ResizeOpts {
152
152
  */
153
153
  focal?: FocalPoint;
154
154
  /**
155
- * Face-box-relative crop zoom for `crop: 'focal'`. A scale factor (`>= 1.0`)
156
- * on the smallest output-aspect frame that fully contains the source's
157
- * detected face(s): `1.0` frames tightly around the subject; larger values
158
- * pull back for more headroom/context; a value large enough to exceed the
159
- * image saturates to the plain scale-to-cover crop. Only meaningful with
160
- * `crop: 'focal'` on a source that has stored face data (from `detectFaces`);
161
- * ignored otherwise. Omit for the legacy point-only crop when unset it does
162
- * not affect the derived key, so existing crops are unchanged.
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
163
  */
164
- zoom?: number;
164
+ zoom?: number | 'auto';
165
165
  /**
166
- * Union bounding box of the source's detected faces, normalized `0..=1`.
167
- * Server-injected (like {@link ResizeOpts.focal}) and only when `zoom` is set
168
- * callers never pass this. Present so the wire type mirrors the Rust
169
- * `ResizeOpts` exactly.
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.
170
183
  */
171
184
  subject_box?: SubjectBox;
172
185
  }
@@ -488,11 +501,22 @@ interface FocalCssFrameInput {
488
501
  /** Target container aspect ratio, `width / height`. */
489
502
  containerAspect: number;
490
503
  /**
491
- * Frame zoom, clamped to `>= 1`. `1` frames tightly around the face(s); larger
492
- * pulls back toward the plain cover view. Defaults to `1`. Mirrors the
493
- * server-side `resize({ crop: 'focal', zoom })` semantics.
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.
494
518
  */
495
- zoom?: number;
519
+ subject?: 'union' | 'primary';
496
520
  }
497
521
  /** CSS values produced by {@link focalCssFrame}. */
498
522
  interface FocalCssFrame {
@@ -509,6 +533,15 @@ interface FocalCssFrame {
509
533
  * window; mirrors the server-side union used by `resize({ crop:'focal', zoom })`.
510
534
  */
511
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;
512
545
  /**
513
546
  * Compute CSS values that frame a source image on its detected face(s) for a
514
547
  * container of ANY aspect ratio — the browser-side counterpart to the server's
@@ -537,6 +570,7 @@ declare const transforms: {
537
570
  keyFor: typeof keyFor;
538
571
  focalCssFrame: typeof focalCssFrame;
539
572
  unionFaceBox: typeof unionFaceBox;
573
+ primaryFaceBox: typeof primaryFaceBox;
540
574
  };
541
575
  /**
542
576
  * Per-pattern transforms declaration used inside the `transforms` block of
@@ -591,4 +625,4 @@ interface TransformsPatternSpec {
591
625
  */
592
626
  type TransformsConfig = Record<string, TransformsPatternSpec>;
593
627
 
594
- export { unionFaceBox as A, focalCssFrame as B, transforms as C, 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 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 };
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.16.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
@@ -206,21 +206,34 @@ export interface ResizeOpts {
206
206
  */
207
207
  focal?: FocalPoint;
208
208
  /**
209
- * Face-box-relative crop zoom for `crop: 'focal'`. A scale factor (`>= 1.0`)
210
- * on the smallest output-aspect frame that fully contains the source's
211
- * detected face(s): `1.0` frames tightly around the subject; larger values
212
- * pull back for more headroom/context; a value large enough to exceed the
213
- * image saturates to the plain scale-to-cover crop. Only meaningful with
214
- * `crop: 'focal'` on a source that has stored face data (from `detectFaces`);
215
- * ignored otherwise. Omit for the legacy point-only crop when unset it does
216
- * not affect the derived key, so existing crops are unchanged.
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
217
  */
218
- zoom?: number;
218
+ zoom?: number | 'auto';
219
219
  /**
220
- * Union bounding box of the source's detected faces, normalized `0..=1`.
221
- * Server-injected (like {@link ResizeOpts.focal}) and only when `zoom` is set
222
- * callers never pass this. Present so the wire type mirrors the Rust
223
- * `ResizeOpts` exactly.
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.
224
237
  */
225
238
  subject_box?: SubjectBox;
226
239
  }
@@ -652,13 +665,27 @@ export interface FocalCssFrameInput {
652
665
  /** Target container aspect ratio, `width / height`. */
653
666
  containerAspect: number;
654
667
  /**
655
- * Frame zoom, clamped to `>= 1`. `1` frames tightly around the face(s); larger
656
- * pulls back toward the plain cover view. Defaults to `1`. Mirrors the
657
- * server-side `resize({ crop: 'focal', zoom })` semantics.
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.
658
672
  */
659
- zoom?: number;
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';
660
684
  }
661
685
 
686
+ /** `zoom: 'auto'` as a factor — mirrors the Rust `AUTO_ZOOM` (subject ~82%). */
687
+ const AUTO_ZOOM = 1.22;
688
+
662
689
  /** CSS values produced by {@link focalCssFrame}. */
663
690
  export interface FocalCssFrame {
664
691
  /** `object-position` value, e.g. `"44.0% 27.0%"`. */
@@ -698,6 +725,29 @@ export function unionFaceBox(faces?: FaceBox[] | null): SubjectBox | null {
698
725
  return { x: x0, y: y0, w, h };
699
726
  }
700
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
+
701
751
  /**
702
752
  * Compute CSS values that frame a source image on its detected face(s) for a
703
753
  * container of ANY aspect ratio — the browser-side counterpart to the server's
@@ -721,21 +771,33 @@ export function unionFaceBox(faces?: FaceBox[] | null): SubjectBox | null {
721
771
  * With no detected faces it degrades to a plain focal-point cover (`scale: 1`).
722
772
  */
723
773
  export function focalCssFrame(input: FocalCssFrameInput): FocalCssFrame {
724
- const { focal, faces, imgWidth, imgHeight, containerAspect } = input;
725
- const zoom = input.zoom ?? 1;
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
+
726
788
  const objectPosition = `${(clamp01(focal.x) * 100).toFixed(1)}% ${(clamp01(focal.y) * 100).toFixed(1)}%`;
727
789
  const base: FocalCssFrame = { objectPosition, scale: 1, transformOrigin: objectPosition };
728
-
729
- const box = unionFaceBox(faces);
730
790
  if (!box || imgWidth <= 0 || imgHeight <= 0 || !(containerAspect > 0)) return base;
731
791
 
732
792
  const iar = imgWidth / imgHeight; // source aspect
733
793
  const car = containerAspect; // container aspect
734
- const z = Math.max(1, zoom);
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;
735
798
 
736
- // Normalized width of the tight, output-aspect frame around the face(s),
737
- // scaled by zoom. (Collapses to depend only on the aspect ratios.)
738
- const targetW = z * Math.max(box.w, (box.h * car) / iar);
799
+ // Normalized width of the (padded) output-aspect frame, scaled by zoom.
800
+ const targetW = z * Math.max(bw, (bh * car) / iar);
739
801
  // Normalized width the browser's own `object-fit: cover` already shows.
740
802
  const coverW = car >= iar ? 1 : car / iar;
741
803
  // Extra scale to tighten from cover to the target window; floored at 1 (can't
@@ -750,6 +812,7 @@ export const transforms = {
750
812
  keyFor,
751
813
  focalCssFrame,
752
814
  unionFaceBox,
815
+ primaryFaceBox,
753
816
  };
754
817
 
755
818
  // ── Declarative `transforms` config (consumed by the adapter compiler) ──
@@ -1,8 +1,14 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
- import { focalCssFrame, unionFaceBox, type FaceBox } from '../src/transforms.js';
3
+ import { focalCssFrame, unionFaceBox, primaryFaceBox, type FaceBox } from '../src/transforms.js';
4
4
 
5
- const face = (x: number, y: number, w: number, h: number): FaceBox => ({ x, y, w, h, score: 0.9 });
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
+ });
6
12
 
7
13
  describe('unionFaceBox', () => {
8
14
  it('is null for no faces', () => {
@@ -103,4 +109,48 @@ describe('focalCssFrame', () => {
103
109
  const explicit = focalCssFrame({ focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1, zoom: 1 });
104
110
  expect(withDefault.scale).toBe(explicit.scale);
105
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
+ });
106
156
  });