11import { resolveGameImageConventionPaths , resolveRuntimeAssetUrl } from "./gameImageConvention.js" ;
22
33const TRANSPARENT_ALPHA_THRESHOLD = 8 ;
4+ const DEFAULT_BEZEL_STRETCH_OVERRIDE_FILENAME = "bezel.stretch.override.json" ;
5+ const DEFAULT_BEZEL_STRETCH_CONFIG = Object . freeze ( {
6+ uniformEdgeStretchPx : 0
7+ } ) ;
48
59function toDisplayValue ( visible ) {
610 return visible ? "block" : "none" ;
@@ -96,6 +100,103 @@ function toPixel(value) {
96100 return `${ rounded } px` ;
97101}
98102
103+ function normalizePath ( value ) {
104+ return typeof value === "string" ? value . replace ( / \\ / g, "/" ) : "" ;
105+ }
106+
107+ function safeNumber ( value , fallback = 0 ) {
108+ const parsed = Number ( value ) ;
109+ return Number . isFinite ( parsed ) ? parsed : fallback ;
110+ }
111+
112+ export function sanitizeUniformEdgeStretchPx ( value ) {
113+ return Math . max ( 0 , safeNumber ( value , 0 ) ) ;
114+ }
115+
116+ export function resolveBezelStretchConfigPath ( bezelPath , fileName = DEFAULT_BEZEL_STRETCH_OVERRIDE_FILENAME ) {
117+ const normalized = normalizePath ( bezelPath ) . trim ( ) ;
118+ if ( ! normalized ) {
119+ return "" ;
120+ }
121+ const slashIndex = normalized . lastIndexOf ( "/" ) ;
122+ if ( slashIndex < 0 ) {
123+ return fileName ;
124+ }
125+ return `${ normalized . slice ( 0 , slashIndex + 1 ) } ${ fileName } ` ;
126+ }
127+
128+ function parseStretchConfigObject ( candidate ) {
129+ const source = candidate && typeof candidate === "object" ? candidate : { } ;
130+ return {
131+ uniformEdgeStretchPx : sanitizeUniformEdgeStretchPx ( source . uniformEdgeStretchPx )
132+ } ;
133+ }
134+
135+ function isNodeRuntime ( ) {
136+ return typeof process !== "undefined"
137+ && ! ! process ?. versions ?. node ;
138+ }
139+
140+ export async function ensureBezelStretchConfigFile ( configPath , options = { } ) {
141+ const normalizedPath = normalizePath ( configPath ) . trim ( ) ;
142+ if ( ! normalizedPath || ! isNodeRuntime ( ) ) {
143+ return { ...DEFAULT_BEZEL_STRETCH_CONFIG } ;
144+ }
145+
146+ const cwd = typeof options . cwd === "string" && options . cwd . trim ( )
147+ ? options . cwd
148+ : process . cwd ( ) ;
149+ const fsModule = options . fsModule || await import ( "node:fs/promises" ) ;
150+ const pathModule = options . pathModule || await import ( "node:path" ) ;
151+ const absolutePath = pathModule . resolve ( cwd , normalizedPath ) ;
152+ const directoryPath = pathModule . dirname ( absolutePath ) ;
153+ const defaultContent = `${ JSON . stringify ( DEFAULT_BEZEL_STRETCH_CONFIG , null , 2 ) } \n` ;
154+
155+ try {
156+ await fsModule . access ( absolutePath ) ;
157+ } catch {
158+ await fsModule . mkdir ( directoryPath , { recursive : true } ) ;
159+ await fsModule . writeFile ( absolutePath , defaultContent , "utf8" ) ;
160+ return { ...DEFAULT_BEZEL_STRETCH_CONFIG } ;
161+ }
162+
163+ try {
164+ const content = await fsModule . readFile ( absolutePath , "utf8" ) ;
165+ const parsed = JSON . parse ( content ) ;
166+ return parseStretchConfigObject ( parsed ) ;
167+ } catch {
168+ return { ...DEFAULT_BEZEL_STRETCH_CONFIG } ;
169+ }
170+ }
171+
172+ export async function loadBezelStretchConfig ( configPath , options = { } ) {
173+ const normalizedPath = normalizePath ( configPath ) . trim ( ) ;
174+ if ( ! normalizedPath ) {
175+ return { ...DEFAULT_BEZEL_STRETCH_CONFIG } ;
176+ }
177+
178+ if ( isNodeRuntime ( ) ) {
179+ return ensureBezelStretchConfigFile ( normalizedPath , options ) ;
180+ }
181+
182+ const fetchImpl = options . fetchImpl || globalThis . fetch ;
183+ if ( typeof fetchImpl !== "function" ) {
184+ return { ...DEFAULT_BEZEL_STRETCH_CONFIG } ;
185+ }
186+
187+ try {
188+ const requestPath = normalizedPath . startsWith ( "/" ) ? normalizedPath : `/${ normalizedPath } ` ;
189+ const response = await fetchImpl ( requestPath , { cache : "no-store" } ) ;
190+ if ( ! response || response . ok !== true ) {
191+ return { ...DEFAULT_BEZEL_STRETCH_CONFIG } ;
192+ }
193+ const parsed = await response . json ( ) ;
194+ return parseStretchConfigObject ( parsed ) ;
195+ } catch {
196+ return { ...DEFAULT_BEZEL_STRETCH_CONFIG } ;
197+ }
198+ }
199+
99200function sanitizeRect ( candidate ) {
100201 if ( ! candidate || typeof candidate !== "object" ) {
101202 return null ;
@@ -112,6 +213,45 @@ function sanitizeRect(candidate) {
112213 return { x, y, width, height } ;
113214}
114215
216+ function expandRectWithinBounds ( rect , expandPx , bounds ) {
217+ const source = sanitizeRect ( rect ) ;
218+ if ( ! source || ! bounds || typeof bounds !== "object" ) {
219+ return source ;
220+ }
221+
222+ const stretch = sanitizeUniformEdgeStretchPx ( expandPx ) ;
223+ if ( stretch <= 0 ) {
224+ return source ;
225+ }
226+
227+ const boundX = safeNumber ( bounds . x , 0 ) ;
228+ const boundY = safeNumber ( bounds . y , 0 ) ;
229+ const boundWidth = toPositiveNumber ( bounds . width ) ;
230+ const boundHeight = toPositiveNumber ( bounds . height ) ;
231+ if ( boundWidth <= 0 || boundHeight <= 0 ) {
232+ return source ;
233+ }
234+
235+ const boundRight = boundX + boundWidth ;
236+ const boundBottom = boundY + boundHeight ;
237+ const expandedLeft = Math . max ( boundX , source . x - stretch ) ;
238+ const expandedTop = Math . max ( boundY , source . y - stretch ) ;
239+ const expandedRight = Math . min ( boundRight , source . x + source . width + stretch ) ;
240+ const expandedBottom = Math . min ( boundBottom , source . y + source . height + stretch ) ;
241+ const expandedWidth = Math . max ( 0 , expandedRight - expandedLeft ) ;
242+ const expandedHeight = Math . max ( 0 , expandedBottom - expandedTop ) ;
243+ if ( expandedWidth <= 0 || expandedHeight <= 0 ) {
244+ return source ;
245+ }
246+
247+ return {
248+ x : expandedLeft ,
249+ y : expandedTop ,
250+ width : expandedWidth ,
251+ height : expandedHeight
252+ } ;
253+ }
254+
115255function getHostBounds ( host ) {
116256 if ( ! host ) {
117257 return null ;
@@ -399,11 +539,17 @@ export default class fullscreenBezel {
399539 this . defaultHost = options . host || null ;
400540 this . gameId = resolved . gameId ;
401541 this . path = resolved . bezelPath ;
542+ this . stretchConfigPath = resolveBezelStretchConfigPath ( this . path ) ;
402543 this . host = null ;
403544 this . element = null ;
404545 this . ready = false ;
405546 this . missing = ! this . path ;
406547 this . alphaInspector = typeof options . alphaInspector === "function" ? options . alphaInspector : null ;
548+ this . stretchConfigProvider = typeof options . stretchConfigProvider === "function"
549+ ? options . stretchConfigProvider
550+ : loadBezelStretchConfig ;
551+ this . stretchConfigPromise = null ;
552+ this . uniformEdgeStretchPx = 0 ;
407553 this . transparentWindowRect = null ;
408554 this . imageSize = null ;
409555 this . canvasLayoutMode = "fallback" ;
@@ -417,7 +563,9 @@ export default class fullscreenBezel {
417563 visible : this . element ?. style ?. display === "block" ,
418564 hostTagName : this . host ?. tagName || "" ,
419565 canvasLayoutMode : this . canvasLayoutMode ,
420- transparentWindowRect : this . transparentWindowRect
566+ transparentWindowRect : this . transparentWindowRect ,
567+ stretchConfigPath : this . stretchConfigPath ,
568+ uniformEdgeStretchPx : this . uniformEdgeStretchPx
421569 } ;
422570 }
423571
@@ -454,6 +602,51 @@ export default class fullscreenBezel {
454602 return true ;
455603 }
456604
605+ applyStretchConfig ( config ) {
606+ const parsed = parseStretchConfigObject ( config ) ;
607+ this . uniformEdgeStretchPx = parsed . uniformEdgeStretchPx ;
608+ return parsed ;
609+ }
610+
611+ refreshStretchConfig ( ) {
612+ if ( ! this . stretchConfigPath || typeof this . stretchConfigProvider !== "function" ) {
613+ this . applyStretchConfig ( DEFAULT_BEZEL_STRETCH_CONFIG ) ;
614+ return null ;
615+ }
616+
617+ try {
618+ const result = this . stretchConfigProvider ( this . stretchConfigPath , {
619+ bezelPath : this . path ,
620+ gameId : this . gameId ,
621+ defaultConfig : { ...DEFAULT_BEZEL_STRETCH_CONFIG }
622+ } ) ;
623+ if ( result && typeof result . then === "function" ) {
624+ const pending = Promise . resolve ( result )
625+ . then ( ( config ) => {
626+ const parsed = this . applyStretchConfig ( config ) ;
627+ if ( this . element ?. style ?. display === "block" ) {
628+ const fitted = this . applyCanvasWindowFitLayout ( ) ;
629+ if ( ! fitted ) {
630+ this . applyCanvasFallbackLayout ( ) ;
631+ }
632+ }
633+ return parsed ;
634+ } )
635+ . catch ( ( ) => this . applyStretchConfig ( DEFAULT_BEZEL_STRETCH_CONFIG ) ) ;
636+ this . stretchConfigPromise = pending ;
637+ return pending ;
638+ }
639+
640+ this . applyStretchConfig ( result ) ;
641+ this . stretchConfigPromise = Promise . resolve ( { uniformEdgeStretchPx : this . uniformEdgeStretchPx } ) ;
642+ return this . stretchConfigPromise ;
643+ } catch {
644+ this . applyStretchConfig ( DEFAULT_BEZEL_STRETCH_CONFIG ) ;
645+ this . stretchConfigPromise = Promise . resolve ( { uniformEdgeStretchPx : this . uniformEdgeStretchPx } ) ;
646+ return this . stretchConfigPromise ;
647+ }
648+ }
649+
457650 attach ( ) {
458651 if ( ! this . documentRef || ! this . path || this . element ) {
459652 return ;
@@ -475,6 +668,7 @@ export default class fullscreenBezel {
475668 this . transparentWindowRect = this . imageSize
476669 ? inspectTransparentWindowRect ( element , this . documentRef , this . alphaInspector , this . imageSize )
477670 : null ;
671+ this . refreshStretchConfig ( ) ;
478672 this . ready = true ;
479673 this . missing = false ;
480674 } ;
@@ -553,17 +747,34 @@ export default class fullscreenBezel {
553747 return false ;
554748 }
555749
556- const mappedWindowWidth = this . transparentWindowRect . width * containPlacement . scale ;
557- const mappedWindowHeight = this . transparentWindowRect . height * containPlacement . scale ;
558- const fittedCanvas = chooseBestOpeningFit ( sourceWidth , sourceHeight , mappedWindowWidth , mappedWindowHeight ) ;
750+ const mappedWindow = {
751+ x : containPlacement . x + ( this . transparentWindowRect . x * containPlacement . scale ) ,
752+ y : containPlacement . y + ( this . transparentWindowRect . y * containPlacement . scale ) ,
753+ width : this . transparentWindowRect . width * containPlacement . scale ,
754+ height : this . transparentWindowRect . height * containPlacement . scale
755+ } ;
756+ const stretchedWindow = expandRectWithinBounds ( mappedWindow , this . uniformEdgeStretchPx , {
757+ x : containPlacement . x ,
758+ y : containPlacement . y ,
759+ width : containPlacement . width ,
760+ height : containPlacement . height
761+ } ) ;
762+ const fittedCanvas = chooseBestOpeningFit (
763+ sourceWidth ,
764+ sourceHeight ,
765+ stretchedWindow ?. width || 0 ,
766+ stretchedWindow ?. height || 0
767+ ) ;
559768 if ( ! fittedCanvas ) {
560769 return false ;
561770 }
562771
563- const mappedWindowX = containPlacement . x + ( this . transparentWindowRect . x * containPlacement . scale ) ;
564- const mappedWindowY = containPlacement . y + ( this . transparentWindowRect . y * containPlacement . scale ) ;
565- const left = mappedWindowX + ( ( mappedWindowWidth - fittedCanvas . width ) * 0.5 ) ;
566- const top = mappedWindowY + ( ( mappedWindowHeight - fittedCanvas . height ) * 0.5 ) ;
772+ const windowX = stretchedWindow ?. x || 0 ;
773+ const windowY = stretchedWindow ?. y || 0 ;
774+ const windowWidth = stretchedWindow ?. width || 0 ;
775+ const windowHeight = stretchedWindow ?. height || 0 ;
776+ const left = windowX + ( ( windowWidth - fittedCanvas . width ) * 0.5 ) ;
777+ const top = windowY + ( ( windowHeight - fittedCanvas . height ) * 0.5 ) ;
567778
568779 this . canvas . style . position = "absolute" ;
569780 this . canvas . style . left = toPixel ( left ) ;
0 commit comments