@maravilla-labs/platform 0.15.0 → 0.16.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,35 @@ 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 (`>= 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.
163
+ */
164
+ zoom?: number;
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.
170
+ */
171
+ subject_box?: SubjectBox;
172
+ }
173
+ /**
174
+ * Union bounding box of a source's detected faces, normalized `0..=1`
175
+ * (top-left `x`/`y` + `w`/`h`). Sizes the `crop: 'focal'` {@link
176
+ * ResizeOpts.zoom} window; server-injected only.
177
+ */
178
+ interface SubjectBox {
179
+ x: number;
180
+ y: number;
181
+ w: number;
182
+ h: number;
154
183
  }
155
184
  /** Options for `transforms.ocr`. */
156
185
  interface OcrOpts {
@@ -443,9 +472,71 @@ interface TransformsService {
443
472
  * `crates/platform/tests/derive_key_vectors.json`.
444
473
  */
445
474
  declare function keyFor(srcKey: string, spec: TransformSpec): string;
475
+ /** Inputs for {@link focalCssFrame}. */
476
+ interface FocalCssFrameInput {
477
+ /** The source's focal point (from `detectFaces`/`getFocalPoint` or manual). */
478
+ focal: FocalPoint;
479
+ /** Detected face boxes (from the same record). Empty/absent → plain cover. */
480
+ faces?: FaceBox[] | null;
481
+ /**
482
+ * Source image intrinsic pixel dimensions — only their ratio is used. In the
483
+ * browser these are free from a loaded `<img>` (`naturalWidth`/`Height`);
484
+ * `detectFaces`' output artifact also carries them.
485
+ */
486
+ imgWidth: number;
487
+ imgHeight: number;
488
+ /** Target container aspect ratio, `width / height`. */
489
+ containerAspect: number;
490
+ /**
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.
494
+ */
495
+ zoom?: number;
496
+ }
497
+ /** CSS values produced by {@link focalCssFrame}. */
498
+ interface FocalCssFrame {
499
+ /** `object-position` value, e.g. `"44.0% 27.0%"`. */
500
+ objectPosition: string;
501
+ /** `transform: scale(...)` factor to apply on top of `object-fit: cover`. */
502
+ scale: number;
503
+ /** `transform-origin` — same anchor as `objectPosition`. */
504
+ transformOrigin: string;
505
+ }
506
+ /**
507
+ * Union bounding box of detected faces, clamped to `0..1`; `null` for an empty
508
+ * list or a degenerate (zero-area) union. Sizes the {@link focalCssFrame} zoom
509
+ * window; mirrors the server-side union used by `resize({ crop:'focal', zoom })`.
510
+ */
511
+ declare function unionFaceBox(faces?: FaceBox[] | null): SubjectBox | null;
512
+ /**
513
+ * Compute CSS values that frame a source image on its detected face(s) for a
514
+ * container of ANY aspect ratio — the browser-side counterpart to the server's
515
+ * `resize({ crop: 'focal', zoom })`. Instead of a pre-rendered crop per shape,
516
+ * apply the result to a single `<img>`:
517
+ *
518
+ * ```html
519
+ * <div style="aspect-ratio: 3/1; overflow: hidden">
520
+ * <img
521
+ * style="width:100%; height:100%; object-fit:cover;
522
+ * object-position: {objectPosition}; transform-origin: {transformOrigin};
523
+ * transform: scale({scale})" />
524
+ * </div>
525
+ * ```
526
+ *
527
+ * `object-position` pans the cover image onto the focal point; the extra
528
+ * `transform: scale` tightens the frame around the face(s) per `zoom`, scaling
529
+ * about the same anchor so the face stays put. Framework-agnostic: it returns
530
+ * plain CSS values — apply them however your framework applies styles.
531
+ *
532
+ * With no detected faces it degrades to a plain focal-point cover (`scale: 1`).
533
+ */
534
+ declare function focalCssFrame(input: FocalCssFrameInput): FocalCssFrame;
446
535
  /** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
447
536
  declare const transforms: {
448
537
  keyFor: typeof keyFor;
538
+ focalCssFrame: typeof focalCssFrame;
539
+ unionFaceBox: typeof unionFaceBox;
449
540
  };
450
541
  /**
451
542
  * Per-pattern transforms declaration used inside the `transforms` block of
@@ -500,4 +591,4 @@ interface TransformsPatternSpec {
500
591
  */
501
592
  type TransformsConfig = Record<string, TransformsPatternSpec>;
502
593
 
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 };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maravilla-labs/platform",
3
- "version": "0.15.0",
3
+ "version": "0.16.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,36 @@ 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 (`>= 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.
217
+ */
218
+ zoom?: number;
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.
224
+ */
225
+ subject_box?: SubjectBox;
226
+ }
227
+
228
+ /**
229
+ * Union bounding box of a source's detected faces, normalized `0..=1`
230
+ * (top-left `x`/`y` + `w`/`h`). Sizes the `crop: 'focal'` {@link
231
+ * ResizeOpts.zoom} window; server-injected only.
232
+ */
233
+ export interface SubjectBox {
234
+ x: number;
235
+ y: number;
236
+ w: number;
237
+ h: number;
208
238
  }
209
239
 
210
240
  /** Options for `transforms.ocr`. */
@@ -604,9 +634,122 @@ export function keyFor(srcKey: string, spec: TransformSpec): string {
604
634
  return `__derived/${srcHash}/${variantHash}.${ext}`;
605
635
  }
606
636
 
637
+ // ── CSS-only focal framing (isomorphic, framework-agnostic) ───────────────
638
+
639
+ /** Inputs for {@link focalCssFrame}. */
640
+ export interface FocalCssFrameInput {
641
+ /** The source's focal point (from `detectFaces`/`getFocalPoint` or manual). */
642
+ focal: FocalPoint;
643
+ /** Detected face boxes (from the same record). Empty/absent → plain cover. */
644
+ faces?: FaceBox[] | null;
645
+ /**
646
+ * Source image intrinsic pixel dimensions — only their ratio is used. In the
647
+ * browser these are free from a loaded `<img>` (`naturalWidth`/`Height`);
648
+ * `detectFaces`' output artifact also carries them.
649
+ */
650
+ imgWidth: number;
651
+ imgHeight: number;
652
+ /** Target container aspect ratio, `width / height`. */
653
+ containerAspect: number;
654
+ /**
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.
658
+ */
659
+ zoom?: number;
660
+ }
661
+
662
+ /** CSS values produced by {@link focalCssFrame}. */
663
+ export interface FocalCssFrame {
664
+ /** `object-position` value, e.g. `"44.0% 27.0%"`. */
665
+ objectPosition: string;
666
+ /** `transform: scale(...)` factor to apply on top of `object-fit: cover`. */
667
+ scale: number;
668
+ /** `transform-origin` — same anchor as `objectPosition`. */
669
+ transformOrigin: string;
670
+ }
671
+
672
+ const clamp01 = (n: number) => Math.min(1, Math.max(0, n));
673
+
674
+ /**
675
+ * Union bounding box of detected faces, clamped to `0..1`; `null` for an empty
676
+ * list or a degenerate (zero-area) union. Sizes the {@link focalCssFrame} zoom
677
+ * window; mirrors the server-side union used by `resize({ crop:'focal', zoom })`.
678
+ */
679
+ export function unionFaceBox(faces?: FaceBox[] | null): SubjectBox | null {
680
+ if (!faces || faces.length === 0) return null;
681
+ let x0 = Infinity,
682
+ y0 = Infinity,
683
+ x1 = -Infinity,
684
+ y1 = -Infinity;
685
+ for (const f of faces) {
686
+ x0 = Math.min(x0, f.x);
687
+ y0 = Math.min(y0, f.y);
688
+ x1 = Math.max(x1, f.x + f.w);
689
+ y1 = Math.max(y1, f.y + f.h);
690
+ }
691
+ x0 = clamp01(x0);
692
+ y0 = clamp01(y0);
693
+ x1 = clamp01(x1);
694
+ y1 = clamp01(y1);
695
+ const w = x1 - x0;
696
+ const h = y1 - y0;
697
+ if (w <= 0 || h <= 0) return null;
698
+ return { x: x0, y: y0, w, h };
699
+ }
700
+
701
+ /**
702
+ * Compute CSS values that frame a source image on its detected face(s) for a
703
+ * container of ANY aspect ratio — the browser-side counterpart to the server's
704
+ * `resize({ crop: 'focal', zoom })`. Instead of a pre-rendered crop per shape,
705
+ * apply the result to a single `<img>`:
706
+ *
707
+ * ```html
708
+ * <div style="aspect-ratio: 3/1; overflow: hidden">
709
+ * <img
710
+ * style="width:100%; height:100%; object-fit:cover;
711
+ * object-position: {objectPosition}; transform-origin: {transformOrigin};
712
+ * transform: scale({scale})" />
713
+ * </div>
714
+ * ```
715
+ *
716
+ * `object-position` pans the cover image onto the focal point; the extra
717
+ * `transform: scale` tightens the frame around the face(s) per `zoom`, scaling
718
+ * about the same anchor so the face stays put. Framework-agnostic: it returns
719
+ * plain CSS values — apply them however your framework applies styles.
720
+ *
721
+ * With no detected faces it degrades to a plain focal-point cover (`scale: 1`).
722
+ */
723
+ export function focalCssFrame(input: FocalCssFrameInput): FocalCssFrame {
724
+ const { focal, faces, imgWidth, imgHeight, containerAspect } = input;
725
+ const zoom = input.zoom ?? 1;
726
+ const objectPosition = `${(clamp01(focal.x) * 100).toFixed(1)}% ${(clamp01(focal.y) * 100).toFixed(1)}%`;
727
+ const base: FocalCssFrame = { objectPosition, scale: 1, transformOrigin: objectPosition };
728
+
729
+ const box = unionFaceBox(faces);
730
+ if (!box || imgWidth <= 0 || imgHeight <= 0 || !(containerAspect > 0)) return base;
731
+
732
+ const iar = imgWidth / imgHeight; // source aspect
733
+ const car = containerAspect; // container aspect
734
+ const z = Math.max(1, zoom);
735
+
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);
739
+ // Normalized width the browser's own `object-fit: cover` already shows.
740
+ const coverW = car >= iar ? 1 : car / iar;
741
+ // Extra scale to tighten from cover to the target window; floored at 1 (can't
742
+ // show more than cover) — the saturation floor matching the server crop.
743
+ const scale = Math.max(1, coverW / targetW);
744
+
745
+ return { objectPosition, scale: Math.round(scale * 1000) / 1000, transformOrigin: objectPosition };
746
+ }
747
+
607
748
  /** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
608
749
  export const transforms = {
609
750
  keyFor,
751
+ focalCssFrame,
752
+ unionFaceBox,
610
753
  };
611
754
 
612
755
  // ── Declarative `transforms` config (consumed by the adapter compiler) ──
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { focalCssFrame, unionFaceBox, type FaceBox } from '../src/transforms.js';
4
+
5
+ const face = (x: number, y: number, w: number, h: number): FaceBox => ({ x, y, w, h, score: 0.9 });
6
+
7
+ describe('unionFaceBox', () => {
8
+ it('is null for no faces', () => {
9
+ expect(unionFaceBox([])).toBeNull();
10
+ expect(unionFaceBox(null)).toBeNull();
11
+ expect(unionFaceBox(undefined)).toBeNull();
12
+ });
13
+
14
+ it('returns the single face', () => {
15
+ const b = unionFaceBox([face(0.3, 0.2, 0.1, 0.15)])!;
16
+ expect(b.x).toBeCloseTo(0.3);
17
+ expect(b.y).toBeCloseTo(0.2);
18
+ expect(b.w).toBeCloseTo(0.1);
19
+ expect(b.h).toBeCloseTo(0.15);
20
+ });
21
+
22
+ it('covers disjoint faces', () => {
23
+ const b = unionFaceBox([face(0.1, 0.1, 0.1, 0.1), face(0.7, 0.6, 0.1, 0.2)])!;
24
+ expect(b.x).toBeCloseTo(0.1);
25
+ expect(b.y).toBeCloseTo(0.1);
26
+ expect(b.x + b.w).toBeCloseTo(0.8);
27
+ expect(b.y + b.h).toBeCloseTo(0.8);
28
+ });
29
+
30
+ it('clamps out-of-range coordinates to 0..1', () => {
31
+ const b = unionFaceBox([face(-0.1, -0.2, 0.4, 0.5)])!;
32
+ expect(b.x).toBe(0);
33
+ expect(b.y).toBe(0);
34
+ expect(b.x + b.w).toBeLessThanOrEqual(1);
35
+ expect(b.y + b.h).toBeLessThanOrEqual(1);
36
+ });
37
+ });
38
+
39
+ describe('focalCssFrame', () => {
40
+ const faces = [face(0.4, 0.4, 0.2, 0.2)];
41
+
42
+ it('anchors object-position on the focal point', () => {
43
+ const f = focalCssFrame({
44
+ focal: { x: 0.44, y: 0.27 },
45
+ faces,
46
+ imgWidth: 200,
47
+ imgHeight: 200,
48
+ containerAspect: 1,
49
+ zoom: 1,
50
+ });
51
+ expect(f.objectPosition).toBe('44.0% 27.0%');
52
+ expect(f.transformOrigin).toBe(f.objectPosition);
53
+ });
54
+
55
+ it('zoom=1 tightly frames the face (scale > 1)', () => {
56
+ const f = focalCssFrame({
57
+ focal: { x: 0.5, y: 0.5 },
58
+ faces,
59
+ imgWidth: 200,
60
+ imgHeight: 200,
61
+ containerAspect: 1,
62
+ zoom: 1,
63
+ });
64
+ // 0.2-wide face in a square container → scale 1/0.2 = 5.
65
+ expect(f.scale).toBeCloseTo(5, 3);
66
+ });
67
+
68
+ it('zoom pulls back linearly then saturates to cover (scale 1)', () => {
69
+ const at = (zoom: number) =>
70
+ focalCssFrame({ focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1, zoom }).scale;
71
+ expect(at(2)).toBeCloseTo(2.5, 3); // half the zoom=1 tightness
72
+ expect(at(100)).toBe(1); // saturates to plain cover
73
+ });
74
+
75
+ it('accounts for source aspect ratio (wide image, square container)', () => {
76
+ // 400x200 source (iar 2), square container: cover already shows half the
77
+ // width, so a 0.2-wide face needs scale 0.5/0.2 = 2.5, not 5.
78
+ const f = focalCssFrame({
79
+ focal: { x: 0.5, y: 0.5 },
80
+ faces,
81
+ imgWidth: 400,
82
+ imgHeight: 200,
83
+ containerAspect: 1,
84
+ zoom: 1,
85
+ });
86
+ expect(f.scale).toBeCloseTo(2.5, 3);
87
+ });
88
+
89
+ it('degrades to plain cover (scale 1) with no faces', () => {
90
+ const f = focalCssFrame({
91
+ focal: { x: 0.5, y: 0.5 },
92
+ faces: [],
93
+ imgWidth: 200,
94
+ imgHeight: 200,
95
+ containerAspect: 1.5,
96
+ zoom: 1,
97
+ });
98
+ expect(f.scale).toBe(1);
99
+ });
100
+
101
+ it('defaults zoom to 1 when omitted', () => {
102
+ const withDefault = focalCssFrame({ focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1 });
103
+ const explicit = focalCssFrame({ focal: { x: 0.5, y: 0.5 }, faces, imgWidth: 200, imgHeight: 200, containerAspect: 1, zoom: 1 });
104
+ expect(withDefault.scale).toBe(explicit.scale);
105
+ });
106
+ });