Skip to content

Commit deb68e4

Browse files
committed
fix(module-editor): refine panel crop workflow
1 parent fd39731 commit deb68e4

6 files changed

Lines changed: 692 additions & 36 deletions

File tree

src/app/components/module-parts/module-editor/module-editor-data.service.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,87 @@ describe('ModuleEditorDataService', () => {
109109
expect(result.type).toBe('image/png');
110110
});
111111
});
112+
113+
describe('suggestPanelTypeFromBlob', () => {
114+
const imageBitmapTarget = window as Window & {createImageBitmap?: (blob: Blob) => Promise<unknown>};
115+
let previousCreateImageBitmap: typeof imageBitmapTarget.createImageBitmap;
116+
let createImageBitmapSpy: jasmine.Spy;
117+
let drawImageSpy: jasmine.Spy;
118+
let getImageDataSpy: jasmine.Spy;
119+
let closeSpy: jasmine.Spy;
120+
121+
beforeEach(() => {
122+
drawImageSpy = jasmine.createSpy('drawImage');
123+
getImageDataSpy = jasmine.createSpy('getImageData');
124+
closeSpy = jasmine.createSpy('close');
125+
previousCreateImageBitmap = imageBitmapTarget.createImageBitmap;
126+
createImageBitmapSpy = jasmine.createSpy('createImageBitmap');
127+
imageBitmapTarget.createImageBitmap = createImageBitmapSpy as any;
128+
const nativeCreateElement = document.createElement.bind(document);
129+
spyOn(document, 'createElement').and.callFake((tagName: string) => {
130+
if (tagName === 'canvas') {
131+
return {
132+
width: 0,
133+
height: 0,
134+
getContext: () => ({
135+
drawImage: drawImageSpy,
136+
getImageData: getImageDataSpy
137+
})
138+
} as any;
139+
}
140+
return nativeCreateElement(tagName);
141+
});
142+
});
143+
144+
afterEach(() => {
145+
imageBitmapTarget.createImageBitmap = previousCreateImageBitmap;
146+
});
147+
148+
it('suggests Light for bright desaturated panels', async () => {
149+
createImageBitmapSpy.and.resolveTo({width: 4, height: 4, close: closeSpy} as any);
150+
getImageDataSpy.and.returnValue({
151+
data: new Uint8ClampedArray([
152+
240, 240, 240, 255, 245, 245, 245, 255, 235, 235, 235, 255, 238, 238, 238, 255
153+
])
154+
});
155+
156+
await expectAsync(service.suggestPanelTypeFromBlob(new Blob(['x'], {type: 'image/jpeg'}))).toBeResolvedTo(1);
157+
});
158+
159+
it('suggests Dark for dark desaturated panels', async () => {
160+
createImageBitmapSpy.and.resolveTo({width: 4, height: 4, close: closeSpy} as any);
161+
getImageDataSpy.and.returnValue({
162+
data: new Uint8ClampedArray([
163+
20, 20, 20, 255, 32, 32, 32, 255, 40, 40, 40, 255, 28, 28, 28, 255
164+
])
165+
});
166+
167+
await expectAsync(service.suggestPanelTypeFromBlob(new Blob(['x'], {type: 'image/jpeg'}))).toBeResolvedTo(2);
168+
});
169+
170+
it('suggests Special edition for colorful panels', async () => {
171+
createImageBitmapSpy.and.resolveTo({width: 4, height: 4, close: closeSpy} as any);
172+
getImageDataSpy.and.returnValue({
173+
data: new Uint8ClampedArray([
174+
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 180, 0, 255
175+
])
176+
});
177+
178+
await expectAsync(service.suggestPanelTypeFromBlob(new Blob(['x'], {type: 'image/jpeg'}))).toBeResolvedTo(3);
179+
});
180+
181+
it('downsamples oversized images before reading pixels', async () => {
182+
createImageBitmapSpy.and.resolveTo({width: 1200, height: 600, close: closeSpy} as any);
183+
getImageDataSpy.and.returnValue({
184+
data: new Uint8ClampedArray([240, 240, 240, 255])
185+
});
186+
187+
await service.suggestPanelTypeFromBlob(new Blob(['x'], {type: 'image/jpeg'}));
188+
189+
expect(drawImageSpy).toHaveBeenCalledWith(jasmine.anything(), 0, 0, 192, 96);
190+
expect(getImageDataSpy).toHaveBeenCalledWith(0, 0, 192, 96);
191+
});
192+
});
112193

113194
describe('createFormCV', () => {
114195
it('uses empty string when name is undefined', () => {

src/app/components/module-parts/module-editor/module-editor-data.service.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,20 @@ export interface PendingSaveState {
4444
hasPendingChanges: boolean;
4545
}
4646

47+
interface PanelAppearanceMetrics {
48+
averageLuminance: number;
49+
averageSaturation: number;
50+
colorfulPixelRatio: number;
51+
}
52+
53+
type DecodedPanelImage = ImageBitmap | HTMLImageElement;
54+
55+
const PANEL_ANALYSIS_MAX_EDGE = 192;
56+
const COLORFUL_PIXEL_SATURATION_THRESHOLD = 0.22;
57+
const SPECIAL_EDITION_COLORFUL_PIXEL_RATIO_THRESHOLD = 0.18;
58+
const SPECIAL_EDITION_AVERAGE_SATURATION_THRESHOLD = 0.16;
59+
const DARK_PANEL_LUMINANCE_THRESHOLD = 0.45;
60+
4761
interface BuildPersistPlanArgs {
4862
module: DbModule;
4963
pendingState: PendingSaveState;
@@ -64,6 +78,12 @@ interface BuildPersistPlanArgs {
6478
export class ModuleEditorDataService {
6579
constructor(private readonly backend: SupabaseService) {}
6680

81+
async suggestPanelTypeFromBlob(blob: Blob): Promise<number> {
82+
const imageData = await this.readImageDataFromBlob(blob);
83+
const metrics = this.measurePanelAppearance(imageData.data);
84+
return this.classifyPanelType(metrics);
85+
}
86+
6787
buildCroppedPanelFile(sourceFile: File, blob: Blob): File {
6888
const fileType = blob.type || sourceFile.type || 'image/jpeg';
6989
const extension = this.fileExtensionFromType(fileType) || this.fileExtensionFromName(sourceFile.name) || 'jpg';
@@ -332,6 +352,125 @@ export class ModuleEditorDataService {
332352
return (str || '').replace(/[^a-z0-9]/gi, '_');
333353
}
334354

355+
private async readImageDataFromBlob(blob: Blob): Promise<ImageData> {
356+
const image = await this.decodePanelImage(blob);
357+
const canvas = document.createElement('canvas');
358+
const dimensions = this.getPanelAnalysisDimensions(image.width, image.height);
359+
canvas.width = dimensions.width;
360+
canvas.height = dimensions.height;
361+
const context = canvas.getContext('2d');
362+
363+
if (!context) {
364+
this.releaseDecodedPanelImage(image);
365+
throw new Error('Could not prepare panel image analysis.');
366+
}
367+
368+
context.drawImage(image, 0, 0, dimensions.width, dimensions.height);
369+
this.releaseDecodedPanelImage(image);
370+
return context.getImageData(0, 0, canvas.width, canvas.height);
371+
}
372+
373+
private measurePanelAppearance(data: Uint8ClampedArray): PanelAppearanceMetrics {
374+
let totalLuminance = 0;
375+
let totalSaturation = 0;
376+
let colorfulPixels = 0;
377+
let opaquePixels = 0;
378+
379+
for (let i = 0; i < data.length; i += 16) {
380+
const alpha = data[i + 3] / 255;
381+
if (alpha < 0.5) {
382+
continue;
383+
}
384+
385+
const red = data[i] / 255;
386+
const green = data[i + 1] / 255;
387+
const blue = data[i + 2] / 255;
388+
const max = Math.max(red, green, blue);
389+
const min = Math.min(red, green, blue);
390+
const luminance = (0.2126 * red) + (0.7152 * green) + (0.0722 * blue);
391+
const saturation = max === 0 ? 0 : (max - min) / max;
392+
393+
totalLuminance += luminance;
394+
totalSaturation += saturation;
395+
if (saturation > COLORFUL_PIXEL_SATURATION_THRESHOLD) {
396+
colorfulPixels++;
397+
}
398+
opaquePixels++;
399+
}
400+
401+
if (opaquePixels === 0) {
402+
return {
403+
averageLuminance: 1,
404+
averageSaturation: 0,
405+
colorfulPixelRatio: 0
406+
};
407+
}
408+
409+
return {
410+
averageLuminance: totalLuminance / opaquePixels,
411+
averageSaturation: totalSaturation / opaquePixels,
412+
colorfulPixelRatio: colorfulPixels / opaquePixels
413+
};
414+
}
415+
416+
private classifyPanelType(metrics: PanelAppearanceMetrics): number {
417+
if (
418+
metrics.colorfulPixelRatio > SPECIAL_EDITION_COLORFUL_PIXEL_RATIO_THRESHOLD
419+
|| metrics.averageSaturation > SPECIAL_EDITION_AVERAGE_SATURATION_THRESHOLD
420+
) {
421+
return 3;
422+
}
423+
424+
return metrics.averageLuminance < DARK_PANEL_LUMINANCE_THRESHOLD ? 2 : 1;
425+
}
426+
427+
private async decodePanelImage(blob: Blob): Promise<DecodedPanelImage> {
428+
if (typeof createImageBitmap === 'function') {
429+
return createImageBitmap(blob);
430+
}
431+
432+
const objectUrl = URL.createObjectURL(blob);
433+
try {
434+
return await this.loadImageElement(objectUrl);
435+
} catch {
436+
URL.revokeObjectURL(objectUrl);
437+
throw new Error('Could not decode panel image locally.');
438+
}
439+
}
440+
441+
private loadImageElement(objectUrl: string): Promise<HTMLImageElement> {
442+
return new Promise((resolve, reject) => {
443+
const image = new Image();
444+
image.onload = () => {
445+
URL.revokeObjectURL(objectUrl);
446+
resolve(image);
447+
};
448+
image.onerror = () => {
449+
reject(new Error('Could not decode panel image locally.'));
450+
};
451+
image.src = objectUrl;
452+
});
453+
}
454+
455+
private releaseDecodedPanelImage(image: DecodedPanelImage): void {
456+
if ('close' in image && typeof image.close === 'function') {
457+
image.close();
458+
}
459+
}
460+
461+
private getPanelAnalysisDimensions(width: number, height: number): {width: number; height: number} {
462+
const maxEdge = Math.max(width, height);
463+
if (maxEdge <= PANEL_ANALYSIS_MAX_EDGE) {
464+
return {width, height};
465+
}
466+
467+
const scale = PANEL_ANALYSIS_MAX_EDGE / maxEdge;
468+
return {
469+
width: Math.max(1, Math.round(width * scale)),
470+
height: Math.max(1, Math.round(height * scale))
471+
};
472+
}
473+
335474
private fileExtensionFromName(filename: string | undefined): string {
336475
if (!filename || !filename.includes('.')) {
337476
return '';

src/app/components/module-parts/module-editor/module-editor.component.html

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@
5050
}; as cropBag) {
5151
@if (cropBag.selectedPanelFile) {
5252
<section class="setup-panel-crop col gap1"
53+
[class.setup-panel-crop--loading]="cropBag.cropLoading"
54+
[class.setup-panel-crop--ready]="!!cropBag.croppedPanelFile"
5355
aria-label="Panel crop workspace"
54-
>
56+
>
5557
<div class="setup-panel-crop-copy">
5658
<mat-card-subtitle>Adjust the crop locally. Only the cropped image preview below will be uploaded.</mat-card-subtitle>
5759
</div>
@@ -60,13 +62,13 @@
6062
<div class="setup-panel-crop-actions">
6163
<button type="button"
6264
mat-stroked-button
63-
(click)="fitPanelImage()"
65+
(click)="fillPanelImage()"
6466
>
6567
Fit
6668
</button>
6769
<button type="button"
6870
mat-stroked-button
69-
(click)="fillPanelImage()"
71+
(click)="fitPanelImage()"
7072
>
7173
Fill
7274
</button>
@@ -133,7 +135,7 @@
133135
[imageAltText]="'Selected panel image'"
134136
[cropperFrameAriaLabel]="'Panel crop selection'"
135137
[initialStepSize]="1"
136-
[transform]="panelCropTransform"
138+
[cropper]="panelCropOverride"
137139
(imageCropped)="onPanelImageCropped($event)"
138140
(imageLoaded)="onPanelImageLoaded()"
139141
(cropperChange)="onPanelCropperChange($event)"
@@ -154,7 +156,9 @@
154156
}
155157
</div>
156158

157-
<div class="setup-panel-preview">
159+
<div class="setup-panel-preview"
160+
[class.setup-panel-preview--ready]="!!cropBag.croppedPanelFile"
161+
>
158162
<div class="setup-panel-preview-header">
159163
<span class="setup-panel-preview-label">Final preview</span>
160164
@if (cropBag.croppedPanelFile) {
@@ -182,10 +186,14 @@
182186
}
183187

184188
<div class="col gap1 setup-field-stack setup-panel-fields">
185-
<lib-mat-form-entity
186-
[dataPack]="panelType"
187-
[disableVoidSelection]="true"
188-
></lib-mat-form-entity>
189+
<div class="setup-panel-type-shell"
190+
[class.setup-panel-type-shell--auto-selected]="panelTypeAutoSelectionCue$ | async"
191+
>
192+
<lib-mat-form-entity
193+
[dataPack]="panelType"
194+
[disableVoidSelection]="true"
195+
></lib-mat-form-entity>
196+
</div>
189197

190198
<lib-mat-form-entity [dataPack]="panelDescription"></lib-mat-form-entity>
191199
</div>

0 commit comments

Comments
 (0)