@@ -210,6 +210,10 @@ export async function createCompositionRenderer(
210210 // Lazily created from the effects pipeline's GPU device
211211 let gpuCompositor : CompositorPipeline | null = null ;
212212 let gpuMaskManager : MaskTextureManager | null = null ;
213+ let gpuCompositeCanvas : OffscreenCanvas | null = null ;
214+ let gpuCompositeCtx : GPUCanvasContext | null = null ;
215+ let gpuCompositeW = 0 ;
216+ let gpuCompositeH = 0 ;
213217
214218 function ensureGpuCompositor ( ) : boolean {
215219 if ( gpuCompositor ) return true ;
@@ -220,6 +224,35 @@ export async function createCompositionRenderer(
220224 return true ;
221225 }
222226
227+ function ensureGpuCompositeOutput (
228+ width : number ,
229+ height : number ,
230+ ) : { canvas : OffscreenCanvas ; ctx : GPUCanvasContext } | null {
231+ if ( ! gpuPipeline ) return null ;
232+
233+ if ( ! gpuCompositeCanvas ) {
234+ gpuCompositeCanvas = new OffscreenCanvas ( width , height ) ;
235+ }
236+
237+ if ( ! gpuCompositeCtx || gpuCompositeW !== width || gpuCompositeH !== height ) {
238+ if ( gpuCompositeCanvas . width !== width || gpuCompositeCanvas . height !== height ) {
239+ gpuCompositeCanvas . width = width ;
240+ gpuCompositeCanvas . height = height ;
241+ }
242+ gpuCompositeCtx = gpuPipeline . configureCanvas ( gpuCompositeCanvas ) ;
243+ if ( ! gpuCompositeCtx ) {
244+ gpuCompositeCanvas = null ;
245+ gpuCompositeW = 0 ;
246+ gpuCompositeH = 0 ;
247+ return null ;
248+ }
249+ gpuCompositeW = width ;
250+ gpuCompositeH = height ;
251+ }
252+
253+ return { canvas : gpuCompositeCanvas , ctx : gpuCompositeCtx } ;
254+ }
255+
223256 // Build lookup maps
224257 const keyframesMap = buildKeyframesMap ( keyframes ) ;
225258
@@ -1300,6 +1333,7 @@ export async function createCompositionRenderer(
13001333 // Render tracks in order (bottom to top), with transitions at their track position
13011334 // Track order: higher values render first (behind), lower values render last (on top)
13021335 let skippedTracks = 0 ;
1336+ let finalCompositeSource : OffscreenCanvas = contentCanvas ;
13031337
13041338 // Parallelize item rendering (video decode is the bottleneck).
13051339 // Collect all renderable items in z-order, fire all renders concurrently,
@@ -1354,19 +1388,27 @@ export async function createCompositionRenderer(
13541388 ( t ) => t . type === 'item' && t . item . blendMode && t . item . blendMode !== 'normal' ,
13551389 ) ;
13561390 const useGpuCompositor = hasNonNormalBlend && gpuPipeline && ensureGpuCompositor ( ) ;
1391+ const gpuCompositeOutput = useGpuCompositor
1392+ ? ensureGpuCompositeOutput ( canvasSettings . width , canvasSettings . height )
1393+ : null ;
13571394
1358- if ( useGpuCompositor && gpuCompositor && gpuMaskManager ) {
1395+ if ( useGpuCompositor && gpuCompositor && gpuMaskManager && gpuCompositeOutput ) {
13591396 // GPU compositing path — pixel-perfect blend modes via WebGPU
13601397 const device = gpuPipeline ! . getDevice ( ) ;
13611398 const w = canvasSettings . width ;
13621399 const h = canvasSettings . height ;
13631400 const layers : CompositeLayer [ ] = [ ] ;
13641401 const layerTextures : GPUTexture [ ] = [ ] ;
1402+ const compositedResults : Array < {
1403+ task : typeof renderTasks [ number ] ;
1404+ result : { source : OffscreenCanvas ; poolCanvases : OffscreenCanvas [ ] } ;
1405+ } > = [ ] ;
13651406
13661407 for ( let i = 0 ; i < results . length ; i ++ ) {
13671408 const task = renderTasks [ i ] ! ;
13681409 const result = applyTrackScopedMasks ( results [ i ] ?? null , task . trackOrder ) ;
13691410 if ( ! result ) continue ;
1411+ compositedResults . push ( { task, result } ) ;
13701412
13711413 const blendMode = task . type === 'item' ? ( task . item . blendMode ?? 'normal' ) : 'normal' ;
13721414
@@ -1388,46 +1430,35 @@ export async function createCompositionRenderer(
13881430 textureView : tex . createView ( ) ,
13891431 maskView : gpuMaskManager . getFallbackView ( ) ,
13901432 } ) ;
1391-
1392- for ( const c of result . poolCanvases ) canvasPool . release ( c ) ;
13931433 }
13941434
1395- if ( layers . length > 0 ) {
1396- const commandEncoder = device . createCommandEncoder ( ) ;
1397- const composited = gpuCompositor . compositeToTexture ( layers , w , h , commandEncoder ) ;
1435+ const compositedToGpuCanvas = layers . length > 0
1436+ && gpuCompositor . compositeToCanvas ( layers , w , h , gpuCompositeOutput . ctx ) ;
13981437
1399- if ( composited ) {
1400- // Readback composited result to Canvas2D
1401- const bytesPerRow = Math . ceil ( w * 4 / 256 ) * 256 ;
1402- const readBuffer = device . createBuffer ( {
1403- size : bytesPerRow * h ,
1404- usage : GPUBufferUsage . COPY_DST | GPUBufferUsage . MAP_READ ,
1405- } ) ;
1406- commandEncoder . copyTextureToBuffer (
1407- { texture : composited . texture } ,
1408- { buffer : readBuffer , bytesPerRow } ,
1409- { width : w , height : h } ,
1410- ) ;
1411- device . queue . submit ( [ commandEncoder . finish ( ) ] ) ;
1412-
1413- await readBuffer . mapAsync ( GPUMapMode . READ ) ;
1414- const mapped = new Uint8Array ( readBuffer . getMappedRange ( ) ) ;
1415- const pixels = new Uint8ClampedArray ( w * h * 4 ) ;
1416- for ( let row = 0 ; row < h ; row ++ ) {
1417- pixels . set (
1418- mapped . subarray ( row * bytesPerRow , row * bytesPerRow + w * 4 ) ,
1419- row * w * 4 ,
1420- ) ;
1438+ if ( compositedToGpuCanvas ) {
1439+ finalCompositeSource = gpuCompositeOutput . canvas ;
1440+ } else {
1441+ // Fall back to the established Canvas2D compositor if the GPU target
1442+ // isn't available for this frame. This preserves feature parity and
1443+ // avoids dropping content when WebGPU canvas presentation fails.
1444+ for ( const { task, result } of compositedResults ) {
1445+ const blendMode = task . type === 'item' ? task . item . blendMode : undefined ;
1446+ if ( blendMode && blendMode !== 'normal' ) {
1447+ contentCtx . globalCompositeOperation = getCompositeOperation ( blendMode ) ;
14211448 }
1422- readBuffer . unmap ( ) ;
1423- readBuffer . destroy ( ) ;
14241449
1425- contentCtx . putImageData ( new ImageData ( pixels , w , h ) , 0 , 0 ) ;
1426- } else {
1427- device . queue . submit ( [ commandEncoder . finish ( ) ] ) ;
1450+ contentCtx . drawImage ( result . source , 0 , 0 ) ;
1451+
1452+ if ( blendMode && blendMode !== 'normal' ) {
1453+ contentCtx . globalCompositeOperation = 'source-over' ;
1454+ }
14281455 }
14291456 }
14301457
1458+ for ( const { result } of compositedResults ) {
1459+ for ( const c of result . poolCanvases ) canvasPool . release ( c ) ;
1460+ }
1461+
14311462 // Destroy per-frame textures
14321463 for ( const tex of layerTextures ) tex . destroy ( ) ;
14331464 } else {
@@ -1458,7 +1489,7 @@ export async function createCompositionRenderer(
14581489 log . debug ( `Occlusion culling: skipped ${ skippedTracks } tracks at frame ${ frame } ` ) ;
14591490 }
14601491
1461- ctx . drawImage ( contentCanvas , 0 , 0 ) ;
1492+ ctx . drawImage ( finalCompositeSource , 0 , 0 ) ;
14621493
14631494 // Release content canvas back to pool
14641495 canvasPool . release ( contentCanvas ) ;
@@ -1616,6 +1647,10 @@ export async function createCompositionRenderer(
16161647 gpuCompositor = null ;
16171648 gpuMaskManager ?. destroy ( ) ;
16181649 gpuMaskManager = null ;
1650+ gpuCompositeCtx = null ;
1651+ gpuCompositeCanvas = null ;
1652+ gpuCompositeW = 0 ;
1653+ gpuCompositeH = 0 ;
16191654 gpuTransitionPipeline ?. destroy ( ) ;
16201655 gpuTransitionPipeline = null ;
16211656 gpuPipeline ?. destroy ( ) ;
0 commit comments