@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.
- package/CHANGELOG.md +18 -0
- package/dist/config.d.ts +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +34 -6
- package/dist/index.js.map +1 -1
- package/dist/{transforms-DkAlUPu6.d.ts → transforms-C2zafn5k.d.ts} +52 -18
- package/package.json +1 -1
- package/src/transforms.ts +88 -25
- package/tests/focal-css.test.ts +52 -2
|
@@ -152,21 +152,34 @@ interface ResizeOpts {
|
|
|
152
152
|
*/
|
|
153
153
|
focal?: FocalPoint;
|
|
154
154
|
/**
|
|
155
|
-
* Face-box-relative crop zoom for `crop: 'focal'
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* not affect the derived key
|
|
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
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
|
|
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
|
|
492
|
-
* pulls back toward
|
|
493
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
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'
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
* not affect the derived key
|
|
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
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
|
|
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
|
|
656
|
-
* pulls back toward
|
|
657
|
-
*
|
|
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 {
|
|
725
|
-
|
|
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(
|
|
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
|
|
737
|
-
|
|
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) ──
|
package/tests/focal-css.test.ts
CHANGED
|
@@ -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 => ({
|
|
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
|
});
|