@lmb-it/kitsconcerto-native 0.1.284 → 0.1.286
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/apps/mobile/src/Core/AutoComplete/index.cjs +12 -4
- package/apps/mobile/src/Core/AutoComplete/index.cjs.map +1 -1
- package/apps/mobile/src/Core/AutoComplete/index.js +12 -4
- package/apps/mobile/src/Core/AutoComplete/index.js.map +1 -1
- package/apps/mobile/src/Core/MultiSelect/index.cjs +7 -4
- package/apps/mobile/src/Core/MultiSelect/index.cjs.map +1 -1
- package/apps/mobile/src/Core/MultiSelect/index.js +7 -4
- package/apps/mobile/src/Core/MultiSelect/index.js.map +1 -1
- package/apps/mobile/src/ui/accordion/index.js +2 -2
- package/components/ImageCropper/compression.cjs +63 -0
- package/components/ImageCropper/compression.cjs.map +1 -0
- package/components/ImageCropper/compression.js +58 -0
- package/components/ImageCropper/compression.js.map +1 -0
- package/components/ImageCropper/constants.cjs +35 -0
- package/components/ImageCropper/constants.cjs.map +1 -0
- package/components/ImageCropper/constants.js +23 -0
- package/components/ImageCropper/constants.js.map +1 -0
- package/components/ImageCropper/cropperState.cjs +119 -0
- package/components/ImageCropper/cropperState.cjs.map +1 -0
- package/components/ImageCropper/cropperState.js +114 -0
- package/components/ImageCropper/cropperState.js.map +1 -0
- package/components/ImageCropper/encode.cjs +45 -0
- package/components/ImageCropper/encode.cjs.map +1 -0
- package/components/ImageCropper/encode.js +42 -0
- package/components/ImageCropper/encode.js.map +1 -0
- package/components/ImageCropper/encoderRegistry.cjs +13 -0
- package/components/ImageCropper/encoderRegistry.cjs.map +1 -0
- package/components/ImageCropper/encoderRegistry.js +9 -0
- package/components/ImageCropper/encoderRegistry.js.map +1 -0
- package/components/ImageCropper/encoders/rnImageEncoder.cjs +54 -0
- package/components/ImageCropper/encoders/rnImageEncoder.cjs.map +1 -0
- package/components/ImageCropper/encoders/rnImageEncoder.js +51 -0
- package/components/ImageCropper/encoders/rnImageEncoder.js.map +1 -0
- package/components/ImageCropper/geometry.cjs +208 -0
- package/components/ImageCropper/geometry.cjs.map +1 -0
- package/components/ImageCropper/geometry.js +186 -0
- package/components/ImageCropper/geometry.js.map +1 -0
- package/components/ImageCropper/index.cjs +436 -0
- package/components/ImageCropper/index.cjs.map +1 -0
- package/components/ImageCropper/index.js +434 -0
- package/components/ImageCropper/index.js.map +1 -0
- package/components/ImageCropper/pinchZoom.cjs +8 -0
- package/components/ImageCropper/pinchZoom.cjs.map +1 -0
- package/components/ImageCropper/pinchZoom.js +5 -0
- package/components/ImageCropper/pinchZoom.js.map +1 -0
- package/components/ImageCropper/useImageSource.cjs +87 -0
- package/components/ImageCropper/useImageSource.cjs.map +1 -0
- package/components/ImageCropper/useImageSource.js +84 -0
- package/components/ImageCropper/useImageSource.js.map +1 -0
- package/form/helpers/FormContainer/index.cjs +12 -2
- package/form/helpers/FormContainer/index.cjs.map +1 -1
- package/form/helpers/FormContainer/index.js +12 -2
- package/form/helpers/FormContainer/index.js.map +1 -1
- package/form/selects/AutoComplete/index.cjs +10 -1
- package/form/selects/AutoComplete/index.cjs.map +1 -1
- package/form/selects/AutoComplete/index.js +11 -2
- package/form/selects/AutoComplete/index.js.map +1 -1
- package/form/selects/MultiSelect/index.cjs +8 -1
- package/form/selects/MultiSelect/index.cjs.map +1 -1
- package/form/selects/MultiSelect/index.js +9 -2
- package/form/selects/MultiSelect/index.js.map +1 -1
- package/hooks/useComponentDefaults.cjs +62 -8
- package/hooks/useComponentDefaults.cjs.map +1 -1
- package/hooks/useComponentDefaults.js +62 -8
- package/hooks/useComponentDefaults.js.map +1 -1
- package/index.cjs +120 -115
- package/index.cjs.map +1 -1
- package/index.d.ts +494 -16
- package/index.js +2 -0
- package/index.js.map +1 -1
- package/package.json +13 -1
- package/packages/types/src/Theme/defaultTheme.cjs +135 -4
- package/packages/types/src/Theme/defaultTheme.cjs.map +1 -1
- package/packages/types/src/Theme/defaultTheme.js +135 -4
- package/packages/types/src/Theme/defaultTheme.js.map +1 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { rotateCw, resizeRect, clamp, zoomAbout, orientedSize, minScaleToCover, centerRect, largestRectOfRatio, imageRect, clampRectInside, clampTransformToCover } from './geometry.js';
|
|
2
|
+
import { MIN_CROP_SIZE } from './constants.js';
|
|
3
|
+
|
|
4
|
+
const scaleRange = (state) => {
|
|
5
|
+
const oriented = orientedSize(state.config.source, state.rotation);
|
|
6
|
+
const min = minScaleToCover(oriented, state.crop);
|
|
7
|
+
return { min, max: min * state.config.maxZoom };
|
|
8
|
+
};
|
|
9
|
+
const zoomFraction = (state) => {
|
|
10
|
+
const { min, max } = scaleRange(state);
|
|
11
|
+
if (!Number.isFinite(min) || max <= min) return 0;
|
|
12
|
+
return clamp((state.transform.scale - min) / (max - min), 0, 1);
|
|
13
|
+
};
|
|
14
|
+
const settle = (state) => {
|
|
15
|
+
const oriented = orientedSize(state.config.source, state.rotation);
|
|
16
|
+
const stage = { x: 0, y: 0, ...state.config.viewport };
|
|
17
|
+
const crop = clampRectInside(state.crop, stage);
|
|
18
|
+
const min = minScaleToCover(oriented, crop);
|
|
19
|
+
const scale = clamp(state.transform.scale, min, min * state.config.maxZoom);
|
|
20
|
+
const scaled = zoomAbout(
|
|
21
|
+
state.transform,
|
|
22
|
+
{ x: crop.x + crop.width / 2, y: crop.y + crop.height / 2 },
|
|
23
|
+
scale
|
|
24
|
+
);
|
|
25
|
+
return { ...state, crop, transform: clampTransformToCover(scaled, oriented, crop) };
|
|
26
|
+
};
|
|
27
|
+
const reframe = (config, rotation) => {
|
|
28
|
+
const oriented = orientedSize(config.source, rotation);
|
|
29
|
+
const crop = centerRect(largestRectOfRatio(config.viewport, config.ratio), config.viewport);
|
|
30
|
+
const scale = minScaleToCover(oriented, crop);
|
|
31
|
+
const img = imageRect(oriented, { scale, x: 0, y: 0 });
|
|
32
|
+
return settle({
|
|
33
|
+
config,
|
|
34
|
+
rotation,
|
|
35
|
+
crop,
|
|
36
|
+
transform: {
|
|
37
|
+
scale,
|
|
38
|
+
x: crop.x + (crop.width - img.width) / 2,
|
|
39
|
+
y: crop.y + (crop.height - img.height) / 2
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
const initialCropperState = (config) => reframe(config, 0);
|
|
44
|
+
const cropperReducer = (state, action) => {
|
|
45
|
+
switch (action.type) {
|
|
46
|
+
case "configure": {
|
|
47
|
+
const config = { ...state.config, ...action.config };
|
|
48
|
+
const previous = state.config.viewport;
|
|
49
|
+
const next = config.viewport;
|
|
50
|
+
const framingChanged = config.ratio !== state.config.ratio || config.source.width !== state.config.source.width || config.source.height !== state.config.source.height;
|
|
51
|
+
if (framingChanged) return reframe(config, state.rotation);
|
|
52
|
+
const resized = next.width !== previous.width || next.height !== previous.height;
|
|
53
|
+
if (!resized) return settle({ ...state, config });
|
|
54
|
+
if (previous.width <= 1 || previous.height <= 1) return reframe(config, state.rotation);
|
|
55
|
+
const k = Math.min(next.width / previous.width, next.height / previous.height);
|
|
56
|
+
return settle({
|
|
57
|
+
...state,
|
|
58
|
+
config,
|
|
59
|
+
crop: {
|
|
60
|
+
x: state.crop.x * k,
|
|
61
|
+
y: state.crop.y * k,
|
|
62
|
+
width: state.crop.width * k,
|
|
63
|
+
height: state.crop.height * k
|
|
64
|
+
},
|
|
65
|
+
transform: {
|
|
66
|
+
scale: state.transform.scale * k,
|
|
67
|
+
x: state.transform.x * k,
|
|
68
|
+
y: state.transform.y * k
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
case "pan":
|
|
73
|
+
return settle({
|
|
74
|
+
...state,
|
|
75
|
+
transform: {
|
|
76
|
+
...state.transform,
|
|
77
|
+
x: state.transform.x + action.dx,
|
|
78
|
+
y: state.transform.y + action.dy
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
case "zoomAt": {
|
|
82
|
+
const { min, max } = scaleRange(state);
|
|
83
|
+
const scale = clamp(action.scale, min, max);
|
|
84
|
+
return settle({ ...state, transform: zoomAbout(state.transform, action.anchor, scale) });
|
|
85
|
+
}
|
|
86
|
+
case "setZoom": {
|
|
87
|
+
const { min, max } = scaleRange(state);
|
|
88
|
+
const scale = min + clamp(action.value, 0, 1) * (max - min);
|
|
89
|
+
const anchor = {
|
|
90
|
+
x: state.crop.x + state.crop.width / 2,
|
|
91
|
+
y: state.crop.y + state.crop.height / 2
|
|
92
|
+
};
|
|
93
|
+
return settle({ ...state, transform: zoomAbout(state.transform, anchor, scale) });
|
|
94
|
+
}
|
|
95
|
+
case "resize": {
|
|
96
|
+
if (!state.config.resizable) return state;
|
|
97
|
+
const crop = resizeRect(state.crop, action.handle, action.pointer, {
|
|
98
|
+
ratio: state.config.ratio,
|
|
99
|
+
min: MIN_CROP_SIZE,
|
|
100
|
+
bounds: { x: 0, y: 0, ...state.config.viewport }
|
|
101
|
+
});
|
|
102
|
+
return settle({ ...state, crop });
|
|
103
|
+
}
|
|
104
|
+
case "rotate":
|
|
105
|
+
return reframe(state.config, rotateCw(state.rotation));
|
|
106
|
+
case "reset":
|
|
107
|
+
return reframe(state.config, 0);
|
|
108
|
+
default:
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export { cropperReducer, initialCropperState, scaleRange, zoomFraction };
|
|
114
|
+
//# sourceMappingURL=cropperState.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cropperState.js","sources":["../../../../src/components/ImageCropper/cropperState.ts"],"sourcesContent":["import type {\n CropHandle,\n CropRotation,\n ICropPoint,\n ICropRect,\n ICropSize,\n ICropTransform,\n} from \"@lmb-it/kitsconcerto-types\";\nimport {\n clamp,\n clampRectInside,\n clampTransformToCover,\n imageRect,\n largestRectOfRatio,\n centerRect,\n minScaleToCover,\n orientedSize,\n resizeRect,\n rotateCw,\n zoomAbout,\n} from \"./geometry\";\nimport {HANDLE_HIT_SIZE, MIN_CROP_SIZE} from \"./constants\";\n\n/**\n * The cropper's behaviour, as a pure reducer.\n *\n * ⛔ A REDUCER AND NOT A HOOK, BECAUSE THIS REPO'S VITEST HAS NO DOM. The\n * config is `environment: 'node'` and jsdom is deliberately not installed —\n * its own comment says the first test that renders a component is what earns\n * it. A `useCropperState` hook would therefore have been shipped untested,\n * which for the part that decides where a crop lands is not a trade worth\n * making. As a reducer every transition is an ordinary function call, and the\n * platform files are left holding nothing but `useReducer` and the events.\n *\n * ⚠️ TWO INVARIANTS HOLD AFTER EVERY ACTION, not just after the ones that\n * obviously threaten them:\n * 1. the image covers the crop window — no empty corners, ever;\n * 2. the scale is inside [cover, cover × maxZoom] — you can never zoom out\n * past the point where the window is filled.\n * They are re-applied in one place, `settle`, rather than at each call site,\n * because \"I added an action and forgot the clamp\" is the shape this bug takes.\n */\n\nexport interface ICropperConfig {\n /** The image's natural size, in source pixels. */\n source: ICropSize;\n /** The stage, in viewport units. */\n viewport: ICropSize;\n /** Locked crop ratio, width ÷ height. */\n ratio: number;\n /** Zoom ceiling, as a multiple of the cover scale. */\n maxZoom: number;\n /** Whether the eight grips may resize the window. */\n resizable: boolean;\n}\n\nexport interface ICropperState {\n config: ICropperConfig;\n crop: ICropRect;\n transform: ICropTransform;\n rotation: CropRotation;\n}\n\nexport type CropperAction =\n /** The stage was measured, or re-measured on resize. */\n | {type: \"configure\"; config: Partial<ICropperConfig>}\n /** Drag the image behind a fixed window. */\n | {type: \"pan\"; dx: number; dy: number}\n /** Wheel or pinch, anchored so the pixel under the pointer stays there. */\n | {type: \"zoomAt\"; anchor: ICropPoint; scale: number}\n /** The zoom slider, in 0–1 over the legal band. */\n | {type: \"setZoom\"; value: number}\n /** Drag one of the eight grips. */\n | {type: \"resize\"; handle: CropHandle; pointer: ICropPoint}\n /** The rotate button: one quarter turn clockwise. */\n | {type: \"rotate\"}\n /** Back to how it opened. */\n | {type: \"reset\"};\n\n/* ─── Derived values ──────────────────────────────────────────────────────── */\n\n/** The scale band: cover at the bottom, `maxZoom` times that at the top. */\nexport const scaleRange = (state: ICropperState): {min: number; max: number} => {\n const oriented = orientedSize(state.config.source, state.rotation);\n const min = minScaleToCover(oriented, state.crop);\n return {min, max: min * state.config.maxZoom};\n};\n\n/**\n * Where the slider sits, 0–1 over the legal band.\n *\n * ⚠️ LINEAR IN SCALE, NOT LOGARITHMIC. A log mapping feels better over a wide\n * band and is what a photo editor would use; it is also a different control to\n * the one the pinch gesture drives, and two controls that disagree about what\n * \"half way\" means is worse than one that is slightly bottom-heavy. Change both\n * or neither.\n */\nexport const zoomFraction = (state: ICropperState): number => {\n const {min, max} = scaleRange(state);\n if (!Number.isFinite(min) || max <= min) return 0;\n return clamp((state.transform.scale - min) / (max - min), 0, 1);\n};\n\n/** The grips a renderer should draw — empty when resizing is off. */\nexport const activeHandles = (state: ICropperState): CropHandle[] =>\n state.config.resizable ? [\"nw\", \"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\"] : [];\n\n/** Hit size of a grip, so web and native agree on how fat a target is. */\nexport const handleHitSize = (): number => HANDLE_HIT_SIZE;\n\n/* ─── The two invariants, applied in one place ────────────────────────────── */\n\nconst settle = (state: ICropperState): ICropperState => {\n const oriented = orientedSize(state.config.source, state.rotation);\n\n // The window itself may never leave the stage.\n const stage: ICropRect = {x: 0, y: 0, ...state.config.viewport};\n const crop = clampRectInside(state.crop, stage);\n\n // Scale into the legal band…\n const min = minScaleToCover(oriented, crop);\n const scale = clamp(state.transform.scale, min, min * state.config.maxZoom);\n\n // …then slide the image until the window is filled. ⚠️ ORDER MATTERS: a\n // cover clamp computed against a scale that is about to change puts the\n // image where it needed to be a moment ago.\n const scaled = zoomAbout(\n state.transform,\n {x: crop.x + crop.width / 2, y: crop.y + crop.height / 2},\n scale,\n );\n return {...state, crop, transform: clampTransformToCover(scaled, oriented, crop)};\n};\n\n/* ─── Start ───────────────────────────────────────────────────────────────── */\n\n/**\n * The natural framing for a config and a rotation: the biggest window of the\n * ratio the stage allows, with the image just covering it, centred.\n *\n * ⚠️ ONE FUNCTION FOR OPEN, RESET AND ROTATE, because all three mean the same\n * thing — \"frame this from scratch\" — and three copies of it is three chances\n * for one of them to forget `settle`.\n */\nconst reframe = (config: ICropperConfig, rotation: CropRotation): ICropperState => {\n const oriented = orientedSize(config.source, rotation);\n const crop = centerRect(largestRectOfRatio(config.viewport, config.ratio), config.viewport);\n const scale = minScaleToCover(oriented, crop);\n const img = imageRect(oriented, {scale, x: 0, y: 0});\n return settle({\n config,\n rotation,\n crop,\n transform: {\n scale,\n x: crop.x + (crop.width - img.width) / 2,\n y: crop.y + (crop.height - img.height) / 2,\n },\n });\n};\n\nexport const initialCropperState = (config: ICropperConfig): ICropperState =>\n reframe(config, 0);\n\n/* ─── Transitions ─────────────────────────────────────────────────────────── */\n\nexport const cropperReducer = (state: ICropperState, action: CropperAction): ICropperState => {\n switch (action.type) {\n case \"configure\": {\n const config = {...state.config, ...action.config};\n const previous = state.config.viewport;\n const next = config.viewport;\n\n // ⛔ A NEW IMAGE OR A NEW RATIO IS GENUINELY A NEW FRAMING. Keeping\n // the old window across a ratio change hands back a window of the\n // wrong shape; across a new image it frames pixels that are not\n // there any more. Rotation is the user's choice about the PHOTO, so\n // it survives.\n const framingChanged =\n config.ratio !== state.config.ratio ||\n config.source.width !== state.config.source.width ||\n config.source.height !== state.config.source.height;\n if (framingChanged) return reframe(config, state.rotation);\n\n const resized = next.width !== previous.width || next.height !== previous.height;\n if (!resized) return settle({...state, config});\n\n // ⛔ A RE-MEASURED STAGE MUST NOT THROW THE USER'S WORK AWAY, AND\n // FOR ONE BUILD IT DID. Saving a crop made the result preview\n // appear below the component, the page grew a scrollbar, the stage\n // lost ~15px of width, the ResizeObserver fired — and the cropper\n // silently snapped back to 1.0× and unrotated, in the same frame\n // the user pressed Save. Nothing about that is a new framing: the\n // same photo is being cropped the same way on a slightly narrower\n // stage. Scaling the whole framing by the stage's own factor keeps\n // it, and `settle` fixes up whatever no longer fits.\n //\n // ⚠️ THE FIRST MEASURE IS NOT A RESIZE. The stage starts at the 1×1\n // placeholder the component uses before the ResizeObserver has\n // reported, and scaling by a factor of several hundred from that is\n // not preserving anything.\n if (previous.width <= 1 || previous.height <= 1) return reframe(config, state.rotation);\n\n const k = Math.min(next.width / previous.width, next.height / previous.height);\n return settle({\n ...state,\n config,\n crop: {\n x: state.crop.x * k,\n y: state.crop.y * k,\n width: state.crop.width * k,\n height: state.crop.height * k,\n },\n transform: {\n scale: state.transform.scale * k,\n x: state.transform.x * k,\n y: state.transform.y * k,\n },\n });\n }\n\n case \"pan\":\n return settle({\n ...state,\n transform: {\n ...state.transform,\n x: state.transform.x + action.dx,\n y: state.transform.y + action.dy,\n },\n });\n\n case \"zoomAt\": {\n const {min, max} = scaleRange(state);\n const scale = clamp(action.scale, min, max);\n return settle({...state, transform: zoomAbout(state.transform, action.anchor, scale)});\n }\n\n case \"setZoom\": {\n const {min, max} = scaleRange(state);\n const scale = min + clamp(action.value, 0, 1) * (max - min);\n // The slider has no pointer to anchor on, so it zooms about the\n // window's centre — the one point the user is certainly looking at.\n const anchor = {\n x: state.crop.x + state.crop.width / 2,\n y: state.crop.y + state.crop.height / 2,\n };\n return settle({...state, transform: zoomAbout(state.transform, anchor, scale)});\n }\n\n case \"resize\": {\n if (!state.config.resizable) return state;\n const crop = resizeRect(state.crop, action.handle, action.pointer, {\n ratio: state.config.ratio,\n min: MIN_CROP_SIZE,\n bounds: {x: 0, y: 0, ...state.config.viewport},\n });\n // ⚠️ SETTLE AFTER A RESIZE TOO. A bigger window can need a bigger\n // scale to stay covered, and the grip is exactly the control that\n // makes the window bigger.\n return settle({...state, crop});\n }\n\n case \"rotate\":\n // ⛔ THE WINDOW IS REBUILT, NOT KEPT. A quarter turn transposes the\n // image, so a scale that covered the old window may not cover the\n // new one at all, and the old offset points somewhere else entirely.\n // Reopening at the natural framing for the new orientation is what\n // every other cropper does, and it is the only state guaranteed to\n // satisfy both invariants.\n return reframe(state.config, rotateCw(state.rotation));\n\n case \"reset\":\n return reframe(state.config, 0);\n\n default:\n return state;\n }\n};\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var compression = require('./compression.cjs');
|
|
4
|
+
var encoderRegistry = require('./encoderRegistry.cjs');
|
|
5
|
+
|
|
6
|
+
const encodeCropNative = async (uri, sourceRect, sourceSize, rotation, max, format) => {
|
|
7
|
+
const encoder = encoderRegistry.getCropEncoder();
|
|
8
|
+
if (!encoder) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
"[ImageCropper] No native encoder is registered, so the crop cannot be saved. Call setCropEncoder(\u2026) once at app start \u2014 see encode.native.ts for the one-function contract. (expo-image-manipulator, the library the plan named, needs the Expo modules runtime, which this bare React Native app does not have.)"
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
const quarter = rotation === 90 || rotation === 270;
|
|
14
|
+
const natural = quarter ? { width: sourceRect.height, height: sourceRect.width } : { width: sourceRect.width, height: sourceRect.height };
|
|
15
|
+
const run = await compression.runEncodePlan(natural, max, format, async (attempt) => {
|
|
16
|
+
const out = await encoder({
|
|
17
|
+
uri,
|
|
18
|
+
sourceRect,
|
|
19
|
+
sourceSize,
|
|
20
|
+
rotation,
|
|
21
|
+
size: attempt.size,
|
|
22
|
+
quality: attempt.quality,
|
|
23
|
+
format
|
|
24
|
+
});
|
|
25
|
+
return { bytes: out.bytes, value: { uri: out.uri, width: out.width, height: out.height } };
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
uri: run.value.uri,
|
|
29
|
+
// ⚠️ WHAT THE ENCODER ACTUALLY MADE, falling back to what the search
|
|
30
|
+
// asked for. The bare-React-Native encoder drives its resizer with a
|
|
31
|
+
// square `contain` ceiling — the only orientation-safe way to use it —
|
|
32
|
+
// so the result can be a pixel or two off the request, and a consumer
|
|
33
|
+
// sizing a layout from this number should get the true one.
|
|
34
|
+
width: Math.max(1, Math.round(run.value.width ?? run.size.width)),
|
|
35
|
+
height: Math.max(1, Math.round(run.value.height ?? run.size.height)),
|
|
36
|
+
size: run.bytes,
|
|
37
|
+
format,
|
|
38
|
+
withinBudget: run.withinBudget
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
exports.hasCropEncoder = encoderRegistry.hasCropEncoder;
|
|
43
|
+
exports.setCropEncoder = encoderRegistry.setCropEncoder;
|
|
44
|
+
exports.encodeCropNative = encodeCropNative;
|
|
45
|
+
//# sourceMappingURL=encode.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encode.cjs","sources":["../../../../src/components/ImageCropper/encode.native.ts"],"sourcesContent":["import type {CropOutputFormat, ICropRect, ICropResult, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {runEncodePlan} from \"./compression\";\nimport {getCropEncoder} from \"./encoderRegistry\";\n\nexport {setCropEncoder, hasCropEncoder, type ICropEncoder} from \"./encoderRegistry\";\n\n/**\n * The one thing the native cropper cannot do by itself.\n *\n * ⛔ THERE IS NO CANVAS ON A DEVICE. Every other part of this component —\n * geometry, state, gestures, the compression SEARCH — runs identically on both\n * platforms out of the shared modules. Turning the chosen rect into an encoded\n * file is the exception: it needs a native module, and this repository does not\n * currently ship one.\n *\n * ⚠️ WHAT IS ACTUALLY MISSING, MEASURED RATHER THAN ASSUMED. The approved plan\n * named `expo-image-manipulator`. It is not installed, and `apps/mobile` is a\n * BARE React Native app — there is no `expo`, no `expo-modules-core`, and\n * `expo-image-manipulator` cannot run without them. Adding it means running\n * `install-expo-modules`, which edits the iOS and Android projects, the Podfile\n * and the app delegate, and needs a native rebuild. That is a decision about\n * the app's native setup, not a line of component code, so this file does not\n * make it quietly.\n *\n * ── HOW TO FINISH IT, WHICHEVER LIBRARY IS CHOSEN ──\n * Call `setCropEncoder` once at app start with an implementation of\n * {@link ICropEncoder}. One function, four inputs, one output; the search that\n * decides WHICH size and quality to ask for stays in `compression.ts` and is\n * already tested. For expo-image-manipulator that body is a `manipulateAsync`\n * with a `crop` action, a `rotate` action, a `resize` action and a\n * `SaveOptions` — roughly fifteen lines.\n */\n\n\n/**\n * Crops, rotates, resizes and encodes — the whole output path on native.\n *\n * ⚠️ THE SEARCH IS NOT HERE, exactly as on web. Which (size, quality) to try,\n * in what order, and when to stop squeezing and start shrinking all live in\n * `compression.ts`. This drives the registered encoder with that plan, so both\n * platforms reach a byte ceiling by the same route.\n */\nexport const encodeCropNative = async (\n uri: string,\n sourceRect: ICropRect,\n sourceSize: ICropSize,\n rotation: number,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Promise<ICropResult & {withinBudget: boolean}> => {\n const encoder = getCropEncoder();\n if (!encoder) {\n throw new Error(\n \"[ImageCropper] No native encoder is registered, so the crop cannot be saved. \" +\n \"Call setCropEncoder(…) once at app start — see encode.native.ts for the \" +\n \"one-function contract. (expo-image-manipulator, the library the plan named, \" +\n \"needs the Expo modules runtime, which this bare React Native app does not have.)\",\n );\n }\n\n const quarter = rotation === 90 || rotation === 270;\n // The crop at 1:1, in the orientation it will be SAVED in.\n const natural: ICropSize = quarter\n ? {width: sourceRect.height, height: sourceRect.width}\n : {width: sourceRect.width, height: sourceRect.height};\n\n const run = await runEncodePlan(natural, max, format, async (attempt) => {\n const out = await encoder({\n uri,\n sourceRect,\n sourceSize,\n rotation,\n size: attempt.size,\n quality: attempt.quality,\n format,\n });\n return {bytes: out.bytes, value: {uri: out.uri, width: out.width, height: out.height}};\n });\n\n return {\n uri: run.value.uri,\n // ⚠️ WHAT THE ENCODER ACTUALLY MADE, falling back to what the search\n // asked for. The bare-React-Native encoder drives its resizer with a\n // square `contain` ceiling — the only orientation-safe way to use it —\n // so the result can be a pixel or two off the request, and a consumer\n // sizing a layout from this number should get the true one.\n width: Math.max(1, Math.round(run.value.width ?? run.size.width)),\n height: Math.max(1, Math.round(run.value.height ?? run.size.height)),\n size: run.bytes,\n format,\n withinBudget: run.withinBudget,\n };\n};\n"],"names":["getCropEncoder","runEncodePlan"],"mappings":";;;;;AA0CO,MAAM,mBAAmB,OAC5B,GAAA,EACA,YACA,UAAA,EACA,QAAA,EACA,KACA,MAAA,KACiD;AACjD,EAAA,MAAM,UAAUA,8BAAA,EAAe;AAC/B,EAAA,IAAI,CAAC,OAAA,EAAS;AACV,IAAA,MAAM,IAAI,KAAA;AAAA,MACN;AAAA,KAIJ;AAAA,EACJ;AAEA,EAAA,MAAM,OAAA,GAAU,QAAA,KAAa,EAAA,IAAM,QAAA,KAAa,GAAA;AAEhD,EAAA,MAAM,UAAqB,OAAA,GACrB,EAAC,KAAA,EAAO,UAAA,CAAW,QAAQ,MAAA,EAAQ,UAAA,CAAW,KAAA,EAAK,GACnD,EAAC,KAAA,EAAO,UAAA,CAAW,KAAA,EAAO,MAAA,EAAQ,WAAW,MAAA,EAAM;AAEzD,EAAA,MAAM,MAAM,MAAMC,yBAAA,CAAc,SAAS,GAAA,EAAK,MAAA,EAAQ,OAAO,OAAA,KAAY;AACrE,IAAA,MAAM,GAAA,GAAM,MAAM,OAAA,CAAQ;AAAA,MACtB,GAAA;AAAA,MACA,UAAA;AAAA,MACA,UAAA;AAAA,MACA,QAAA;AAAA,MACA,MAAM,OAAA,CAAQ,IAAA;AAAA,MACd,SAAS,OAAA,CAAQ,OAAA;AAAA,MACjB;AAAA,KACH,CAAA;AACD,IAAA,OAAO,EAAC,KAAA,EAAO,GAAA,CAAI,KAAA,EAAO,OAAO,EAAC,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,OAAO,GAAA,CAAI,KAAA,EAAO,MAAA,EAAQ,GAAA,CAAI,QAAM,EAAC;AAAA,EACzF,CAAC,CAAA;AAED,EAAA,OAAO;AAAA,IACH,GAAA,EAAK,IAAI,KAAA,CAAM,GAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMf,KAAA,EAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,KAAA,CAAM,KAAA,IAAS,GAAA,CAAI,IAAA,CAAK,KAAK,CAAC,CAAA;AAAA,IAChE,MAAA,EAAQ,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,KAAA,CAAM,MAAA,IAAU,GAAA,CAAI,IAAA,CAAK,MAAM,CAAC,CAAA;AAAA,IACnE,MAAM,GAAA,CAAI,KAAA;AAAA,IACV,MAAA;AAAA,IACA,cAAc,GAAA,CAAI;AAAA,GACtB;AACJ;;;;;;"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { runEncodePlan } from './compression.js';
|
|
2
|
+
import { getCropEncoder } from './encoderRegistry.js';
|
|
3
|
+
export { hasCropEncoder, setCropEncoder } from './encoderRegistry.js';
|
|
4
|
+
|
|
5
|
+
const encodeCropNative = async (uri, sourceRect, sourceSize, rotation, max, format) => {
|
|
6
|
+
const encoder = getCropEncoder();
|
|
7
|
+
if (!encoder) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
"[ImageCropper] No native encoder is registered, so the crop cannot be saved. Call setCropEncoder(\u2026) once at app start \u2014 see encode.native.ts for the one-function contract. (expo-image-manipulator, the library the plan named, needs the Expo modules runtime, which this bare React Native app does not have.)"
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
const quarter = rotation === 90 || rotation === 270;
|
|
13
|
+
const natural = quarter ? { width: sourceRect.height, height: sourceRect.width } : { width: sourceRect.width, height: sourceRect.height };
|
|
14
|
+
const run = await runEncodePlan(natural, max, format, async (attempt) => {
|
|
15
|
+
const out = await encoder({
|
|
16
|
+
uri,
|
|
17
|
+
sourceRect,
|
|
18
|
+
sourceSize,
|
|
19
|
+
rotation,
|
|
20
|
+
size: attempt.size,
|
|
21
|
+
quality: attempt.quality,
|
|
22
|
+
format
|
|
23
|
+
});
|
|
24
|
+
return { bytes: out.bytes, value: { uri: out.uri, width: out.width, height: out.height } };
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
uri: run.value.uri,
|
|
28
|
+
// ⚠️ WHAT THE ENCODER ACTUALLY MADE, falling back to what the search
|
|
29
|
+
// asked for. The bare-React-Native encoder drives its resizer with a
|
|
30
|
+
// square `contain` ceiling — the only orientation-safe way to use it —
|
|
31
|
+
// so the result can be a pixel or two off the request, and a consumer
|
|
32
|
+
// sizing a layout from this number should get the true one.
|
|
33
|
+
width: Math.max(1, Math.round(run.value.width ?? run.size.width)),
|
|
34
|
+
height: Math.max(1, Math.round(run.value.height ?? run.size.height)),
|
|
35
|
+
size: run.bytes,
|
|
36
|
+
format,
|
|
37
|
+
withinBudget: run.withinBudget
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export { encodeCropNative };
|
|
42
|
+
//# sourceMappingURL=encode.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encode.js","sources":["../../../../src/components/ImageCropper/encode.native.ts"],"sourcesContent":["import type {CropOutputFormat, ICropRect, ICropResult, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {runEncodePlan} from \"./compression\";\nimport {getCropEncoder} from \"./encoderRegistry\";\n\nexport {setCropEncoder, hasCropEncoder, type ICropEncoder} from \"./encoderRegistry\";\n\n/**\n * The one thing the native cropper cannot do by itself.\n *\n * ⛔ THERE IS NO CANVAS ON A DEVICE. Every other part of this component —\n * geometry, state, gestures, the compression SEARCH — runs identically on both\n * platforms out of the shared modules. Turning the chosen rect into an encoded\n * file is the exception: it needs a native module, and this repository does not\n * currently ship one.\n *\n * ⚠️ WHAT IS ACTUALLY MISSING, MEASURED RATHER THAN ASSUMED. The approved plan\n * named `expo-image-manipulator`. It is not installed, and `apps/mobile` is a\n * BARE React Native app — there is no `expo`, no `expo-modules-core`, and\n * `expo-image-manipulator` cannot run without them. Adding it means running\n * `install-expo-modules`, which edits the iOS and Android projects, the Podfile\n * and the app delegate, and needs a native rebuild. That is a decision about\n * the app's native setup, not a line of component code, so this file does not\n * make it quietly.\n *\n * ── HOW TO FINISH IT, WHICHEVER LIBRARY IS CHOSEN ──\n * Call `setCropEncoder` once at app start with an implementation of\n * {@link ICropEncoder}. One function, four inputs, one output; the search that\n * decides WHICH size and quality to ask for stays in `compression.ts` and is\n * already tested. For expo-image-manipulator that body is a `manipulateAsync`\n * with a `crop` action, a `rotate` action, a `resize` action and a\n * `SaveOptions` — roughly fifteen lines.\n */\n\n\n/**\n * Crops, rotates, resizes and encodes — the whole output path on native.\n *\n * ⚠️ THE SEARCH IS NOT HERE, exactly as on web. Which (size, quality) to try,\n * in what order, and when to stop squeezing and start shrinking all live in\n * `compression.ts`. This drives the registered encoder with that plan, so both\n * platforms reach a byte ceiling by the same route.\n */\nexport const encodeCropNative = async (\n uri: string,\n sourceRect: ICropRect,\n sourceSize: ICropSize,\n rotation: number,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Promise<ICropResult & {withinBudget: boolean}> => {\n const encoder = getCropEncoder();\n if (!encoder) {\n throw new Error(\n \"[ImageCropper] No native encoder is registered, so the crop cannot be saved. \" +\n \"Call setCropEncoder(…) once at app start — see encode.native.ts for the \" +\n \"one-function contract. (expo-image-manipulator, the library the plan named, \" +\n \"needs the Expo modules runtime, which this bare React Native app does not have.)\",\n );\n }\n\n const quarter = rotation === 90 || rotation === 270;\n // The crop at 1:1, in the orientation it will be SAVED in.\n const natural: ICropSize = quarter\n ? {width: sourceRect.height, height: sourceRect.width}\n : {width: sourceRect.width, height: sourceRect.height};\n\n const run = await runEncodePlan(natural, max, format, async (attempt) => {\n const out = await encoder({\n uri,\n sourceRect,\n sourceSize,\n rotation,\n size: attempt.size,\n quality: attempt.quality,\n format,\n });\n return {bytes: out.bytes, value: {uri: out.uri, width: out.width, height: out.height}};\n });\n\n return {\n uri: run.value.uri,\n // ⚠️ WHAT THE ENCODER ACTUALLY MADE, falling back to what the search\n // asked for. The bare-React-Native encoder drives its resizer with a\n // square `contain` ceiling — the only orientation-safe way to use it —\n // so the result can be a pixel or two off the request, and a consumer\n // sizing a layout from this number should get the true one.\n width: Math.max(1, Math.round(run.value.width ?? run.size.width)),\n height: Math.max(1, Math.round(run.value.height ?? run.size.height)),\n size: run.bytes,\n format,\n withinBudget: run.withinBudget,\n };\n};\n"],"names":[],"mappings":";;;;AA0CO,MAAM,mBAAmB,OAC5B,GAAA,EACA,YACA,UAAA,EACA,QAAA,EACA,KACA,MAAA,KACiD;AACjD,EAAA,MAAM,UAAU,cAAA,EAAe;AAC/B,EAAA,IAAI,CAAC,OAAA,EAAS;AACV,IAAA,MAAM,IAAI,KAAA;AAAA,MACN;AAAA,KAIJ;AAAA,EACJ;AAEA,EAAA,MAAM,OAAA,GAAU,QAAA,KAAa,EAAA,IAAM,QAAA,KAAa,GAAA;AAEhD,EAAA,MAAM,UAAqB,OAAA,GACrB,EAAC,KAAA,EAAO,UAAA,CAAW,QAAQ,MAAA,EAAQ,UAAA,CAAW,KAAA,EAAK,GACnD,EAAC,KAAA,EAAO,UAAA,CAAW,KAAA,EAAO,MAAA,EAAQ,WAAW,MAAA,EAAM;AAEzD,EAAA,MAAM,MAAM,MAAM,aAAA,CAAc,SAAS,GAAA,EAAK,MAAA,EAAQ,OAAO,OAAA,KAAY;AACrE,IAAA,MAAM,GAAA,GAAM,MAAM,OAAA,CAAQ;AAAA,MACtB,GAAA;AAAA,MACA,UAAA;AAAA,MACA,UAAA;AAAA,MACA,QAAA;AAAA,MACA,MAAM,OAAA,CAAQ,IAAA;AAAA,MACd,SAAS,OAAA,CAAQ,OAAA;AAAA,MACjB;AAAA,KACH,CAAA;AACD,IAAA,OAAO,EAAC,KAAA,EAAO,GAAA,CAAI,KAAA,EAAO,OAAO,EAAC,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,OAAO,GAAA,CAAI,KAAA,EAAO,MAAA,EAAQ,GAAA,CAAI,QAAM,EAAC;AAAA,EACzF,CAAC,CAAA;AAED,EAAA,OAAO;AAAA,IACH,GAAA,EAAK,IAAI,KAAA,CAAM,GAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAMf,KAAA,EAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,KAAA,CAAM,KAAA,IAAS,GAAA,CAAI,IAAA,CAAK,KAAK,CAAC,CAAA;AAAA,IAChE,MAAA,EAAQ,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,GAAA,CAAI,KAAA,CAAM,MAAA,IAAU,GAAA,CAAI,IAAA,CAAK,MAAM,CAAC,CAAA;AAAA,IACnE,MAAM,GAAA,CAAI,KAAA;AAAA,IACV,MAAA;AAAA,IACA,cAAc,GAAA,CAAI;AAAA,GACtB;AACJ;;;;"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let encoder = null;
|
|
4
|
+
const setCropEncoder = (next) => {
|
|
5
|
+
encoder = next;
|
|
6
|
+
};
|
|
7
|
+
const getCropEncoder = () => encoder;
|
|
8
|
+
const hasCropEncoder = () => encoder !== null;
|
|
9
|
+
|
|
10
|
+
exports.getCropEncoder = getCropEncoder;
|
|
11
|
+
exports.hasCropEncoder = hasCropEncoder;
|
|
12
|
+
exports.setCropEncoder = setCropEncoder;
|
|
13
|
+
//# sourceMappingURL=encoderRegistry.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encoderRegistry.cjs","sources":["../../../../src/components/ImageCropper/encoderRegistry.ts"],"sourcesContent":["import type {CropOutputFormat, ICropRect, ICropSize} from \"@lmb-it/kitsconcerto-types\";\n\n/**\n * Where a host app plugs in the one thing the NATIVE cropper cannot do itself.\n *\n * ⛔ THIS FILE HAS NO `.native` SUFFIX ON PURPOSE, AND THAT IS NOT COSMETIC.\n * `rollup.web.config.mjs` treats any path containing `.native.` as EXTERNAL —\n * so a registry living in `encode.native.ts` and re-exported from the shared\n * `components` barrel would compile, and then emit a bare import of a native\n * file into the WEB bundle for a consumer's bundler to fail on. Found by\n * reading that config rather than by shipping it. The registry itself imports\n * nothing platform-specific, so a neutral name is also the honest one.\n *\n * ⚠️ THE WEB CROPPER NEVER CONSULTS THIS. It has a canvas and needs no\n * injection. A cross-platform app can therefore call `setCropEncoder` once at\n * start-up unconditionally: on web it is a harmless no-op, on a device it is\n * what makes Save work.\n */\nexport interface ICropEncoder {\n /**\n * Produce one encoded file.\n *\n * @param uri the source image\n * @param sourceRect the region to keep, in SOURCE pixels, rotation undone\n * @param sourceSize the source's full pixel size\n * @param rotation degrees clockwise to apply when saving\n * @param size the output's pixel size, already decided by the search\n * @param quality 0–1; ignored by lossless formats\n * @param format the encoder to use\n * @returns the written file's uri, its size in bytes, and — when the\n * implementation knows them — the dimensions it actually produced\n *\n * ⚠️ `width`/`height` ARE REPORTED BECAUSE THE REQUEST IS NOT ALWAYS THE\n * RESULT. The bare-React-Native encoder asks its resizer for a square\n * ceiling with `contain` (the only orientation-safe way to drive it), so\n * the output can land a pixel or two off the requested size. Reporting the\n * number back beats the caller assuming the one it asked for.\n */\n (args: {\n uri: string;\n sourceRect: ICropRect;\n /**\n * The source's full size.\n *\n * ⚠️ THE BUNDLED ENCODER NO LONGER NEEDS THIS, and the field stays\n * anyway. It was added when that encoder rotated the file BEFORE\n * cropping it and therefore had to re-express the rect into the rotated\n * frame. Reordering the chain to crop first removed that mapping — but\n * the contract is public, any implementation that rotates first still\n * needs the dimensions, and they are free to carry. A canvas encoder\n * ignores the field entirely.\n */\n sourceSize: ICropSize;\n rotation: number;\n size: ICropSize;\n quality: number;\n format: CropOutputFormat;\n }): Promise<{uri: string; bytes: number; width?: number; height?: number}>;\n}\n\nlet encoder: ICropEncoder | null = null;\n\n/**\n * Register the native encoder. Call once, at app start, before the cropper is\n * used. Passing `null` unregisters it, which is what a test wants.\n */\nexport const setCropEncoder = (next: ICropEncoder | null): void => {\n encoder = next;\n};\n\n/** The registered encoder, or null. */\nexport const getCropEncoder = (): ICropEncoder | null => encoder;\n\n/** Whether a host app has wired one up. The native UI reads this to disable Save. */\nexport const hasCropEncoder = (): boolean => encoder !== null;\n"],"names":[],"mappings":";;AA4DA,IAAI,OAAA,GAA+B,IAAA;AAM5B,MAAM,cAAA,GAAiB,CAAC,IAAA,KAAoC;AAC/D,EAAA,OAAA,GAAU,IAAA;AACd;AAGO,MAAM,iBAAiB,MAA2B;AAGlD,MAAM,cAAA,GAAiB,MAAe,OAAA,KAAY;;;;;;"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
let encoder = null;
|
|
2
|
+
const setCropEncoder = (next) => {
|
|
3
|
+
encoder = next;
|
|
4
|
+
};
|
|
5
|
+
const getCropEncoder = () => encoder;
|
|
6
|
+
const hasCropEncoder = () => encoder !== null;
|
|
7
|
+
|
|
8
|
+
export { getCropEncoder, hasCropEncoder, setCropEncoder };
|
|
9
|
+
//# sourceMappingURL=encoderRegistry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encoderRegistry.js","sources":["../../../../src/components/ImageCropper/encoderRegistry.ts"],"sourcesContent":["import type {CropOutputFormat, ICropRect, ICropSize} from \"@lmb-it/kitsconcerto-types\";\n\n/**\n * Where a host app plugs in the one thing the NATIVE cropper cannot do itself.\n *\n * ⛔ THIS FILE HAS NO `.native` SUFFIX ON PURPOSE, AND THAT IS NOT COSMETIC.\n * `rollup.web.config.mjs` treats any path containing `.native.` as EXTERNAL —\n * so a registry living in `encode.native.ts` and re-exported from the shared\n * `components` barrel would compile, and then emit a bare import of a native\n * file into the WEB bundle for a consumer's bundler to fail on. Found by\n * reading that config rather than by shipping it. The registry itself imports\n * nothing platform-specific, so a neutral name is also the honest one.\n *\n * ⚠️ THE WEB CROPPER NEVER CONSULTS THIS. It has a canvas and needs no\n * injection. A cross-platform app can therefore call `setCropEncoder` once at\n * start-up unconditionally: on web it is a harmless no-op, on a device it is\n * what makes Save work.\n */\nexport interface ICropEncoder {\n /**\n * Produce one encoded file.\n *\n * @param uri the source image\n * @param sourceRect the region to keep, in SOURCE pixels, rotation undone\n * @param sourceSize the source's full pixel size\n * @param rotation degrees clockwise to apply when saving\n * @param size the output's pixel size, already decided by the search\n * @param quality 0–1; ignored by lossless formats\n * @param format the encoder to use\n * @returns the written file's uri, its size in bytes, and — when the\n * implementation knows them — the dimensions it actually produced\n *\n * ⚠️ `width`/`height` ARE REPORTED BECAUSE THE REQUEST IS NOT ALWAYS THE\n * RESULT. The bare-React-Native encoder asks its resizer for a square\n * ceiling with `contain` (the only orientation-safe way to drive it), so\n * the output can land a pixel or two off the requested size. Reporting the\n * number back beats the caller assuming the one it asked for.\n */\n (args: {\n uri: string;\n sourceRect: ICropRect;\n /**\n * The source's full size.\n *\n * ⚠️ THE BUNDLED ENCODER NO LONGER NEEDS THIS, and the field stays\n * anyway. It was added when that encoder rotated the file BEFORE\n * cropping it and therefore had to re-express the rect into the rotated\n * frame. Reordering the chain to crop first removed that mapping — but\n * the contract is public, any implementation that rotates first still\n * needs the dimensions, and they are free to carry. A canvas encoder\n * ignores the field entirely.\n */\n sourceSize: ICropSize;\n rotation: number;\n size: ICropSize;\n quality: number;\n format: CropOutputFormat;\n }): Promise<{uri: string; bytes: number; width?: number; height?: number}>;\n}\n\nlet encoder: ICropEncoder | null = null;\n\n/**\n * Register the native encoder. Call once, at app start, before the cropper is\n * used. Passing `null` unregisters it, which is what a test wants.\n */\nexport const setCropEncoder = (next: ICropEncoder | null): void => {\n encoder = next;\n};\n\n/** The registered encoder, or null. */\nexport const getCropEncoder = (): ICropEncoder | null => encoder;\n\n/** Whether a host app has wired one up. The native UI reads this to disable Save. */\nexport const hasCropEncoder = (): boolean => encoder !== null;\n"],"names":[],"mappings":"AA4DA,IAAI,OAAA,GAA+B,IAAA;AAM5B,MAAM,cAAA,GAAiB,CAAC,IAAA,KAAoC;AAC/D,EAAA,OAAA,GAAU,IAAA;AACd;AAGO,MAAM,iBAAiB,MAA2B;AAGlD,MAAM,cAAA,GAAiB,MAAe,OAAA,KAAY;;;;"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var ImageEditor = require('@react-native-community/image-editor');
|
|
4
|
+
var ImageResizer = require('@bam.tech/react-native-image-resizer');
|
|
5
|
+
|
|
6
|
+
const RESIZER_FORMAT = {
|
|
7
|
+
jpeg: "JPEG",
|
|
8
|
+
png: "PNG",
|
|
9
|
+
webp: "WEBP"
|
|
10
|
+
};
|
|
11
|
+
let cropCache = null;
|
|
12
|
+
const clearCropCache = () => {
|
|
13
|
+
cropCache = null;
|
|
14
|
+
};
|
|
15
|
+
const croppedSource = async (uri, rect) => {
|
|
16
|
+
const offset = { x: Math.round(rect.x), y: Math.round(rect.y) };
|
|
17
|
+
const size = { width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
18
|
+
const key = `${uri}@${offset.x},${offset.y},${size.width},${size.height}`;
|
|
19
|
+
if (cropCache?.key === key) return cropCache.uri;
|
|
20
|
+
const out = await ImageEditor.cropImage(uri, { offset, size });
|
|
21
|
+
cropCache = { key, uri: out.uri };
|
|
22
|
+
return out.uri;
|
|
23
|
+
};
|
|
24
|
+
const rnImageEncoder = async ({
|
|
25
|
+
uri,
|
|
26
|
+
sourceRect,
|
|
27
|
+
rotation,
|
|
28
|
+
size,
|
|
29
|
+
quality,
|
|
30
|
+
format
|
|
31
|
+
}) => {
|
|
32
|
+
const cropped = await croppedSource(uri, sourceRect);
|
|
33
|
+
const ceiling = Math.max(1, Math.round(Math.max(size.width, size.height)));
|
|
34
|
+
const out = await ImageResizer.createResizedImage(
|
|
35
|
+
cropped,
|
|
36
|
+
ceiling,
|
|
37
|
+
ceiling,
|
|
38
|
+
RESIZER_FORMAT[format],
|
|
39
|
+
// ⚠️ 0–100 HERE, 0–1 IN THE CONTRACT. The shared search speaks in the
|
|
40
|
+
// fractions a canvas wants; this library wants percent. Passing 0.8
|
|
41
|
+
// straight through would ask for 0.8% quality and return a smear —
|
|
42
|
+
// which would still "fit the byte ceiling", silently.
|
|
43
|
+
Math.round(quality * 100),
|
|
44
|
+
rotation,
|
|
45
|
+
void 0,
|
|
46
|
+
false,
|
|
47
|
+
{ mode: "contain", onlyScaleDown: true }
|
|
48
|
+
);
|
|
49
|
+
return { uri: out.uri, bytes: out.size, width: out.width, height: out.height };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
exports.clearCropCache = clearCropCache;
|
|
53
|
+
exports.rnImageEncoder = rnImageEncoder;
|
|
54
|
+
//# sourceMappingURL=rnImageEncoder.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rnImageEncoder.cjs","sources":["../../../../../src/components/ImageCropper/encoders/rnImageEncoder.ts"],"sourcesContent":["import ImageEditor from \"@react-native-community/image-editor\";\nimport ImageResizer from \"@bam.tech/react-native-image-resizer\";\nimport type {CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\nimport type {ICropEncoder} from \"../encoderRegistry\";\n\n/**\n * The native encoder: crop with one library, then compress with the other.\n *\n * ⛔ IT TAKES TWO BECAUSE NEITHER DOES THE WHOLE JOB, and that was checked by\n * reading their types rather than assumed. `@react-native-community/image-editor`\n * crops a sub-rectangle (`offset` + `size`) — and cannot rotate.\n * `@bam.tech/react-native-image-resizer` rotates, resizes, and sets quality and\n * format — and its entire options object is `{mode, onlyScaleDown}`, so it has\n * no offset and cannot crop. (`expo-image-manipulator` does all four in one\n * call, which is why the plan named it, and it needs the Expo modules runtime\n * this bare app does not have.)\n *\n * ── CROP ONCE, COMPRESS MANY ──\n * ⛔ THE CROP RECT IS THE SAME ON EVERY ATTEMPT. The compression search varies\n * only the output SIZE and the QUALITY — the region of the photo was chosen by\n * the user before Save was pressed and never moves. So the crop happens once,\n * into a cached temporary file, and each of the (up to fourteen) attempts is a\n * single resizer call against it. Cropping inside the loop would be fourteen\n * identical crops of the same rect.\n *\n * ⚠️ AND THE LOSSY STEP IS LAST, which is the other reason this order is right.\n * The crop is taken at full quality; the only place bytes are deliberately\n * thrown away is the final encode. Compressing first and cropping after would\n * pay the quality cost twice.\n *\n * ⚠️ NO ROTATION MAPPING IS NEEDED IN THIS ORDER, and that is a real\n * simplification over the first version. `sourceRect` arrives in the ORIGINAL\n * image's coordinates with the rotation undone, and the file being cropped has\n * not been rotated yet — so the rect goes straight through. The previous chain\n * rotated first and therefore had to re-express the rect into the rotated\n * frame, which is one more place for a 90°-wrong crop to hide.\n */\n\nconst RESIZER_FORMAT: Record<CropOutputFormat, \"JPEG\" | \"PNG\" | \"WEBP\"> = {\n jpeg: \"JPEG\",\n png: \"PNG\",\n webp: \"WEBP\",\n};\n\nlet cropCache: {key: string; uri: string} | null = null;\n\n/** Drops the cached crop. Call when the cropper closes, and in tests. */\nexport const clearCropCache = (): void => {\n cropCache = null;\n};\n\n/**\n * The chosen region as a file, at full quality, cached across attempts.\n *\n * ⚠️ THE CACHE HOLDS ONE ENTRY. The cropper works on one image at a time, and\n * an unbounded map of temp-file paths in a long-lived app is a leak that looks\n * like nothing until storage fills.\n */\nconst croppedSource = async (\n uri: string,\n rect: {x: number; y: number; width: number; height: number},\n): Promise<string> => {\n const offset = {x: Math.round(rect.x), y: Math.round(rect.y)};\n const size = {width: Math.round(rect.width), height: Math.round(rect.height)};\n const key = `${uri}@${offset.x},${offset.y},${size.width},${size.height}`;\n if (cropCache?.key === key) return cropCache.uri;\n\n // ⚠️ NO `displaySize` AND NO `quality` HERE ON PURPOSE. This is an\n // intermediate: scaling it now would resample twice, and compressing it now\n // would throw away bytes the real encode is about to throw away again.\n const out = await ImageEditor.cropImage(uri, {offset, size});\n\n cropCache = {key, uri: out.uri};\n return out.uri;\n};\n\n/**\n * An {@link ICropEncoder} for bare React Native apps.\n *\n * Wire it once at app start:\n * ```ts\n * import {setCropEncoder} from '@lmb-it/kitsconcerto-native';\n * import {rnImageEncoder} from '@lmb-it/kitsconcerto-native/components/ImageCropper/encoders/rnImageEncoder';\n * setCropEncoder(rnImageEncoder);\n * ```\n */\nexport const rnImageEncoder: ICropEncoder = async ({\n uri,\n sourceRect,\n rotation,\n size,\n quality,\n format,\n}) => {\n const cropped = await croppedSource(uri, sourceRect);\n\n /**\n * ⛔ A SQUARE CEILING, NOT THE TARGET WIDTH AND HEIGHT, AND THIS IS THE ONE\n * THING HERE I CANNOT VERIFY WITHOUT A DEVICE. `createResizedImage` takes\n * `maxWidth`/`maxHeight` and a rotation, and its documentation does not say\n * whether the ceiling is applied BEFORE or AFTER the turn. Those two give\n * different answers for a quarter turn: a 1600×900 crop asked for 450×800\n * comes out 450×800 one way and 253×450 the other.\n *\n * A square box removes the question. `contain` preserves the aspect ratio,\n * and a square ceiling is the same box whichever way the image is facing —\n * so both orderings produce the identical result. The cost is that the\n * output may land a pixel or two off the exact request, which is why the\n * ACTUAL dimensions are read back from the response below rather than\n * assumed from the request.\n */\n const ceiling = Math.max(1, Math.round(Math.max(size.width, size.height)));\n\n const out = await ImageResizer.createResizedImage(\n cropped,\n ceiling,\n ceiling,\n RESIZER_FORMAT[format],\n // ⚠️ 0–100 HERE, 0–1 IN THE CONTRACT. The shared search speaks in the\n // fractions a canvas wants; this library wants percent. Passing 0.8\n // straight through would ask for 0.8% quality and return a smear —\n // which would still \"fit the byte ceiling\", silently.\n Math.round(quality * 100),\n rotation,\n undefined,\n false,\n {mode: \"contain\", onlyScaleDown: true},\n );\n\n return {uri: out.uri, bytes: out.size, width: out.width, height: out.height};\n};\n"],"names":[],"mappings":";;;;;AAsCA,MAAM,cAAA,GAAoE;AAAA,EACtE,IAAA,EAAM,MAAA;AAAA,EACN,GAAA,EAAK,KAAA;AAAA,EACL,IAAA,EAAM;AACV,CAAA;AAEA,IAAI,SAAA,GAA+C,IAAA;AAG5C,MAAM,iBAAiB,MAAY;AACtC,EAAA,SAAA,GAAY,IAAA;AAChB;AASA,MAAM,aAAA,GAAgB,OAClB,GAAA,EACA,IAAA,KACkB;AAClB,EAAA,MAAM,MAAA,GAAS,EAAC,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,EAAC;AAC5D,EAAA,MAAM,IAAA,GAAO,EAAC,KAAA,EAAO,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,KAAK,CAAA,EAAG,MAAA,EAAQ,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,MAAM,CAAA,EAAC;AAC5E,EAAA,MAAM,GAAA,GAAM,CAAA,EAAG,GAAG,CAAA,CAAA,EAAI,OAAO,CAAC,CAAA,CAAA,EAAI,MAAA,CAAO,CAAC,CAAA,CAAA,EAAI,IAAA,CAAK,KAAK,CAAA,CAAA,EAAI,KAAK,MAAM,CAAA,CAAA;AACvE,EAAA,IAAI,SAAA,EAAW,GAAA,KAAQ,GAAA,EAAK,OAAO,SAAA,CAAU,GAAA;AAK7C,EAAA,MAAM,GAAA,GAAM,MAAM,WAAA,CAAY,SAAA,CAAU,KAAK,EAAC,MAAA,EAAQ,MAAK,CAAA;AAE3D,EAAA,SAAA,GAAY,EAAC,GAAA,EAAK,GAAA,EAAK,GAAA,CAAI,GAAA,EAAG;AAC9B,EAAA,OAAO,GAAA,CAAI,GAAA;AACf,CAAA;AAYO,MAAM,iBAA+B,OAAO;AAAA,EAC/C,GAAA;AAAA,EACA,UAAA;AAAA,EACA,QAAA;AAAA,EACA,IAAA;AAAA,EACA,OAAA;AAAA,EACA;AACJ,CAAA,KAAM;AACF,EAAA,MAAM,OAAA,GAAU,MAAM,aAAA,CAAc,GAAA,EAAK,UAAU,CAAA;AAiBnD,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,KAAA,EAAO,IAAA,CAAK,MAAM,CAAC,CAAC,CAAA;AAEzE,EAAA,MAAM,GAAA,GAAM,MAAM,YAAA,CAAa,kBAAA;AAAA,IAC3B,OAAA;AAAA,IACA,OAAA;AAAA,IACA,OAAA;AAAA,IACA,eAAe,MAAM,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAKrB,IAAA,CAAK,KAAA,CAAM,OAAA,GAAU,GAAG,CAAA;AAAA,IACxB,QAAA;AAAA,IACA,MAAA;AAAA,IACA,KAAA;AAAA,IACA,EAAC,IAAA,EAAM,SAAA,EAAW,aAAA,EAAe,IAAA;AAAI,GACzC;AAEA,EAAA,OAAO,EAAC,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,KAAA,EAAO,GAAA,CAAI,IAAA,EAAM,KAAA,EAAO,GAAA,CAAI,KAAA,EAAO,MAAA,EAAQ,GAAA,CAAI,MAAA,EAAM;AAC/E;;;;;"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import ImageEditor from '@react-native-community/image-editor';
|
|
2
|
+
import ImageResizer from '@bam.tech/react-native-image-resizer';
|
|
3
|
+
|
|
4
|
+
const RESIZER_FORMAT = {
|
|
5
|
+
jpeg: "JPEG",
|
|
6
|
+
png: "PNG",
|
|
7
|
+
webp: "WEBP"
|
|
8
|
+
};
|
|
9
|
+
let cropCache = null;
|
|
10
|
+
const clearCropCache = () => {
|
|
11
|
+
cropCache = null;
|
|
12
|
+
};
|
|
13
|
+
const croppedSource = async (uri, rect) => {
|
|
14
|
+
const offset = { x: Math.round(rect.x), y: Math.round(rect.y) };
|
|
15
|
+
const size = { width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
16
|
+
const key = `${uri}@${offset.x},${offset.y},${size.width},${size.height}`;
|
|
17
|
+
if (cropCache?.key === key) return cropCache.uri;
|
|
18
|
+
const out = await ImageEditor.cropImage(uri, { offset, size });
|
|
19
|
+
cropCache = { key, uri: out.uri };
|
|
20
|
+
return out.uri;
|
|
21
|
+
};
|
|
22
|
+
const rnImageEncoder = async ({
|
|
23
|
+
uri,
|
|
24
|
+
sourceRect,
|
|
25
|
+
rotation,
|
|
26
|
+
size,
|
|
27
|
+
quality,
|
|
28
|
+
format
|
|
29
|
+
}) => {
|
|
30
|
+
const cropped = await croppedSource(uri, sourceRect);
|
|
31
|
+
const ceiling = Math.max(1, Math.round(Math.max(size.width, size.height)));
|
|
32
|
+
const out = await ImageResizer.createResizedImage(
|
|
33
|
+
cropped,
|
|
34
|
+
ceiling,
|
|
35
|
+
ceiling,
|
|
36
|
+
RESIZER_FORMAT[format],
|
|
37
|
+
// ⚠️ 0–100 HERE, 0–1 IN THE CONTRACT. The shared search speaks in the
|
|
38
|
+
// fractions a canvas wants; this library wants percent. Passing 0.8
|
|
39
|
+
// straight through would ask for 0.8% quality and return a smear —
|
|
40
|
+
// which would still "fit the byte ceiling", silently.
|
|
41
|
+
Math.round(quality * 100),
|
|
42
|
+
rotation,
|
|
43
|
+
void 0,
|
|
44
|
+
false,
|
|
45
|
+
{ mode: "contain", onlyScaleDown: true }
|
|
46
|
+
);
|
|
47
|
+
return { uri: out.uri, bytes: out.size, width: out.width, height: out.height };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export { clearCropCache, rnImageEncoder };
|
|
51
|
+
//# sourceMappingURL=rnImageEncoder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rnImageEncoder.js","sources":["../../../../../src/components/ImageCropper/encoders/rnImageEncoder.ts"],"sourcesContent":["import ImageEditor from \"@react-native-community/image-editor\";\nimport ImageResizer from \"@bam.tech/react-native-image-resizer\";\nimport type {CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\nimport type {ICropEncoder} from \"../encoderRegistry\";\n\n/**\n * The native encoder: crop with one library, then compress with the other.\n *\n * ⛔ IT TAKES TWO BECAUSE NEITHER DOES THE WHOLE JOB, and that was checked by\n * reading their types rather than assumed. `@react-native-community/image-editor`\n * crops a sub-rectangle (`offset` + `size`) — and cannot rotate.\n * `@bam.tech/react-native-image-resizer` rotates, resizes, and sets quality and\n * format — and its entire options object is `{mode, onlyScaleDown}`, so it has\n * no offset and cannot crop. (`expo-image-manipulator` does all four in one\n * call, which is why the plan named it, and it needs the Expo modules runtime\n * this bare app does not have.)\n *\n * ── CROP ONCE, COMPRESS MANY ──\n * ⛔ THE CROP RECT IS THE SAME ON EVERY ATTEMPT. The compression search varies\n * only the output SIZE and the QUALITY — the region of the photo was chosen by\n * the user before Save was pressed and never moves. So the crop happens once,\n * into a cached temporary file, and each of the (up to fourteen) attempts is a\n * single resizer call against it. Cropping inside the loop would be fourteen\n * identical crops of the same rect.\n *\n * ⚠️ AND THE LOSSY STEP IS LAST, which is the other reason this order is right.\n * The crop is taken at full quality; the only place bytes are deliberately\n * thrown away is the final encode. Compressing first and cropping after would\n * pay the quality cost twice.\n *\n * ⚠️ NO ROTATION MAPPING IS NEEDED IN THIS ORDER, and that is a real\n * simplification over the first version. `sourceRect` arrives in the ORIGINAL\n * image's coordinates with the rotation undone, and the file being cropped has\n * not been rotated yet — so the rect goes straight through. The previous chain\n * rotated first and therefore had to re-express the rect into the rotated\n * frame, which is one more place for a 90°-wrong crop to hide.\n */\n\nconst RESIZER_FORMAT: Record<CropOutputFormat, \"JPEG\" | \"PNG\" | \"WEBP\"> = {\n jpeg: \"JPEG\",\n png: \"PNG\",\n webp: \"WEBP\",\n};\n\nlet cropCache: {key: string; uri: string} | null = null;\n\n/** Drops the cached crop. Call when the cropper closes, and in tests. */\nexport const clearCropCache = (): void => {\n cropCache = null;\n};\n\n/**\n * The chosen region as a file, at full quality, cached across attempts.\n *\n * ⚠️ THE CACHE HOLDS ONE ENTRY. The cropper works on one image at a time, and\n * an unbounded map of temp-file paths in a long-lived app is a leak that looks\n * like nothing until storage fills.\n */\nconst croppedSource = async (\n uri: string,\n rect: {x: number; y: number; width: number; height: number},\n): Promise<string> => {\n const offset = {x: Math.round(rect.x), y: Math.round(rect.y)};\n const size = {width: Math.round(rect.width), height: Math.round(rect.height)};\n const key = `${uri}@${offset.x},${offset.y},${size.width},${size.height}`;\n if (cropCache?.key === key) return cropCache.uri;\n\n // ⚠️ NO `displaySize` AND NO `quality` HERE ON PURPOSE. This is an\n // intermediate: scaling it now would resample twice, and compressing it now\n // would throw away bytes the real encode is about to throw away again.\n const out = await ImageEditor.cropImage(uri, {offset, size});\n\n cropCache = {key, uri: out.uri};\n return out.uri;\n};\n\n/**\n * An {@link ICropEncoder} for bare React Native apps.\n *\n * Wire it once at app start:\n * ```ts\n * import {setCropEncoder} from '@lmb-it/kitsconcerto-native';\n * import {rnImageEncoder} from '@lmb-it/kitsconcerto-native/components/ImageCropper/encoders/rnImageEncoder';\n * setCropEncoder(rnImageEncoder);\n * ```\n */\nexport const rnImageEncoder: ICropEncoder = async ({\n uri,\n sourceRect,\n rotation,\n size,\n quality,\n format,\n}) => {\n const cropped = await croppedSource(uri, sourceRect);\n\n /**\n * ⛔ A SQUARE CEILING, NOT THE TARGET WIDTH AND HEIGHT, AND THIS IS THE ONE\n * THING HERE I CANNOT VERIFY WITHOUT A DEVICE. `createResizedImage` takes\n * `maxWidth`/`maxHeight` and a rotation, and its documentation does not say\n * whether the ceiling is applied BEFORE or AFTER the turn. Those two give\n * different answers for a quarter turn: a 1600×900 crop asked for 450×800\n * comes out 450×800 one way and 253×450 the other.\n *\n * A square box removes the question. `contain` preserves the aspect ratio,\n * and a square ceiling is the same box whichever way the image is facing —\n * so both orderings produce the identical result. The cost is that the\n * output may land a pixel or two off the exact request, which is why the\n * ACTUAL dimensions are read back from the response below rather than\n * assumed from the request.\n */\n const ceiling = Math.max(1, Math.round(Math.max(size.width, size.height)));\n\n const out = await ImageResizer.createResizedImage(\n cropped,\n ceiling,\n ceiling,\n RESIZER_FORMAT[format],\n // ⚠️ 0–100 HERE, 0–1 IN THE CONTRACT. The shared search speaks in the\n // fractions a canvas wants; this library wants percent. Passing 0.8\n // straight through would ask for 0.8% quality and return a smear —\n // which would still \"fit the byte ceiling\", silently.\n Math.round(quality * 100),\n rotation,\n undefined,\n false,\n {mode: \"contain\", onlyScaleDown: true},\n );\n\n return {uri: out.uri, bytes: out.size, width: out.width, height: out.height};\n};\n"],"names":[],"mappings":";;;AAsCA,MAAM,cAAA,GAAoE;AAAA,EACtE,IAAA,EAAM,MAAA;AAAA,EACN,GAAA,EAAK,KAAA;AAAA,EACL,IAAA,EAAM;AACV,CAAA;AAEA,IAAI,SAAA,GAA+C,IAAA;AAG5C,MAAM,iBAAiB,MAAY;AACtC,EAAA,SAAA,GAAY,IAAA;AAChB;AASA,MAAM,aAAA,GAAgB,OAClB,GAAA,EACA,IAAA,KACkB;AAClB,EAAA,MAAM,MAAA,GAAS,EAAC,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,CAAC,CAAA,EAAC;AAC5D,EAAA,MAAM,IAAA,GAAO,EAAC,KAAA,EAAO,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,KAAK,CAAA,EAAG,MAAA,EAAQ,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,MAAM,CAAA,EAAC;AAC5E,EAAA,MAAM,GAAA,GAAM,CAAA,EAAG,GAAG,CAAA,CAAA,EAAI,OAAO,CAAC,CAAA,CAAA,EAAI,MAAA,CAAO,CAAC,CAAA,CAAA,EAAI,IAAA,CAAK,KAAK,CAAA,CAAA,EAAI,KAAK,MAAM,CAAA,CAAA;AACvE,EAAA,IAAI,SAAA,EAAW,GAAA,KAAQ,GAAA,EAAK,OAAO,SAAA,CAAU,GAAA;AAK7C,EAAA,MAAM,GAAA,GAAM,MAAM,WAAA,CAAY,SAAA,CAAU,KAAK,EAAC,MAAA,EAAQ,MAAK,CAAA;AAE3D,EAAA,SAAA,GAAY,EAAC,GAAA,EAAK,GAAA,EAAK,GAAA,CAAI,GAAA,EAAG;AAC9B,EAAA,OAAO,GAAA,CAAI,GAAA;AACf,CAAA;AAYO,MAAM,iBAA+B,OAAO;AAAA,EAC/C,GAAA;AAAA,EACA,UAAA;AAAA,EACA,QAAA;AAAA,EACA,IAAA;AAAA,EACA,OAAA;AAAA,EACA;AACJ,CAAA,KAAM;AACF,EAAA,MAAM,OAAA,GAAU,MAAM,aAAA,CAAc,GAAA,EAAK,UAAU,CAAA;AAiBnD,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,IAAA,CAAK,KAAA,CAAM,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,KAAA,EAAO,IAAA,CAAK,MAAM,CAAC,CAAC,CAAA;AAEzE,EAAA,MAAM,GAAA,GAAM,MAAM,YAAA,CAAa,kBAAA;AAAA,IAC3B,OAAA;AAAA,IACA,OAAA;AAAA,IACA,OAAA;AAAA,IACA,eAAe,MAAM,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAKrB,IAAA,CAAK,KAAA,CAAM,OAAA,GAAU,GAAG,CAAA;AAAA,IACxB,QAAA;AAAA,IACA,MAAA;AAAA,IACA,KAAA;AAAA,IACA,EAAC,IAAA,EAAM,SAAA,EAAW,aAAA,EAAe,IAAA;AAAI,GACzC;AAEA,EAAA,OAAO,EAAC,GAAA,EAAK,GAAA,CAAI,GAAA,EAAK,KAAA,EAAO,GAAA,CAAI,IAAA,EAAM,KAAA,EAAO,GAAA,CAAI,KAAA,EAAO,MAAA,EAAQ,GAAA,CAAI,MAAA,EAAM;AAC/E;;;;"}
|