@@ -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+
4761interface BuildPersistPlanArgs {
4862 module : DbModule ;
4963 pendingState : PendingSaveState ;
@@ -64,6 +78,12 @@ interface BuildPersistPlanArgs {
6478export 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 - z 0 - 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 '' ;
0 commit comments