@lmb-it/kitsconcerto-native 0.1.287 → 0.1.289
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/components/ImageCropper/compression.cjs +8 -3
- package/components/ImageCropper/compression.cjs.map +1 -1
- package/components/ImageCropper/compression.js +9 -4
- package/components/ImageCropper/compression.js.map +1 -1
- package/components/ImageCropper/constants.cjs +2 -2
- package/components/ImageCropper/constants.cjs.map +1 -1
- package/components/ImageCropper/constants.js +2 -2
- package/components/ImageCropper/constants.js.map +1 -1
- package/components/ImageCropper/index.cjs +584 -550
- package/components/ImageCropper/index.cjs.map +1 -1
- package/components/ImageCropper/index.js +585 -551
- package/components/ImageCropper/index.js.map +1 -1
- package/index.d.ts +82 -2
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ const isLossless = (format) => format === "png";
|
|
|
7
7
|
const bisect = (low, high) => (low + high) / 2;
|
|
8
8
|
function* encodePlan(natural, max, format) {
|
|
9
9
|
let size = geometry.fitWithin(natural, { width: max.width, height: max.height });
|
|
10
|
-
for (
|
|
10
|
+
for (; ; ) {
|
|
11
11
|
if (isLossless(format)) {
|
|
12
12
|
const outcome = yield { size, quality: 1 };
|
|
13
13
|
if (outcome.bytes <= max.bytes) return;
|
|
@@ -27,11 +27,16 @@ function* encodePlan(natural, max, format) {
|
|
|
27
27
|
high = quality;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
if (!fitted) {
|
|
31
|
+
const floor = yield { size, quality: constants.QUALITY_MIN };
|
|
32
|
+
if (floor.bytes <= max.bytes) fitted = true;
|
|
33
|
+
}
|
|
30
34
|
if (fitted) return;
|
|
31
35
|
}
|
|
32
|
-
const
|
|
36
|
+
const longest = Math.max(size.width, size.height);
|
|
37
|
+
if (longest * constants.DOWNSCALE_FACTOR < constants.MIN_DOWNSCALE_EDGE) return;
|
|
38
|
+
const next = longest * constants.DOWNSCALE_FACTOR;
|
|
33
39
|
size = geometry.fitWithin(size, { width: next, height: next });
|
|
34
|
-
if (size.width < 1 || size.height < 1) return;
|
|
35
40
|
}
|
|
36
41
|
}
|
|
37
42
|
async function runEncodePlan(natural, max, format, encode) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compression.cjs","sources":["../../../../src/components/ImageCropper/compression.ts"],"sourcesContent":["import type {CropOutputFormat, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {\n DOWNSCALE_FACTOR,\n MAX_DOWNSCALE_PASSES,\n QUALITY_MAX,\n QUALITY_MIN,\n QUALITY_STEPS,\n} from \"./constants\";\nimport {fitWithin} from \"./geometry\";\n\n/**\n * How hard to squeeze, and when to stop squeezing and shrink instead.\n *\n * ⛔ THE POLICY IS HERE AND THE ENCODER IS NOT, ON PURPOSE. Web encodes with\n * `canvas.toBlob`, native with expo-image-manipulator; neither can run in the\n * other's runtime, and neither can be unit-tested without one. What CAN be\n * tested — and what decides whether a 4 MB photo comes back at 480 KB or at\n * 2 MB — is the search: how the quality band is walked, when the band is\n * exhausted, and how far to scale down after that. Both platforms drive the\n * same plan from here and only supply `encode`.\n */\n\n/** One attempt: what the caller's encoder is asked to produce. */\nexport interface IEncodeAttempt {\n size: ICropSize;\n /** 0–1. Ignored by lossless formats. */\n quality: number;\n}\n\n/** What an encoder reports back. */\nexport interface IEncodeOutcome {\n bytes: number;\n}\n\n/** Lossless formats cannot trade quality for bytes — only dimensions. */\nexport const isLossless = (format: CropOutputFormat): boolean => format === \"png\";\n\n/**\n * The quality to try on step `step` of a binary search that has so far bracketed\n * the answer to [`low`, `high`].\n *\n * ⚠️ A PLAIN MIDPOINT, NOT A GUESS FROM THE BYTE RATIO. Encoded size is not\n * linear in quality — it is flat near the top of the band and falls off a cliff\n * near the bottom — so a ratio-based jump overshoots into the blocky region on\n * the first move. Bisection is slower by a few encodes and lands where it says.\n */\nexport const bisect = (low: number, high: number): number => (low + high) / 2;\n\n/**\n * The plan for one encode pass, as a sequence of (size, quality) attempts.\n *\n * Walks the quality band from {@link QUALITY_MAX} down by bisection for\n * {@link QUALITY_STEPS} steps, keeping the best attempt that fits. When the\n * whole band is over the ceiling it scales the image down by\n * {@link DOWNSCALE_FACTOR} and walks it again, up to\n * {@link MAX_DOWNSCALE_PASSES} times.\n *\n * ⚠️ IT IS A GENERATOR, NOT A LOOP THAT CALLS YOU BACK. The caller owns the\n * encoder, the async, and the cancellation; this owns only the order to try\n * things in. That is what lets the same policy be driven by `canvas.toBlob` on\n * one platform and a native module on the other, and be tested with neither.\n */\nexport function* encodePlan(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Generator<IEncodeAttempt, void, IEncodeOutcome> {\n let size = fitWithin(natural, {width: max.width, height: max.height});\n\n for (let pass = 0; pass <= MAX_DOWNSCALE_PASSES; pass++) {\n if (isLossless(format)) {\n // ⛔ NO QUALITY KNOB TO TURN. A single attempt per size; if it is\n // over, the only lever left is the next downscale pass.\n const outcome = yield {size, quality: 1};\n if (outcome.bytes <= max.bytes) return;\n } else {\n let low = QUALITY_MIN;\n let high = QUALITY_MAX;\n\n // The top of the band first: most images fit at best quality, and\n // when they do nothing else should be tried.\n const best = yield {size, quality: high};\n if (best.bytes <= max.bytes) return;\n\n let fitted = false;\n for (let step = 0; step < QUALITY_STEPS; step++) {\n const quality = bisect(low, high);\n const outcome = yield {size, quality};\n if (outcome.bytes <= max.bytes) {\n fitted = true;\n low = quality; // it fits — try to buy quality back\n } else {\n high = quality;\n }\n }\n if (fitted) return;\n }\n\n // ⚠️ THE LONG EDGE SHRINKS, AND THE RATIO IS KEPT BY `fitWithin`, so a\n // downscale pass can never change the crop's shape. A crop that came\n // back a different aspect than the window showed would be the worst\n // kind of silent failure here.\n const next = Math.max(size.width, size.height) * DOWNSCALE_FACTOR;\n size = fitWithin(size, {width: next, height: next});\n if (size.width < 1 || size.height < 1) return;\n }\n}\n\n/**\n * Drives {@link encodePlan} with a real encoder and returns the best result\n * that met the ceiling — or the smallest one produced, if none did.\n *\n * ⚠️ IT RETURNS THE LAST ATTEMPT EVEN WHEN NOTHING FIT, with `withinBudget`\n * false. Handing back nothing would mean a user who cropped a huge PNG gets a\n * spinner and no file; handing back the smallest we managed, clearly flagged,\n * lets the caller decide between warning and refusing.\n */\nexport async function runEncodePlan<T>(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n encode: (attempt: IEncodeAttempt) => Promise<{bytes: number; value: T}>,\n): Promise<{value: T; bytes: number; size: ICropSize; quality: number; withinBudget: boolean}> {\n const plan = encodePlan(natural, max, format);\n let best: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n let smallest: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n\n // ⚠️ `step` IS A `const` DECLARED INSIDE THE LOOP, AND THAT IS NOT STYLE.\n // Hoisted to a reassigned `let`, TypeScript stops narrowing it across the\n // back edge and `step.value` comes out as `IEncodeAttempt | void` — which\n // then needs a cast, and a cast here would be asserting exactly the thing\n // the `done` check is supposed to prove.\n let outcome: IEncodeOutcome | undefined;\n for (;;) {\n const step = outcome === undefined ? plan.next() : plan.next(outcome);\n if (step.done === true) break;\n\n const attempt = step.value;\n const {bytes, value} = await encode(attempt);\n const record = {value, bytes, size: attempt.size, quality: attempt.quality};\n\n if (bytes <= max.bytes) best = record;\n if (!smallest || bytes < smallest.bytes) smallest = record;\n\n outcome = {bytes};\n }\n\n const chosen = best ?? smallest;\n if (!chosen) {\n throw new Error(\"[ImageCropper] the encode plan produced no attempts at all\");\n }\n return {...chosen, withinBudget: chosen.bytes <= max.bytes};\n}\n"],"names":["fitWithin","MAX_DOWNSCALE_PASSES","QUALITY_MIN","QUALITY_MAX","QUALITY_STEPS","DOWNSCALE_FACTOR"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"compression.cjs","sources":["../../../../src/components/ImageCropper/compression.ts"],"sourcesContent":["import type {CropOutputFormat, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {\n DOWNSCALE_FACTOR,\n MIN_DOWNSCALE_EDGE,\n QUALITY_MAX,\n QUALITY_MIN,\n QUALITY_STEPS,\n} from \"./constants\";\nimport {fitWithin} from \"./geometry\";\n\n/**\n * How hard to squeeze, and when to stop squeezing and shrink instead.\n *\n * ⛔ THE POLICY IS HERE AND THE ENCODER IS NOT, ON PURPOSE. Web encodes with\n * `canvas.toBlob`, native with expo-image-manipulator; neither can run in the\n * other's runtime, and neither can be unit-tested without one. What CAN be\n * tested — and what decides whether a 4 MB photo comes back at 480 KB or at\n * 2 MB — is the search: how the quality band is walked, when the band is\n * exhausted, and how far to scale down after that. Both platforms drive the\n * same plan from here and only supply `encode`.\n */\n\n/** One attempt: what the caller's encoder is asked to produce. */\nexport interface IEncodeAttempt {\n size: ICropSize;\n /** 0–1. Ignored by lossless formats. */\n quality: number;\n}\n\n/** What an encoder reports back. */\nexport interface IEncodeOutcome {\n bytes: number;\n}\n\n/** Lossless formats cannot trade quality for bytes — only dimensions. */\nexport const isLossless = (format: CropOutputFormat): boolean => format === \"png\";\n\n/**\n * The quality to try on step `step` of a binary search that has so far bracketed\n * the answer to [`low`, `high`].\n *\n * ⚠️ A PLAIN MIDPOINT, NOT A GUESS FROM THE BYTE RATIO. Encoded size is not\n * linear in quality — it is flat near the top of the band and falls off a cliff\n * near the bottom — so a ratio-based jump overshoots into the blocky region on\n * the first move. Bisection is slower by a few encodes and lands where it says.\n */\nexport const bisect = (low: number, high: number): number => (low + high) / 2;\n\n/**\n * The plan for one encode pass, as a sequence of (size, quality) attempts.\n *\n * Walks the quality band from {@link QUALITY_MAX} down by bisection for\n * {@link QUALITY_STEPS} steps and then tries the floor itself, keeping the best\n * attempt that fits. When the whole band is over the ceiling it scales the\n * image down by {@link DOWNSCALE_FACTOR} and walks it again — repeating until\n * something fits or the long edge reaches {@link MIN_DOWNSCALE_EDGE}.\n *\n * ⛔ IT SHRINKS UNTIL IT FITS, NOT A FIXED NUMBER OF TIMES. A pass budget made\n * the ceiling a suggestion: the plan stopped at 0.26 of the long edge and\n * reported failure on a picture that had plenty of shrinking left. See\n * {@link MIN_DOWNSCALE_EDGE} for the measurement that prompted it.\n *\n * ⚠️ IT IS A GENERATOR, NOT A LOOP THAT CALLS YOU BACK. The caller owns the\n * encoder, the async, and the cancellation; this owns only the order to try\n * things in. That is what lets the same policy be driven by `canvas.toBlob` on\n * one platform and a native module on the other, and be tested with neither.\n */\nexport function* encodePlan(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Generator<IEncodeAttempt, void, IEncodeOutcome> {\n let size = fitWithin(natural, {width: max.width, height: max.height});\n\n for (;;) {\n if (isLossless(format)) {\n // ⛔ NO QUALITY KNOB TO TURN. A single attempt per size; if it is\n // over, the only lever left is the next downscale pass.\n const outcome = yield {size, quality: 1};\n if (outcome.bytes <= max.bytes) return;\n } else {\n let low = QUALITY_MIN;\n let high = QUALITY_MAX;\n\n // The top of the band first: most images fit at best quality, and\n // when they do nothing else should be tried.\n const best = yield {size, quality: high};\n if (best.bytes <= max.bytes) return;\n\n let fitted = false;\n for (let step = 0; step < QUALITY_STEPS; step++) {\n const quality = bisect(low, high);\n const outcome = yield {size, quality};\n if (outcome.bytes <= max.bytes) {\n fitted = true;\n low = quality; // it fits — try to buy quality back\n } else {\n high = quality;\n }\n }\n\n // ⚠️ THE FLOOR ITSELF, WHICH BISECTION NEVER REACHES. Halving\n // towards `QUALITY_MIN` approaches it and never arrives, so the\n // band's cheapest setting was the one setting never tried — and the\n // plan went off to shrink the image while a quality it had not\n // attempted would have fitted. One encode, and the docblock's \"when\n // the WHOLE band is over the ceiling\" becomes true.\n if (!fitted) {\n const floor = yield {size, quality: QUALITY_MIN};\n if (floor.bytes <= max.bytes) fitted = true;\n }\n if (fitted) return;\n }\n\n // ⚠️ THE LONG EDGE SHRINKS, AND THE RATIO IS KEPT BY `fitWithin`, so a\n // downscale pass can never change the crop's shape. A crop that came\n // back a different aspect than the window showed would be the worst\n // kind of silent failure here.\n // ⛔ THE FLOOR IS CHECKED BEFORE SHRINKING, NOT AFTER. Checking after\n // yields one attempt at a size below the floor — a 12px avatar nobody\n // asked for — and then stops, so the last thing the encoder is asked\n // for is the one size the floor exists to forbid.\n const longest = Math.max(size.width, size.height);\n if (longest * DOWNSCALE_FACTOR < MIN_DOWNSCALE_EDGE) return;\n const next = longest * DOWNSCALE_FACTOR;\n size = fitWithin(size, {width: next, height: next});\n }\n}\n\n/**\n * Drives {@link encodePlan} with a real encoder and returns the best result\n * that met the ceiling — or the smallest one produced, if none did.\n *\n * ⚠️ IT RETURNS THE LAST ATTEMPT EVEN WHEN NOTHING FIT, with `withinBudget`\n * false. Handing back nothing would mean a user who cropped a huge PNG gets a\n * spinner and no file; handing back the smallest we managed, clearly flagged,\n * lets the caller decide between warning and refusing.\n */\nexport async function runEncodePlan<T>(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n encode: (attempt: IEncodeAttempt) => Promise<{bytes: number; value: T}>,\n): Promise<{value: T; bytes: number; size: ICropSize; quality: number; withinBudget: boolean}> {\n const plan = encodePlan(natural, max, format);\n let best: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n let smallest: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n\n // ⚠️ `step` IS A `const` DECLARED INSIDE THE LOOP, AND THAT IS NOT STYLE.\n // Hoisted to a reassigned `let`, TypeScript stops narrowing it across the\n // back edge and `step.value` comes out as `IEncodeAttempt | void` — which\n // then needs a cast, and a cast here would be asserting exactly the thing\n // the `done` check is supposed to prove.\n let outcome: IEncodeOutcome | undefined;\n for (;;) {\n const step = outcome === undefined ? plan.next() : plan.next(outcome);\n if (step.done === true) break;\n\n const attempt = step.value;\n const {bytes, value} = await encode(attempt);\n const record = {value, bytes, size: attempt.size, quality: attempt.quality};\n\n if (bytes <= max.bytes) best = record;\n if (!smallest || bytes < smallest.bytes) smallest = record;\n\n outcome = {bytes};\n }\n\n const chosen = best ?? smallest;\n if (!chosen) {\n throw new Error(\"[ImageCropper] the encode plan produced no attempts at all\");\n }\n return {...chosen, withinBudget: chosen.bytes <= max.bytes};\n}\n"],"names":["fitWithin","QUALITY_MIN","QUALITY_MAX","QUALITY_STEPS","DOWNSCALE_FACTOR","MIN_DOWNSCALE_EDGE"],"mappings":"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"}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { QUALITY_MAX, QUALITY_STEPS, QUALITY_MIN, DOWNSCALE_FACTOR, MIN_DOWNSCALE_EDGE } from './constants.js';
|
|
2
2
|
import { fitWithin } from './geometry.js';
|
|
3
3
|
|
|
4
4
|
const isLossless = (format) => format === "png";
|
|
5
5
|
const bisect = (low, high) => (low + high) / 2;
|
|
6
6
|
function* encodePlan(natural, max, format) {
|
|
7
7
|
let size = fitWithin(natural, { width: max.width, height: max.height });
|
|
8
|
-
for (
|
|
8
|
+
for (; ; ) {
|
|
9
9
|
if (isLossless(format)) {
|
|
10
10
|
const outcome = yield { size, quality: 1 };
|
|
11
11
|
if (outcome.bytes <= max.bytes) return;
|
|
@@ -25,11 +25,16 @@ function* encodePlan(natural, max, format) {
|
|
|
25
25
|
high = quality;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
+
if (!fitted) {
|
|
29
|
+
const floor = yield { size, quality: QUALITY_MIN };
|
|
30
|
+
if (floor.bytes <= max.bytes) fitted = true;
|
|
31
|
+
}
|
|
28
32
|
if (fitted) return;
|
|
29
33
|
}
|
|
30
|
-
const
|
|
34
|
+
const longest = Math.max(size.width, size.height);
|
|
35
|
+
if (longest * DOWNSCALE_FACTOR < MIN_DOWNSCALE_EDGE) return;
|
|
36
|
+
const next = longest * DOWNSCALE_FACTOR;
|
|
31
37
|
size = fitWithin(size, { width: next, height: next });
|
|
32
|
-
if (size.width < 1 || size.height < 1) return;
|
|
33
38
|
}
|
|
34
39
|
}
|
|
35
40
|
async function runEncodePlan(natural, max, format, encode) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compression.js","sources":["../../../../src/components/ImageCropper/compression.ts"],"sourcesContent":["import type {CropOutputFormat, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {\n DOWNSCALE_FACTOR,\n MAX_DOWNSCALE_PASSES,\n QUALITY_MAX,\n QUALITY_MIN,\n QUALITY_STEPS,\n} from \"./constants\";\nimport {fitWithin} from \"./geometry\";\n\n/**\n * How hard to squeeze, and when to stop squeezing and shrink instead.\n *\n * ⛔ THE POLICY IS HERE AND THE ENCODER IS NOT, ON PURPOSE. Web encodes with\n * `canvas.toBlob`, native with expo-image-manipulator; neither can run in the\n * other's runtime, and neither can be unit-tested without one. What CAN be\n * tested — and what decides whether a 4 MB photo comes back at 480 KB or at\n * 2 MB — is the search: how the quality band is walked, when the band is\n * exhausted, and how far to scale down after that. Both platforms drive the\n * same plan from here and only supply `encode`.\n */\n\n/** One attempt: what the caller's encoder is asked to produce. */\nexport interface IEncodeAttempt {\n size: ICropSize;\n /** 0–1. Ignored by lossless formats. */\n quality: number;\n}\n\n/** What an encoder reports back. */\nexport interface IEncodeOutcome {\n bytes: number;\n}\n\n/** Lossless formats cannot trade quality for bytes — only dimensions. */\nexport const isLossless = (format: CropOutputFormat): boolean => format === \"png\";\n\n/**\n * The quality to try on step `step` of a binary search that has so far bracketed\n * the answer to [`low`, `high`].\n *\n * ⚠️ A PLAIN MIDPOINT, NOT A GUESS FROM THE BYTE RATIO. Encoded size is not\n * linear in quality — it is flat near the top of the band and falls off a cliff\n * near the bottom — so a ratio-based jump overshoots into the blocky region on\n * the first move. Bisection is slower by a few encodes and lands where it says.\n */\nexport const bisect = (low: number, high: number): number => (low + high) / 2;\n\n/**\n * The plan for one encode pass, as a sequence of (size, quality) attempts.\n *\n * Walks the quality band from {@link QUALITY_MAX} down by bisection for\n * {@link QUALITY_STEPS} steps, keeping the best attempt that fits. When the\n * whole band is over the ceiling it scales the image down by\n * {@link DOWNSCALE_FACTOR} and walks it again, up to\n * {@link MAX_DOWNSCALE_PASSES} times.\n *\n * ⚠️ IT IS A GENERATOR, NOT A LOOP THAT CALLS YOU BACK. The caller owns the\n * encoder, the async, and the cancellation; this owns only the order to try\n * things in. That is what lets the same policy be driven by `canvas.toBlob` on\n * one platform and a native module on the other, and be tested with neither.\n */\nexport function* encodePlan(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Generator<IEncodeAttempt, void, IEncodeOutcome> {\n let size = fitWithin(natural, {width: max.width, height: max.height});\n\n for (let pass = 0; pass <= MAX_DOWNSCALE_PASSES; pass++) {\n if (isLossless(format)) {\n // ⛔ NO QUALITY KNOB TO TURN. A single attempt per size; if it is\n // over, the only lever left is the next downscale pass.\n const outcome = yield {size, quality: 1};\n if (outcome.bytes <= max.bytes) return;\n } else {\n let low = QUALITY_MIN;\n let high = QUALITY_MAX;\n\n // The top of the band first: most images fit at best quality, and\n // when they do nothing else should be tried.\n const best = yield {size, quality: high};\n if (best.bytes <= max.bytes) return;\n\n let fitted = false;\n for (let step = 0; step < QUALITY_STEPS; step++) {\n const quality = bisect(low, high);\n const outcome = yield {size, quality};\n if (outcome.bytes <= max.bytes) {\n fitted = true;\n low = quality; // it fits — try to buy quality back\n } else {\n high = quality;\n }\n }\n if (fitted) return;\n }\n\n // ⚠️ THE LONG EDGE SHRINKS, AND THE RATIO IS KEPT BY `fitWithin`, so a\n // downscale pass can never change the crop's shape. A crop that came\n // back a different aspect than the window showed would be the worst\n // kind of silent failure here.\n const next = Math.max(size.width, size.height) * DOWNSCALE_FACTOR;\n size = fitWithin(size, {width: next, height: next});\n if (size.width < 1 || size.height < 1) return;\n }\n}\n\n/**\n * Drives {@link encodePlan} with a real encoder and returns the best result\n * that met the ceiling — or the smallest one produced, if none did.\n *\n * ⚠️ IT RETURNS THE LAST ATTEMPT EVEN WHEN NOTHING FIT, with `withinBudget`\n * false. Handing back nothing would mean a user who cropped a huge PNG gets a\n * spinner and no file; handing back the smallest we managed, clearly flagged,\n * lets the caller decide between warning and refusing.\n */\nexport async function runEncodePlan<T>(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n encode: (attempt: IEncodeAttempt) => Promise<{bytes: number; value: T}>,\n): Promise<{value: T; bytes: number; size: ICropSize; quality: number; withinBudget: boolean}> {\n const plan = encodePlan(natural, max, format);\n let best: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n let smallest: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n\n // ⚠️ `step` IS A `const` DECLARED INSIDE THE LOOP, AND THAT IS NOT STYLE.\n // Hoisted to a reassigned `let`, TypeScript stops narrowing it across the\n // back edge and `step.value` comes out as `IEncodeAttempt | void` — which\n // then needs a cast, and a cast here would be asserting exactly the thing\n // the `done` check is supposed to prove.\n let outcome: IEncodeOutcome | undefined;\n for (;;) {\n const step = outcome === undefined ? plan.next() : plan.next(outcome);\n if (step.done === true) break;\n\n const attempt = step.value;\n const {bytes, value} = await encode(attempt);\n const record = {value, bytes, size: attempt.size, quality: attempt.quality};\n\n if (bytes <= max.bytes) best = record;\n if (!smallest || bytes < smallest.bytes) smallest = record;\n\n outcome = {bytes};\n }\n\n const chosen = best ?? smallest;\n if (!chosen) {\n throw new Error(\"[ImageCropper] the encode plan produced no attempts at all\");\n }\n return {...chosen, withinBudget: chosen.bytes <= max.bytes};\n}\n"],"names":[],"mappings":";;;AAmCO,MAAM,UAAA,GAAa,CAAC,MAAA,KAAsC,MAAA,KAAW;AAWrE,MAAM,MAAA,GAAS,CAAC,GAAA,EAAa,IAAA,KAAA,CAA0B,MAAM,IAAA,IAAQ;AAgBrE,UAAU,UAAA,CACb,OAAA,EACA,GAAA,EACA,MAAA,EAC+C;AAC/C,EAAA,IAAI,IAAA,GAAO,SAAA,CAAU,OAAA,EAAS,EAAC,KAAA,EAAO,IAAI,KAAA,EAAO,MAAA,EAAQ,GAAA,CAAI,MAAA,EAAO,CAAA;AAEpE,EAAA,KAAA,IAAS,IAAA,GAAO,CAAA,EAAG,IAAA,IAAQ,oBAAA,EAAsB,IAAA,EAAA,EAAQ;AACrD,IAAA,IAAI,UAAA,CAAW,MAAM,CAAA,EAAG;AAGpB,MAAA,MAAM,OAAA,GAAU,MAAM,EAAC,IAAA,EAAM,SAAS,CAAA,EAAC;AACvC,MAAA,IAAI,OAAA,CAAQ,KAAA,IAAS,GAAA,CAAI,KAAA,EAAO;AAAA,IACpC,CAAA,MAAO;AACH,MAAA,IAAI,GAAA,GAAM,WAAA;AACV,MAAA,IAAI,IAAA,GAAO,WAAA;AAIX,MAAA,MAAM,IAAA,GAAO,MAAM,EAAC,IAAA,EAAM,SAAS,IAAA,EAAI;AACvC,MAAA,IAAI,IAAA,CAAK,KAAA,IAAS,GAAA,CAAI,KAAA,EAAO;AAE7B,MAAA,IAAI,MAAA,GAAS,KAAA;AACb,MAAA,KAAA,IAAS,IAAA,GAAO,CAAA,EAAG,IAAA,GAAO,aAAA,EAAe,IAAA,EAAA,EAAQ;AAC7C,QAAA,MAAM,OAAA,GAAU,MAAA,CAAO,GAAA,EAAK,IAAI,CAAA;AAChC,QAAA,MAAM,OAAA,GAAU,MAAM,EAAC,IAAA,EAAM,OAAA,EAAO;AACpC,QAAA,IAAI,OAAA,CAAQ,KAAA,IAAS,GAAA,CAAI,KAAA,EAAO;AAC5B,UAAA,MAAA,GAAS,IAAA;AACT,UAAA,GAAA,GAAM,OAAA;AAAA,QACV,CAAA,MAAO;AACH,UAAA,IAAA,GAAO,OAAA;AAAA,QACX;AAAA,MACJ;AACA,MAAA,IAAI,MAAA,EAAQ;AAAA,IAChB;AAMA,IAAA,MAAM,OAAO,IAAA,CAAK,GAAA,CAAI,KAAK,KAAA,EAAO,IAAA,CAAK,MAAM,CAAA,GAAI,gBAAA;AACjD,IAAA,IAAA,GAAO,UAAU,IAAA,EAAM,EAAC,OAAO,IAAA,EAAM,MAAA,EAAQ,MAAK,CAAA;AAClD,IAAA,IAAI,IAAA,CAAK,KAAA,GAAQ,CAAA,IAAK,IAAA,CAAK,SAAS,CAAA,EAAG;AAAA,EAC3C;AACJ;AAWA,eAAsB,aAAA,CAClB,OAAA,EACA,GAAA,EACA,MAAA,EACA,MAAA,EAC2F;AAC3F,EAAA,MAAM,IAAA,GAAO,UAAA,CAAW,OAAA,EAAS,GAAA,EAAK,MAAM,CAAA;AAC5C,EAAA,IAAI,IAAA;AACJ,EAAA,IAAI,QAAA;AAOJ,EAAA,IAAI,OAAA;AACJ,EAAA,WAAS;AACL,IAAA,MAAM,IAAA,GAAO,YAAY,MAAA,GAAY,IAAA,CAAK,MAAK,GAAI,IAAA,CAAK,KAAK,OAAO,CAAA;AACpE,IAAA,IAAI,IAAA,CAAK,SAAS,IAAA,EAAM;AAExB,IAAA,MAAM,UAAU,IAAA,CAAK,KAAA;AACrB,IAAA,MAAM,EAAC,KAAA,EAAO,KAAA,EAAK,GAAI,MAAM,OAAO,OAAO,CAAA;AAC3C,IAAA,MAAM,MAAA,GAAS,EAAC,KAAA,EAAO,KAAA,EAAO,MAAM,OAAA,CAAQ,IAAA,EAAM,OAAA,EAAS,OAAA,CAAQ,OAAA,EAAO;AAE1E,IAAA,IAAI,KAAA,IAAS,GAAA,CAAI,KAAA,EAAO,IAAA,GAAO,MAAA;AAC/B,IAAA,IAAI,CAAC,QAAA,IAAY,KAAA,GAAQ,QAAA,CAAS,OAAO,QAAA,GAAW,MAAA;AAEpD,IAAA,OAAA,GAAU,EAAC,KAAA,EAAK;AAAA,EACpB;AAEA,EAAA,MAAM,SAAS,IAAA,IAAQ,QAAA;AACvB,EAAA,IAAI,CAAC,MAAA,EAAQ;AACT,IAAA,MAAM,IAAI,MAAM,4DAA4D,CAAA;AAAA,EAChF;AACA,EAAA,OAAO,EAAC,GAAG,MAAA,EAAQ,cAAc,MAAA,CAAO,KAAA,IAAS,IAAI,KAAA,EAAK;AAC9D;;;;"}
|
|
1
|
+
{"version":3,"file":"compression.js","sources":["../../../../src/components/ImageCropper/compression.ts"],"sourcesContent":["import type {CropOutputFormat, ICropSize} from \"@lmb-it/kitsconcerto-types\";\nimport {\n DOWNSCALE_FACTOR,\n MIN_DOWNSCALE_EDGE,\n QUALITY_MAX,\n QUALITY_MIN,\n QUALITY_STEPS,\n} from \"./constants\";\nimport {fitWithin} from \"./geometry\";\n\n/**\n * How hard to squeeze, and when to stop squeezing and shrink instead.\n *\n * ⛔ THE POLICY IS HERE AND THE ENCODER IS NOT, ON PURPOSE. Web encodes with\n * `canvas.toBlob`, native with expo-image-manipulator; neither can run in the\n * other's runtime, and neither can be unit-tested without one. What CAN be\n * tested — and what decides whether a 4 MB photo comes back at 480 KB or at\n * 2 MB — is the search: how the quality band is walked, when the band is\n * exhausted, and how far to scale down after that. Both platforms drive the\n * same plan from here and only supply `encode`.\n */\n\n/** One attempt: what the caller's encoder is asked to produce. */\nexport interface IEncodeAttempt {\n size: ICropSize;\n /** 0–1. Ignored by lossless formats. */\n quality: number;\n}\n\n/** What an encoder reports back. */\nexport interface IEncodeOutcome {\n bytes: number;\n}\n\n/** Lossless formats cannot trade quality for bytes — only dimensions. */\nexport const isLossless = (format: CropOutputFormat): boolean => format === \"png\";\n\n/**\n * The quality to try on step `step` of a binary search that has so far bracketed\n * the answer to [`low`, `high`].\n *\n * ⚠️ A PLAIN MIDPOINT, NOT A GUESS FROM THE BYTE RATIO. Encoded size is not\n * linear in quality — it is flat near the top of the band and falls off a cliff\n * near the bottom — so a ratio-based jump overshoots into the blocky region on\n * the first move. Bisection is slower by a few encodes and lands where it says.\n */\nexport const bisect = (low: number, high: number): number => (low + high) / 2;\n\n/**\n * The plan for one encode pass, as a sequence of (size, quality) attempts.\n *\n * Walks the quality band from {@link QUALITY_MAX} down by bisection for\n * {@link QUALITY_STEPS} steps and then tries the floor itself, keeping the best\n * attempt that fits. When the whole band is over the ceiling it scales the\n * image down by {@link DOWNSCALE_FACTOR} and walks it again — repeating until\n * something fits or the long edge reaches {@link MIN_DOWNSCALE_EDGE}.\n *\n * ⛔ IT SHRINKS UNTIL IT FITS, NOT A FIXED NUMBER OF TIMES. A pass budget made\n * the ceiling a suggestion: the plan stopped at 0.26 of the long edge and\n * reported failure on a picture that had plenty of shrinking left. See\n * {@link MIN_DOWNSCALE_EDGE} for the measurement that prompted it.\n *\n * ⚠️ IT IS A GENERATOR, NOT A LOOP THAT CALLS YOU BACK. The caller owns the\n * encoder, the async, and the cancellation; this owns only the order to try\n * things in. That is what lets the same policy be driven by `canvas.toBlob` on\n * one platform and a native module on the other, and be tested with neither.\n */\nexport function* encodePlan(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n): Generator<IEncodeAttempt, void, IEncodeOutcome> {\n let size = fitWithin(natural, {width: max.width, height: max.height});\n\n for (;;) {\n if (isLossless(format)) {\n // ⛔ NO QUALITY KNOB TO TURN. A single attempt per size; if it is\n // over, the only lever left is the next downscale pass.\n const outcome = yield {size, quality: 1};\n if (outcome.bytes <= max.bytes) return;\n } else {\n let low = QUALITY_MIN;\n let high = QUALITY_MAX;\n\n // The top of the band first: most images fit at best quality, and\n // when they do nothing else should be tried.\n const best = yield {size, quality: high};\n if (best.bytes <= max.bytes) return;\n\n let fitted = false;\n for (let step = 0; step < QUALITY_STEPS; step++) {\n const quality = bisect(low, high);\n const outcome = yield {size, quality};\n if (outcome.bytes <= max.bytes) {\n fitted = true;\n low = quality; // it fits — try to buy quality back\n } else {\n high = quality;\n }\n }\n\n // ⚠️ THE FLOOR ITSELF, WHICH BISECTION NEVER REACHES. Halving\n // towards `QUALITY_MIN` approaches it and never arrives, so the\n // band's cheapest setting was the one setting never tried — and the\n // plan went off to shrink the image while a quality it had not\n // attempted would have fitted. One encode, and the docblock's \"when\n // the WHOLE band is over the ceiling\" becomes true.\n if (!fitted) {\n const floor = yield {size, quality: QUALITY_MIN};\n if (floor.bytes <= max.bytes) fitted = true;\n }\n if (fitted) return;\n }\n\n // ⚠️ THE LONG EDGE SHRINKS, AND THE RATIO IS KEPT BY `fitWithin`, so a\n // downscale pass can never change the crop's shape. A crop that came\n // back a different aspect than the window showed would be the worst\n // kind of silent failure here.\n // ⛔ THE FLOOR IS CHECKED BEFORE SHRINKING, NOT AFTER. Checking after\n // yields one attempt at a size below the floor — a 12px avatar nobody\n // asked for — and then stops, so the last thing the encoder is asked\n // for is the one size the floor exists to forbid.\n const longest = Math.max(size.width, size.height);\n if (longest * DOWNSCALE_FACTOR < MIN_DOWNSCALE_EDGE) return;\n const next = longest * DOWNSCALE_FACTOR;\n size = fitWithin(size, {width: next, height: next});\n }\n}\n\n/**\n * Drives {@link encodePlan} with a real encoder and returns the best result\n * that met the ceiling — or the smallest one produced, if none did.\n *\n * ⚠️ IT RETURNS THE LAST ATTEMPT EVEN WHEN NOTHING FIT, with `withinBudget`\n * false. Handing back nothing would mean a user who cropped a huge PNG gets a\n * spinner and no file; handing back the smallest we managed, clearly flagged,\n * lets the caller decide between warning and refusing.\n */\nexport async function runEncodePlan<T>(\n natural: ICropSize,\n max: {width?: number; height?: number; bytes: number},\n format: CropOutputFormat,\n encode: (attempt: IEncodeAttempt) => Promise<{bytes: number; value: T}>,\n): Promise<{value: T; bytes: number; size: ICropSize; quality: number; withinBudget: boolean}> {\n const plan = encodePlan(natural, max, format);\n let best: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n let smallest: {value: T; bytes: number; size: ICropSize; quality: number} | undefined;\n\n // ⚠️ `step` IS A `const` DECLARED INSIDE THE LOOP, AND THAT IS NOT STYLE.\n // Hoisted to a reassigned `let`, TypeScript stops narrowing it across the\n // back edge and `step.value` comes out as `IEncodeAttempt | void` — which\n // then needs a cast, and a cast here would be asserting exactly the thing\n // the `done` check is supposed to prove.\n let outcome: IEncodeOutcome | undefined;\n for (;;) {\n const step = outcome === undefined ? plan.next() : plan.next(outcome);\n if (step.done === true) break;\n\n const attempt = step.value;\n const {bytes, value} = await encode(attempt);\n const record = {value, bytes, size: attempt.size, quality: attempt.quality};\n\n if (bytes <= max.bytes) best = record;\n if (!smallest || bytes < smallest.bytes) smallest = record;\n\n outcome = {bytes};\n }\n\n const chosen = best ?? smallest;\n if (!chosen) {\n throw new Error(\"[ImageCropper] the encode plan produced no attempts at all\");\n }\n return {...chosen, withinBudget: chosen.bytes <= max.bytes};\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -9,7 +9,7 @@ const QUALITY_MAX = 0.92;
|
|
|
9
9
|
const QUALITY_MIN = 0.4;
|
|
10
10
|
const QUALITY_STEPS = 7;
|
|
11
11
|
const DOWNSCALE_FACTOR = 0.8;
|
|
12
|
-
const
|
|
12
|
+
const MIN_DOWNSCALE_EDGE = 16;
|
|
13
13
|
const DEFAULT_LABELS = {
|
|
14
14
|
cancel: "Cancel",
|
|
15
15
|
save: "Save photo",
|
|
@@ -36,8 +36,8 @@ exports.DEFAULT_LABELS = DEFAULT_LABELS;
|
|
|
36
36
|
exports.DEFAULT_MAX_BYTES = DEFAULT_MAX_BYTES;
|
|
37
37
|
exports.DEFAULT_MAX_ZOOM = DEFAULT_MAX_ZOOM;
|
|
38
38
|
exports.DOWNSCALE_FACTOR = DOWNSCALE_FACTOR;
|
|
39
|
-
exports.MAX_DOWNSCALE_PASSES = MAX_DOWNSCALE_PASSES;
|
|
40
39
|
exports.MIN_CROP_SIZE = MIN_CROP_SIZE;
|
|
40
|
+
exports.MIN_DOWNSCALE_EDGE = MIN_DOWNSCALE_EDGE;
|
|
41
41
|
exports.QUALITY_MAX = QUALITY_MAX;
|
|
42
42
|
exports.QUALITY_MIN = QUALITY_MIN;
|
|
43
43
|
exports.QUALITY_STEPS = QUALITY_STEPS;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.cjs","sources":["../../../../src/components/ImageCropper/constants.ts"],"sourcesContent":["import type {CropAspect, CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\n\n/** A square, which is what an avatar wants. */\nexport const DEFAULT_ASPECT: CropAspect = [1, 1];\n\n/**\n * 500 KB.\n *\n * ⚠️ BYTES, NOT \"KB\" — a number named `500` that turns out to mean kilobytes is\n * how an upload limit gets enforced a thousand times too loosely. Every byte\n * ceiling in this component is in bytes, and `maxBytes` says so in its name.\n */\nexport const DEFAULT_MAX_BYTES = 500 * 1024;\n\nexport const DEFAULT_FORMAT: CropOutputFormat = \"jpeg\";\n\n/** Zoom ceiling, as a multiple of the scale that just covers the window. */\nexport const DEFAULT_MAX_ZOOM = 8;\n\n/** How much one wheel notch moves the zoom. */\nexport const WHEEL_ZOOM_STEP = 0.0015;\n\n/** The smallest crop window the grips may produce, in viewport units. */\nexport const MIN_CROP_SIZE = {width: 48, height: 48};\n\n/** Hit size of a resize grip on web, in viewport units. The mockup's `HIT`. */\nexport const HANDLE_HIT_SIZE = 30;\n\n/* ─── Encoder search ──────────────────────────────────────────────────────── */\n\n/**\n * Quality band for the lossy encoders.\n *\n * ⚠️ THE FLOOR IS 0.4, NOT 0.1. Below roughly 0.4 a JPEG of a face stops being\n * a smaller picture and becomes a different, blocky one — and a cropper that\n * silently returns that has not honoured the ceiling, it has destroyed the\n * photo to meet a number. When the floor is not enough the search scales the\n * image DOWN instead, which loses detail evenly rather than in 8×8 squares.\n */\nexport const QUALITY_MAX = 0.92;\nexport const QUALITY_MIN = 0.4;\n\n/** Binary-search steps over the quality band. Seven gets within ~0.004. */\nexport const QUALITY_STEPS = 7;\n\n/** Each downscale pass after the quality floor, as a factor on the long edge. */\nexport const DOWNSCALE_FACTOR = 0.8;\n\n
|
|
1
|
+
{"version":3,"file":"constants.cjs","sources":["../../../../src/components/ImageCropper/constants.ts"],"sourcesContent":["import type {CropAspect, CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\n\n/** A square, which is what an avatar wants. */\nexport const DEFAULT_ASPECT: CropAspect = [1, 1];\n\n/**\n * 500 KB.\n *\n * ⚠️ BYTES, NOT \"KB\" — a number named `500` that turns out to mean kilobytes is\n * how an upload limit gets enforced a thousand times too loosely. Every byte\n * ceiling in this component is in bytes, and `maxBytes` says so in its name.\n */\nexport const DEFAULT_MAX_BYTES = 500 * 1024;\n\nexport const DEFAULT_FORMAT: CropOutputFormat = \"jpeg\";\n\n/** Zoom ceiling, as a multiple of the scale that just covers the window. */\nexport const DEFAULT_MAX_ZOOM = 8;\n\n/** How much one wheel notch moves the zoom. */\nexport const WHEEL_ZOOM_STEP = 0.0015;\n\n/** The smallest crop window the grips may produce, in viewport units. */\nexport const MIN_CROP_SIZE = {width: 48, height: 48};\n\n/** Hit size of a resize grip on web, in viewport units. The mockup's `HIT`. */\nexport const HANDLE_HIT_SIZE = 30;\n\n/* ─── Encoder search ──────────────────────────────────────────────────────── */\n\n/**\n * Quality band for the lossy encoders.\n *\n * ⚠️ THE FLOOR IS 0.4, NOT 0.1. Below roughly 0.4 a JPEG of a face stops being\n * a smaller picture and becomes a different, blocky one — and a cropper that\n * silently returns that has not honoured the ceiling, it has destroyed the\n * photo to meet a number. When the floor is not enough the search scales the\n * image DOWN instead, which loses detail evenly rather than in 8×8 squares.\n */\nexport const QUALITY_MAX = 0.92;\nexport const QUALITY_MIN = 0.4;\n\n/** Binary-search steps over the quality band. Seven gets within ~0.004. */\nexport const QUALITY_STEPS = 7;\n\n/** Each downscale pass after the quality floor, as a factor on the long edge. */\nexport const DOWNSCALE_FACTOR = 0.8;\n\n/**\n * The long edge at which shrinking stops, in pixels.\n *\n * ⛔ A FLOOR ON THE IMAGE, NOT A BUDGET OF ATTEMPTS — AND THE DIFFERENCE IS A\n * FILE THAT MISSES ITS CEILING. This used to be `MAX_DOWNSCALE_PASSES = 6`,\n * which at `DOWNSCALE_FACTOR` is 0.8⁶ ≈ 0.26 of the long edge: an 800×800\n * avatar gave up at 210×210 and a 1600×600 cover at 419×157, reporting\n * `too-large` while still well above a size anything could not be squeezed\n * into. Measured, not reasoned: driving the real plan at 200 bytes/pixel\n * returned 976 KB against a 500 KB ceiling, having simply run out of passes.\n *\n * ⚠️ AND THE CALLER COULD DO NOTHING USEFUL WITH THAT. Both consumers had to\n * invent a policy for a case the library created — one refused the crop, the\n * other uploaded it oversized, and the two products then disagreed about what\n * \"under 500 KB\" meant. Shrinking until it FITS removes the choice rather than\n * arbitrating it.\n *\n * ⚠️ 16px IS SMALL ENOUGH TO BE UNREACHABLE AND LARGE ENOUGH TO TERMINATE.\n * Reaching it from 1600px takes about 21 passes, and no photograph encodes\n * above a ceiling at 16px — so `withinBudget: false` now means a ceiling of a\n * few hundred BYTES, which is a caller error rather than a picture problem.\n */\nexport const MIN_DOWNSCALE_EDGE = 16;\n\n/** The labels the component falls back to when a consumer passes none. */\nexport const DEFAULT_LABELS = {\n cancel: \"Cancel\",\n save: \"Save photo\",\n rotate: \"Rotate\",\n reset: \"Reset\",\n zoom: \"Zoom\",\n preview: \"Preview\",\n dropZone: \"Add a photo\",\n processing: \"Saving…\",\n} as const;\n\n/* ─── The mockup's stage ──────────────────────────────────────────────────────\n Measured off `UML/UI/ClientPortal/AlphaMatch - Image Cropper.dc.html`, which\n is the design source for every number below.\n\n ⚠️ THE STAGE IS A SHAPE, NOT A SIZE. The mockup draws 588×404 on desktop and\n 390×404 on a phone, but a component that hard-codes 588 cannot sit in a\n consumer's column. What carries over is the RATIO; the stage then measures\n itself and the geometry runs against whatever it really got. */\n\n/** 588 ÷ 404 — the desktop stage's shape. */\nexport const STAGE_ASPECT_WEB = 588 / 404;\n\n/** 390 ÷ 404 — the phone stage's shape, very nearly square. */\nexport const STAGE_ASPECT_NATIVE = 390 / 404;\n\n/**\n * The dark margin the crop window is inset by, in viewport units.\n *\n * ⛔ IT IS NOT DECORATION — WITHOUT IT THE CORNER GRIPS ARE HALF OFF-STAGE. A\n * window that fills the viewport puts its eight grips exactly on the boundary,\n * and the stage clips `overflow`, so half of every knob is invisible and the\n * corner hit areas run off the edge. The mockup's padding is what makes them\n * reachable.\n */\nexport const STAGE_INSET_WEB = 30;\nexport const STAGE_INSET_NATIVE = 22;\n\n/**\n * The square window is drawn at 92% of the largest square that fits.\n *\n * ⚠️ SQUARE ONLY, AND THE MOCKUP IS EXPLICIT ABOUT IT: `* (a === 1 ? 0.92 : 1)`.\n * A 1:1 window inset by the padding alone is tall enough to touch both margins\n * and reads as though it is jammed in; a cover at 8:3 is limited by WIDTH and\n * already has air above and below, so shrinking it would only waste stage.\n */\nexport const SQUARE_CROP_FACTOR = 0.92;\n\n/* ─── The mockup's palette ───────────────────────────────────────────────────\n ⚠️ LITERALS, NOT TOKENS, AND ON PURPOSE. The stage is dark in BOTH colour\n modes — a photo has to read against it either way — so these must not invert.\n The themeable surfaces go through `defaultTheme`; what is here is the chrome\n the mockup pins to a specific colour. */\n\n/** The teal every primary affordance on the stage is drawn in. */\nexport const CROP_ACCENT = \"#20AAB0\";\n\n/** The lighter teal for icons and tertiary text on the dark stage. */\nexport const CROP_ACCENT_SOFT = \"#5FD0D5\";\n\n/** The flattened teal a disabled Save wears on web. */\nexport const CROP_ACCENT_MUTED = \"#A7DADD\";\n\n/** Hairlines and button borders on the light chrome. */\nexport const CROP_BORDER = \"#E5E7EB\";\n\n/** The lighter hairline under the header and over the footer. */\nexport const CROP_DIVIDER = \"#F0F1F3\";\n\n/** Body text on the light chrome. */\nexport const CROP_INK = \"#374151\";\n\n/** Secondary text on the light chrome. */\nexport const CROP_INK_SUBTLE = \"#9CA3AF\";\n\n/** The error sentence's colour on the dark stage. */\nexport const CROP_DANGER = \"#FCA5A5\";\n\n/* ─── Grip geometry ─────────────────────────────────────────────────────────\n ⚠️ TWO SHAPES, NOT EIGHT OF ONE. The mockup draws corners as ringed circles\n and edges as short bars, which is also what tells a user which axis a grip\n moves. Eight identical knobs lose that. */\n\n/** Corner knob diameter. */\nexport const HANDLE_KNOB_WEB = 16;\nexport const HANDLE_KNOB_NATIVE = 20;\n\n/** An edge bar's long and short sides. */\nexport const HANDLE_BAR_LONG = 28;\nexport const HANDLE_BAR_SHORT = 7;\n\n/** The ring drawn around a corner knob. */\nexport const HANDLE_RING_WIDTH = 3;\n\n/**\n * A grip's touch target, which is larger than the thing drawn.\n *\n * ⚠️ 44 ON NATIVE BECAUSE A FINGER IS NOT A CURSOR — the mockup's own `HIT`\n * splits the same way, and 30 under a thumb is a grip that cannot be grabbed.\n */\nexport const HANDLE_HIT_SIZE_NATIVE = 44;\n"],"names":[],"mappings":";;AAGO,MAAM,cAAA,GAA6B,CAAC,CAAA,EAAG,CAAC;AASxC,MAAM,oBAAoB,GAAA,GAAM;AAEhC,MAAM,cAAA,GAAmC;AAGzC,MAAM,gBAAA,GAAmB;AAMzB,MAAM,aAAA,GAAgB,EAAC,KAAA,EAAO,EAAA,EAAI,QAAQ,EAAA;AAgB1C,MAAM,WAAA,GAAc;AACpB,MAAM,WAAA,GAAc;AAGpB,MAAM,aAAA,GAAgB;AAGtB,MAAM,gBAAA,GAAmB;AAwBzB,MAAM,kBAAA,GAAqB;AAG3B,MAAM,cAAA,GAAiB;AAAA,EAC1B,MAAA,EAAQ,QAAA;AAAA,EACR,IAAA,EAAM,YAAA;AAAA,EACN,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,OAAA;AAAA,EACP,IAAA,EAAM,MAAA;AAAA,EACN,OAAA,EAAS,SAAA;AAAA,EACT,QAAA,EAAU,aAAA;AAAA,EACV,UAAA,EAAY;AAChB;AAeO,MAAM,sBAAsB,GAAA,GAAM;AAYlC,MAAM,kBAAA,GAAqB;AAU3B,MAAM,kBAAA,GAAqB;AAS3B,MAAM,WAAA,GAAc;AAGpB,MAAM,gBAAA,GAAmB;AAkBzB,MAAM,WAAA,GAAc;;;;;;;;;;;;;;;;;;;;"}
|
|
@@ -7,7 +7,7 @@ const QUALITY_MAX = 0.92;
|
|
|
7
7
|
const QUALITY_MIN = 0.4;
|
|
8
8
|
const QUALITY_STEPS = 7;
|
|
9
9
|
const DOWNSCALE_FACTOR = 0.8;
|
|
10
|
-
const
|
|
10
|
+
const MIN_DOWNSCALE_EDGE = 16;
|
|
11
11
|
const DEFAULT_LABELS = {
|
|
12
12
|
cancel: "Cancel",
|
|
13
13
|
save: "Save photo",
|
|
@@ -25,5 +25,5 @@ const CROP_ACCENT = "#20AAB0";
|
|
|
25
25
|
const CROP_ACCENT_SOFT = "#5FD0D5";
|
|
26
26
|
const CROP_DANGER = "#FCA5A5";
|
|
27
27
|
|
|
28
|
-
export { CROP_ACCENT, CROP_ACCENT_SOFT, CROP_DANGER, DEFAULT_ASPECT, DEFAULT_FORMAT, DEFAULT_LABELS, DEFAULT_MAX_BYTES, DEFAULT_MAX_ZOOM, DOWNSCALE_FACTOR,
|
|
28
|
+
export { CROP_ACCENT, CROP_ACCENT_SOFT, CROP_DANGER, DEFAULT_ASPECT, DEFAULT_FORMAT, DEFAULT_LABELS, DEFAULT_MAX_BYTES, DEFAULT_MAX_ZOOM, DOWNSCALE_FACTOR, MIN_CROP_SIZE, MIN_DOWNSCALE_EDGE, QUALITY_MAX, QUALITY_MIN, QUALITY_STEPS, SQUARE_CROP_FACTOR, STAGE_ASPECT_NATIVE, STAGE_INSET_NATIVE };
|
|
29
29
|
//# sourceMappingURL=constants.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.js","sources":["../../../../src/components/ImageCropper/constants.ts"],"sourcesContent":["import type {CropAspect, CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\n\n/** A square, which is what an avatar wants. */\nexport const DEFAULT_ASPECT: CropAspect = [1, 1];\n\n/**\n * 500 KB.\n *\n * ⚠️ BYTES, NOT \"KB\" — a number named `500` that turns out to mean kilobytes is\n * how an upload limit gets enforced a thousand times too loosely. Every byte\n * ceiling in this component is in bytes, and `maxBytes` says so in its name.\n */\nexport const DEFAULT_MAX_BYTES = 500 * 1024;\n\nexport const DEFAULT_FORMAT: CropOutputFormat = \"jpeg\";\n\n/** Zoom ceiling, as a multiple of the scale that just covers the window. */\nexport const DEFAULT_MAX_ZOOM = 8;\n\n/** How much one wheel notch moves the zoom. */\nexport const WHEEL_ZOOM_STEP = 0.0015;\n\n/** The smallest crop window the grips may produce, in viewport units. */\nexport const MIN_CROP_SIZE = {width: 48, height: 48};\n\n/** Hit size of a resize grip on web, in viewport units. The mockup's `HIT`. */\nexport const HANDLE_HIT_SIZE = 30;\n\n/* ─── Encoder search ──────────────────────────────────────────────────────── */\n\n/**\n * Quality band for the lossy encoders.\n *\n * ⚠️ THE FLOOR IS 0.4, NOT 0.1. Below roughly 0.4 a JPEG of a face stops being\n * a smaller picture and becomes a different, blocky one — and a cropper that\n * silently returns that has not honoured the ceiling, it has destroyed the\n * photo to meet a number. When the floor is not enough the search scales the\n * image DOWN instead, which loses detail evenly rather than in 8×8 squares.\n */\nexport const QUALITY_MAX = 0.92;\nexport const QUALITY_MIN = 0.4;\n\n/** Binary-search steps over the quality band. Seven gets within ~0.004. */\nexport const QUALITY_STEPS = 7;\n\n/** Each downscale pass after the quality floor, as a factor on the long edge. */\nexport const DOWNSCALE_FACTOR = 0.8;\n\n
|
|
1
|
+
{"version":3,"file":"constants.js","sources":["../../../../src/components/ImageCropper/constants.ts"],"sourcesContent":["import type {CropAspect, CropOutputFormat} from \"@lmb-it/kitsconcerto-types\";\n\n/** A square, which is what an avatar wants. */\nexport const DEFAULT_ASPECT: CropAspect = [1, 1];\n\n/**\n * 500 KB.\n *\n * ⚠️ BYTES, NOT \"KB\" — a number named `500` that turns out to mean kilobytes is\n * how an upload limit gets enforced a thousand times too loosely. Every byte\n * ceiling in this component is in bytes, and `maxBytes` says so in its name.\n */\nexport const DEFAULT_MAX_BYTES = 500 * 1024;\n\nexport const DEFAULT_FORMAT: CropOutputFormat = \"jpeg\";\n\n/** Zoom ceiling, as a multiple of the scale that just covers the window. */\nexport const DEFAULT_MAX_ZOOM = 8;\n\n/** How much one wheel notch moves the zoom. */\nexport const WHEEL_ZOOM_STEP = 0.0015;\n\n/** The smallest crop window the grips may produce, in viewport units. */\nexport const MIN_CROP_SIZE = {width: 48, height: 48};\n\n/** Hit size of a resize grip on web, in viewport units. The mockup's `HIT`. */\nexport const HANDLE_HIT_SIZE = 30;\n\n/* ─── Encoder search ──────────────────────────────────────────────────────── */\n\n/**\n * Quality band for the lossy encoders.\n *\n * ⚠️ THE FLOOR IS 0.4, NOT 0.1. Below roughly 0.4 a JPEG of a face stops being\n * a smaller picture and becomes a different, blocky one — and a cropper that\n * silently returns that has not honoured the ceiling, it has destroyed the\n * photo to meet a number. When the floor is not enough the search scales the\n * image DOWN instead, which loses detail evenly rather than in 8×8 squares.\n */\nexport const QUALITY_MAX = 0.92;\nexport const QUALITY_MIN = 0.4;\n\n/** Binary-search steps over the quality band. Seven gets within ~0.004. */\nexport const QUALITY_STEPS = 7;\n\n/** Each downscale pass after the quality floor, as a factor on the long edge. */\nexport const DOWNSCALE_FACTOR = 0.8;\n\n/**\n * The long edge at which shrinking stops, in pixels.\n *\n * ⛔ A FLOOR ON THE IMAGE, NOT A BUDGET OF ATTEMPTS — AND THE DIFFERENCE IS A\n * FILE THAT MISSES ITS CEILING. This used to be `MAX_DOWNSCALE_PASSES = 6`,\n * which at `DOWNSCALE_FACTOR` is 0.8⁶ ≈ 0.26 of the long edge: an 800×800\n * avatar gave up at 210×210 and a 1600×600 cover at 419×157, reporting\n * `too-large` while still well above a size anything could not be squeezed\n * into. Measured, not reasoned: driving the real plan at 200 bytes/pixel\n * returned 976 KB against a 500 KB ceiling, having simply run out of passes.\n *\n * ⚠️ AND THE CALLER COULD DO NOTHING USEFUL WITH THAT. Both consumers had to\n * invent a policy for a case the library created — one refused the crop, the\n * other uploaded it oversized, and the two products then disagreed about what\n * \"under 500 KB\" meant. Shrinking until it FITS removes the choice rather than\n * arbitrating it.\n *\n * ⚠️ 16px IS SMALL ENOUGH TO BE UNREACHABLE AND LARGE ENOUGH TO TERMINATE.\n * Reaching it from 1600px takes about 21 passes, and no photograph encodes\n * above a ceiling at 16px — so `withinBudget: false` now means a ceiling of a\n * few hundred BYTES, which is a caller error rather than a picture problem.\n */\nexport const MIN_DOWNSCALE_EDGE = 16;\n\n/** The labels the component falls back to when a consumer passes none. */\nexport const DEFAULT_LABELS = {\n cancel: \"Cancel\",\n save: \"Save photo\",\n rotate: \"Rotate\",\n reset: \"Reset\",\n zoom: \"Zoom\",\n preview: \"Preview\",\n dropZone: \"Add a photo\",\n processing: \"Saving…\",\n} as const;\n\n/* ─── The mockup's stage ──────────────────────────────────────────────────────\n Measured off `UML/UI/ClientPortal/AlphaMatch - Image Cropper.dc.html`, which\n is the design source for every number below.\n\n ⚠️ THE STAGE IS A SHAPE, NOT A SIZE. The mockup draws 588×404 on desktop and\n 390×404 on a phone, but a component that hard-codes 588 cannot sit in a\n consumer's column. What carries over is the RATIO; the stage then measures\n itself and the geometry runs against whatever it really got. */\n\n/** 588 ÷ 404 — the desktop stage's shape. */\nexport const STAGE_ASPECT_WEB = 588 / 404;\n\n/** 390 ÷ 404 — the phone stage's shape, very nearly square. */\nexport const STAGE_ASPECT_NATIVE = 390 / 404;\n\n/**\n * The dark margin the crop window is inset by, in viewport units.\n *\n * ⛔ IT IS NOT DECORATION — WITHOUT IT THE CORNER GRIPS ARE HALF OFF-STAGE. A\n * window that fills the viewport puts its eight grips exactly on the boundary,\n * and the stage clips `overflow`, so half of every knob is invisible and the\n * corner hit areas run off the edge. The mockup's padding is what makes them\n * reachable.\n */\nexport const STAGE_INSET_WEB = 30;\nexport const STAGE_INSET_NATIVE = 22;\n\n/**\n * The square window is drawn at 92% of the largest square that fits.\n *\n * ⚠️ SQUARE ONLY, AND THE MOCKUP IS EXPLICIT ABOUT IT: `* (a === 1 ? 0.92 : 1)`.\n * A 1:1 window inset by the padding alone is tall enough to touch both margins\n * and reads as though it is jammed in; a cover at 8:3 is limited by WIDTH and\n * already has air above and below, so shrinking it would only waste stage.\n */\nexport const SQUARE_CROP_FACTOR = 0.92;\n\n/* ─── The mockup's palette ───────────────────────────────────────────────────\n ⚠️ LITERALS, NOT TOKENS, AND ON PURPOSE. The stage is dark in BOTH colour\n modes — a photo has to read against it either way — so these must not invert.\n The themeable surfaces go through `defaultTheme`; what is here is the chrome\n the mockup pins to a specific colour. */\n\n/** The teal every primary affordance on the stage is drawn in. */\nexport const CROP_ACCENT = \"#20AAB0\";\n\n/** The lighter teal for icons and tertiary text on the dark stage. */\nexport const CROP_ACCENT_SOFT = \"#5FD0D5\";\n\n/** The flattened teal a disabled Save wears on web. */\nexport const CROP_ACCENT_MUTED = \"#A7DADD\";\n\n/** Hairlines and button borders on the light chrome. */\nexport const CROP_BORDER = \"#E5E7EB\";\n\n/** The lighter hairline under the header and over the footer. */\nexport const CROP_DIVIDER = \"#F0F1F3\";\n\n/** Body text on the light chrome. */\nexport const CROP_INK = \"#374151\";\n\n/** Secondary text on the light chrome. */\nexport const CROP_INK_SUBTLE = \"#9CA3AF\";\n\n/** The error sentence's colour on the dark stage. */\nexport const CROP_DANGER = \"#FCA5A5\";\n\n/* ─── Grip geometry ─────────────────────────────────────────────────────────\n ⚠️ TWO SHAPES, NOT EIGHT OF ONE. The mockup draws corners as ringed circles\n and edges as short bars, which is also what tells a user which axis a grip\n moves. Eight identical knobs lose that. */\n\n/** Corner knob diameter. */\nexport const HANDLE_KNOB_WEB = 16;\nexport const HANDLE_KNOB_NATIVE = 20;\n\n/** An edge bar's long and short sides. */\nexport const HANDLE_BAR_LONG = 28;\nexport const HANDLE_BAR_SHORT = 7;\n\n/** The ring drawn around a corner knob. */\nexport const HANDLE_RING_WIDTH = 3;\n\n/**\n * A grip's touch target, which is larger than the thing drawn.\n *\n * ⚠️ 44 ON NATIVE BECAUSE A FINGER IS NOT A CURSOR — the mockup's own `HIT`\n * splits the same way, and 30 under a thumb is a grip that cannot be grabbed.\n */\nexport const HANDLE_HIT_SIZE_NATIVE = 44;\n"],"names":[],"mappings":"AAGO,MAAM,cAAA,GAA6B,CAAC,CAAA,EAAG,CAAC;AASxC,MAAM,oBAAoB,GAAA,GAAM;AAEhC,MAAM,cAAA,GAAmC;AAGzC,MAAM,gBAAA,GAAmB;AAMzB,MAAM,aAAA,GAAgB,EAAC,KAAA,EAAO,EAAA,EAAI,QAAQ,EAAA;AAgB1C,MAAM,WAAA,GAAc;AACpB,MAAM,WAAA,GAAc;AAGpB,MAAM,aAAA,GAAgB;AAGtB,MAAM,gBAAA,GAAmB;AAwBzB,MAAM,kBAAA,GAAqB;AAG3B,MAAM,cAAA,GAAiB;AAAA,EAC1B,MAAA,EAAQ,QAAA;AAAA,EACR,IAAA,EAAM,YAAA;AAAA,EACN,MAAA,EAAQ,QAAA;AAAA,EACR,KAAA,EAAO,OAAA;AAAA,EACP,IAAA,EAAM,MAAA;AAAA,EACN,OAAA,EAAS,SAAA;AAAA,EACT,QAAA,EAAU,aAAA;AAAA,EACV,UAAA,EAAY;AAChB;AAeO,MAAM,sBAAsB,GAAA,GAAM;AAYlC,MAAM,kBAAA,GAAqB;AAU3B,MAAM,kBAAA,GAAqB;AAS3B,MAAM,WAAA,GAAc;AAGpB,MAAM,gBAAA,GAAmB;AAkBzB,MAAM,WAAA,GAAc;;;;"}
|