@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.
- package/CHANGELOG.md +27 -0
- package/dist/config.d.ts +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +39 -1
- package/dist/index.js.map +1 -1
- package/dist/{transforms-BPrlJoYd.d.ts → transforms-DkAlUPu6.d.ts} +92 -1
- package/package.json +1 -1
- package/src/transforms.ts +143 -0
- package/tests/focal-css.test.ts +106 -0
|
@@ -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,
|
|
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
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
|
+
});
|