@maravilla-labs/platform 0.14.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 +37 -0
- package/dist/config.d.ts +42 -3
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.js +80 -1
- package/dist/index.js.map +1 -1
- package/dist/{transforms-BHWez32E.d.ts → transforms-DkAlUPu6.d.ts} +196 -1
- package/package.json +1 -1
- package/src/config.ts +40 -0
- package/src/remote-client.ts +62 -7
- package/src/transforms.ts +249 -0
- package/src/types.ts +21 -12
- package/tests/focal-css.test.ts +106 -0
package/src/transforms.ts
CHANGED
|
@@ -187,6 +187,54 @@ export interface ResizeOpts {
|
|
|
187
187
|
format: ImageFormat;
|
|
188
188
|
quality?: number;
|
|
189
189
|
strip_metadata?: boolean;
|
|
190
|
+
/**
|
|
191
|
+
* Crop strategy when BOTH `width` and `height` are given (the output is
|
|
192
|
+
* cropped to fill the exact box rather than letterboxed):
|
|
193
|
+
* - `'center'` — crop around the geometric center.
|
|
194
|
+
* - `'focal'` — crop around the source's focal point. The server injects
|
|
195
|
+
* the focal point from the persisted {@link FocalPointRecord} (set via
|
|
196
|
+
* `detectFaces` or `setFocalPoint`); if none exists it falls back to
|
|
197
|
+
* center.
|
|
198
|
+
* Omit for the default aspect-preserving fit (no crop).
|
|
199
|
+
*/
|
|
200
|
+
crop?: 'focal' | 'center';
|
|
201
|
+
/**
|
|
202
|
+
* Explicit focal point for `crop: 'focal'`, normalized `0..=1`. Server-
|
|
203
|
+
* injected — callers normally omit this and let the platform resolve the
|
|
204
|
+
* stored focal point for `src_key`. Present here so the wire type mirrors
|
|
205
|
+
* the Rust `ResizeOpts` exactly.
|
|
206
|
+
*/
|
|
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;
|
|
190
238
|
}
|
|
191
239
|
|
|
192
240
|
/** Options for `transforms.ocr`. */
|
|
@@ -195,6 +243,60 @@ export interface OcrOpts {
|
|
|
195
243
|
lang?: string;
|
|
196
244
|
}
|
|
197
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Options for `transforms.detectFaces` — run face detection over an image and
|
|
248
|
+
* persist the resulting focal point (the center of the highest-scoring face,
|
|
249
|
+
* or the centroid of all faces) as a {@link FocalPointRecord} keyed on the
|
|
250
|
+
* source. The derived output is a JSON artifact listing the detected boxes.
|
|
251
|
+
*/
|
|
252
|
+
export interface DetectFacesOpts {
|
|
253
|
+
/** Minimum detector confidence to keep a face, `0..=1`. Defaults to `0.6` server-side. */
|
|
254
|
+
min_score?: number;
|
|
255
|
+
/** Cap on the number of faces retained (highest-scoring first). Defaults to `32` server-side. */
|
|
256
|
+
max_faces?: number;
|
|
257
|
+
/** Trade accuracy for latency (smaller detector input). Defaults to `false` server-side. */
|
|
258
|
+
fast?: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A focal point in normalized image coordinates, each component `0..=1`. */
|
|
262
|
+
export interface FocalPoint {
|
|
263
|
+
x: number;
|
|
264
|
+
y: number;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A single detected face box, normalized to `0..1` of the image dimensions. */
|
|
268
|
+
export interface FaceBox {
|
|
269
|
+
x: number;
|
|
270
|
+
y: number;
|
|
271
|
+
w: number;
|
|
272
|
+
h: number;
|
|
273
|
+
/** Detector confidence, `0..1`. */
|
|
274
|
+
score: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Persisted focal-point record for a source image — the result of
|
|
279
|
+
* `detectFaces` or a manual `setFocalPoint`. Returned by `getFocalPoint`.
|
|
280
|
+
*/
|
|
281
|
+
export interface FocalPointRecord {
|
|
282
|
+
/** Storage key of the source image this focal point belongs to. */
|
|
283
|
+
src_key: string;
|
|
284
|
+
/** The resolved focal point, normalized `0..=1`. */
|
|
285
|
+
focal: FocalPoint;
|
|
286
|
+
/** Whether the point came from face detection or a manual override. */
|
|
287
|
+
source: 'faces' | 'manual';
|
|
288
|
+
/** Number of faces detected (`null` until a detection has run for this key). */
|
|
289
|
+
face_count: number | null;
|
|
290
|
+
/** The detected face boxes, when `source === 'faces'`. */
|
|
291
|
+
faces?: FaceBox[];
|
|
292
|
+
/** Content hash of the source bytes the detection ran against. */
|
|
293
|
+
src_hash?: string;
|
|
294
|
+
/** Version tag of the detector that produced this record. */
|
|
295
|
+
detector_version?: string;
|
|
296
|
+
/** Unix-epoch milliseconds the record was last written. */
|
|
297
|
+
updated_at: number;
|
|
298
|
+
}
|
|
299
|
+
|
|
198
300
|
/**
|
|
199
301
|
* LibreOffice-supported output container for `doc_convert`. Each value
|
|
200
302
|
* maps to a `--convert-to` filter and a derived-key extension.
|
|
@@ -326,6 +428,7 @@ export type TransformSpec =
|
|
|
326
428
|
| ({ kind: 'extract_frames' } & ExtractFramesOpts)
|
|
327
429
|
| ({ kind: 'extract_audio' } & ExtractAudioOpts)
|
|
328
430
|
| ({ kind: 'resize' } & ResizeOpts)
|
|
431
|
+
| ({ kind: 'detect_faces' } & DetectFacesOpts)
|
|
329
432
|
| ({ kind: 'ocr' } & OcrOpts)
|
|
330
433
|
| ({ kind: 'doc_to_pdf' } & DocToPdfOpts)
|
|
331
434
|
| ({ kind: 'doc_thumbnail' } & DocThumbnailOpts)
|
|
@@ -380,6 +483,30 @@ export interface TransformsService {
|
|
|
380
483
|
resize(srcKey: string, opts: ResizeOpts): Promise<JobHandle>;
|
|
381
484
|
probe(srcKey: string): Promise<MediaInfo>;
|
|
382
485
|
ocr(srcKey: string, opts?: OcrOpts | null): Promise<JobHandle>;
|
|
486
|
+
/**
|
|
487
|
+
* Run face detection over an image and persist the resulting focal point as
|
|
488
|
+
* a {@link FocalPointRecord}. Resolves to a `JobHandle` whose `output_key`
|
|
489
|
+
* holds a JSON artifact of the detected {@link FaceBox | boxes} once
|
|
490
|
+
* complete. Subsequent `resize` calls with `crop: 'focal'` on the same
|
|
491
|
+
* source pick up the stored focal point automatically.
|
|
492
|
+
*/
|
|
493
|
+
detectFaces(srcKey: string, opts?: DetectFacesOpts | null): Promise<JobHandle>;
|
|
494
|
+
/**
|
|
495
|
+
* Manually set (or override) the focal point for a source image, normalized
|
|
496
|
+
* `0..=1`. Persists a {@link FocalPointRecord} with `source: 'manual'`.
|
|
497
|
+
* Resolves to `null`.
|
|
498
|
+
*/
|
|
499
|
+
setFocalPoint(srcKey: string, point: FocalPoint): Promise<null>;
|
|
500
|
+
/**
|
|
501
|
+
* Read the persisted {@link FocalPointRecord} for a source, or `null` when
|
|
502
|
+
* none has been set (neither `detectFaces` nor `setFocalPoint` has run).
|
|
503
|
+
*/
|
|
504
|
+
getFocalPoint(srcKey: string): Promise<FocalPointRecord | null>;
|
|
505
|
+
/**
|
|
506
|
+
* Remove the persisted focal point for a source. Resolves to `true` when a
|
|
507
|
+
* record was deleted, `false` when there was nothing to clear.
|
|
508
|
+
*/
|
|
509
|
+
clearFocalPoint(srcKey: string): Promise<boolean>;
|
|
383
510
|
/** Convert a LibreOffice-supported document to PDF. */
|
|
384
511
|
docToPdf(srcKey: string, opts?: DocToPdfOpts | null): Promise<JobHandle>;
|
|
385
512
|
/** Render a single page of a document as a raster thumbnail. */
|
|
@@ -452,6 +579,9 @@ function outputExtension(spec: TransformSpec): string {
|
|
|
452
579
|
const fmt = (spec as { format?: ImageFormat }).format ?? 'jpg';
|
|
453
580
|
return fmt;
|
|
454
581
|
}
|
|
582
|
+
case 'detect_faces':
|
|
583
|
+
// Output key is the JSON artifact listing the detected face boxes.
|
|
584
|
+
return 'json';
|
|
455
585
|
case 'ocr':
|
|
456
586
|
return 'txt';
|
|
457
587
|
case 'doc_to_pdf':
|
|
@@ -504,9 +634,122 @@ export function keyFor(srcKey: string, spec: TransformSpec): string {
|
|
|
504
634
|
return `__derived/${srcHash}/${variantHash}.${ext}`;
|
|
505
635
|
}
|
|
506
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
|
+
|
|
507
748
|
/** Convenience namespace mirroring the Rust `transforms::keyFor` re-export. */
|
|
508
749
|
export const transforms = {
|
|
509
750
|
keyFor,
|
|
751
|
+
focalCssFrame,
|
|
752
|
+
unionFaceBox,
|
|
510
753
|
};
|
|
511
754
|
|
|
512
755
|
// ── Declarative `transforms` config (consumed by the adapter compiler) ──
|
|
@@ -533,6 +776,12 @@ export interface TransformsPatternSpec {
|
|
|
533
776
|
/** Sugar for `resize` arrays — same shape, same semantics. */
|
|
534
777
|
variants?: ResizeOpts[];
|
|
535
778
|
ocr?: OcrOpts | OcrOpts[];
|
|
779
|
+
/**
|
|
780
|
+
* Run face detection on matching uploads and persist a focal point so
|
|
781
|
+
* `resize`/`variants` entries with `crop: 'focal'` crop around faces. Pass
|
|
782
|
+
* `true` for detector defaults, or an object to tune the detection.
|
|
783
|
+
*/
|
|
784
|
+
detectFaces?: boolean | { min_score?: number; max_faces?: number; fast?: boolean };
|
|
536
785
|
}
|
|
537
786
|
|
|
538
787
|
/**
|
package/src/types.ts
CHANGED
|
@@ -206,37 +206,46 @@ export interface Database {
|
|
|
206
206
|
|
|
207
207
|
/**
|
|
208
208
|
* Update a single document in a collection.
|
|
209
|
-
*
|
|
209
|
+
*
|
|
210
210
|
* @param collection - The collection name
|
|
211
211
|
* @param filter - Query filter to match the document to update
|
|
212
212
|
* @param update - Update operations (MongoDB-style with $set, $inc, etc.)
|
|
213
|
-
* @returns Promise that resolves to
|
|
214
|
-
*
|
|
213
|
+
* @returns Promise that resolves to real matched/modified counts — `modified`
|
|
214
|
+
* is 0 when the filter matched nothing, so a conditional filter plus an
|
|
215
|
+
* atomic operator is safe for enforcement (quotas, redemption caps, seats)
|
|
216
|
+
*
|
|
215
217
|
* @example
|
|
216
218
|
* ```typescript
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
219
|
+
* // Atomic redemption-cap enforcement: only one of N concurrent checkouts
|
|
220
|
+
* // can win each remaining slot — no read-then-act needed.
|
|
221
|
+
* const result = await db.updateOne('discount_codes',
|
|
222
|
+
* { id, redeemedCount: { $lt: maxRedemptions } },
|
|
223
|
+
* { $inc: { redeemedCount: 1 } }
|
|
220
224
|
* );
|
|
221
|
-
*
|
|
225
|
+
* if (result.modified === 0) throw new Error('code exhausted');
|
|
222
226
|
* ```
|
|
223
227
|
*/
|
|
224
|
-
updateOne(
|
|
228
|
+
updateOne(
|
|
229
|
+
collection: string,
|
|
230
|
+
filter: any,
|
|
231
|
+
update: any,
|
|
232
|
+
): Promise<{ modified: number; matched: number; matchedCount: number; modifiedCount: number }>;
|
|
225
233
|
|
|
226
234
|
/**
|
|
227
235
|
* Delete a single document from a collection.
|
|
228
|
-
*
|
|
236
|
+
*
|
|
229
237
|
* @param collection - The collection name
|
|
230
238
|
* @param filter - Query filter to match the document to delete
|
|
231
|
-
* @returns Promise that resolves to
|
|
232
|
-
*
|
|
239
|
+
* @returns Promise that resolves to the real number of deleted documents
|
|
240
|
+
* (0 when the filter matched nothing)
|
|
241
|
+
*
|
|
233
242
|
* @example
|
|
234
243
|
* ```typescript
|
|
235
244
|
* const result = await db.deleteOne('posts', { _id: postId });
|
|
236
245
|
* console.log(`Deleted ${result.deleted} documents`);
|
|
237
246
|
* ```
|
|
238
247
|
*/
|
|
239
|
-
deleteOne(collection: string, filter: any): Promise<{ deleted: number }>;
|
|
248
|
+
deleteOne(collection: string, filter: any): Promise<{ deleted: number; deletedCount: number }>;
|
|
240
249
|
|
|
241
250
|
/**
|
|
242
251
|
* Delete multiple documents from a collection.
|
|
@@ -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
|
+
});
|