@@ -21,10 +21,10 @@ import {
2121 ClipsContent , Geometry , Group , Paint , Color , Caption , ScaleMode , Shader ,
2222 BlendMode , Effect , Mask , Transition , MixedCornerRadius ,
2323 LocalTransform , WorldTransform , Computed , Cache ,
24- Host ,
24+ Host , Scene , Shimmering ,
2525 Mode , FrameRate , Camera , Background , RenderSurface ,
2626 HitRegions ,
27- Root ,
27+ Root , Time ,
2828} from '../traits' ;
2929import { getParentNode } from '../queries/hierarchy' ;
3030import { getViewMatrix } from '../queries/camera' ;
@@ -610,6 +610,103 @@ function renderPendingFill(world: World, entity: Entity): void {
610610 ctx . fill ( ) ;
611611}
612612
613+ const SHIMMER_PERIOD = 2400 ; // ms per turn of the highlights
614+ const SHIMMER_SWEEP = 1400 ; // ms the light takes to cross the scene after each turn
615+ const SHIMMER_BAND = 1 ; // width of the light, in diagonals of the scene
616+ const SHIMMER_PRIMARY = [ 0 , 140 , 255 ] as const ; // #008CFF
617+ const SHIMMER_DEPTH = 0.7 ; // how far the highlights darken toward the stage background
618+ const SHIMMER_SWEEP_OPACITY = 0.2 ;
619+
620+ /**
621+ * The outline of a scene that work is running on (see `Shimmering`), over
622+ * everything the scene draws: a primary blue stroke along its edge with two
623+ * highlights travelling around it that darken toward the stage background,
624+ * and after each turn a band of translucent primary blue sweeping across the
625+ * scene, corner to corner. Counted on the world's clock from when
626+ * the work began. The editor's only, never drawn into an export. The stroke
627+ * keeps its width on screen whatever the zoom, so it is drawn in device
628+ * pixels from the scene's corners.
629+ */
630+ function renderShimmer ( world : World , entity : Entity ) : void {
631+ if ( ! entity . has ( Shimmering ) || ! entity . has ( Scene ) ) return ;
632+
633+ const ctx = getCtx ( world ) ;
634+ const computed = store ( world , Computed ) ;
635+ const w = computed . width [ entity . id ( ) ] ! ;
636+ const h = computed . height [ entity . id ( ) ] ! ;
637+ const resolution = world . get ( RenderSurface ) ?. resolution ?? 1 ;
638+
639+ const m = ctx . getTransform ( ) ;
640+ const quad = [ [ 0 , 0 ] , [ w , 0 ] , [ w , h ] , [ 0 , h ] ] . map ( ( [ x , y ] ) => m . transformPoint ( { x, y } ) ) ;
641+ const cx = ( quad [ 0 ] ! . x + quad [ 1 ] ! . x + quad [ 2 ] ! . x + quad [ 3 ] ! . x ) / 4 ;
642+ const cy = ( quad [ 0 ] ! . y + quad [ 1 ] ! . y + quad [ 2 ] ! . y + quad [ 3 ] ! . y ) / 4 ;
643+ const trace = ( ) => {
644+ ctx . beginPath ( ) ;
645+ ctx . moveTo ( quad [ 0 ] ! . x , quad [ 0 ] ! . y ) ;
646+ ctx . lineTo ( quad [ 1 ] ! . x , quad [ 1 ] ! . y ) ;
647+ ctx . lineTo ( quad [ 2 ] ! . x , quad [ 2 ] ! . y ) ;
648+ ctx . lineTo ( quad [ 3 ] ! . x , quad [ 3 ] ! . y ) ;
649+ ctx . closePath ( ) ;
650+ } ;
651+
652+ const elapsed = Math . max ( 0 , ( world . get ( Time ) ?. now ?? 0 ) - entity . get ( Shimmering ) ! . start ) ;
653+ const turn = elapsed % SHIMMER_PERIOD ;
654+
655+ ctx . save ( ) ;
656+ ctx . resetTransform ( ) ;
657+ ctx . globalAlpha = 1 ;
658+ ctx . globalCompositeOperation = 'source-over' ;
659+ ctx . filter = 'none' ;
660+
661+ if ( elapsed >= SHIMMER_PERIOD && turn < SHIMMER_SWEEP ) {
662+ // The band's centre goes from half a band before the first corner to
663+ // half a band past the opposite one: it enters and leaves the scene
664+ // exactly, and is on it the whole sweep.
665+ const t = turn / SHIMMER_SWEEP ;
666+ const eased = ( 1 - Math . cos ( Math . PI * t ) ) / 2 ;
667+ const centre = - SHIMMER_BAND / 2 + eased * ( 1 + SHIMMER_BAND ) ;
668+ const dx = quad [ 2 ] ! . x - quad [ 0 ] ! . x ;
669+ const dy = quad [ 2 ] ! . y - quad [ 0 ] ! . y ;
670+ const from = centre - SHIMMER_BAND / 2 ;
671+ const to = centre + SHIMMER_BAND / 2 ;
672+ const light = ctx . createLinearGradient (
673+ quad [ 0 ] ! . x + dx * from , quad [ 0 ] ! . y + dy * from ,
674+ quad [ 0 ] ! . x + dx * to , quad [ 0 ] ! . y + dy * to ,
675+ ) ;
676+ const [ r , g , b ] = SHIMMER_PRIMARY ;
677+ // Full strength across the middle of the band, fading at its edges.
678+ light . addColorStop ( 0 , `rgba(${ r } , ${ g } , ${ b } , 0)` ) ;
679+ light . addColorStop ( 0.3 , `rgba(${ r } , ${ g } , ${ b } , ${ SHIMMER_SWEEP_OPACITY } )` ) ;
680+ light . addColorStop ( 0.7 , `rgba(${ r } , ${ g } , ${ b } , ${ SHIMMER_SWEEP_OPACITY } )` ) ;
681+ light . addColorStop ( 1 , `rgba(${ r } , ${ g } , ${ b } , 0)` ) ;
682+
683+ trace ( ) ;
684+ ctx . fillStyle = light ;
685+ ctx . fill ( ) ;
686+ }
687+
688+ const background = world . get ( Root ) ! . get ( Background ) ?. value ?? 0 ;
689+ const backdrop = [ ( background >> 16 ) & 0xff , ( background >> 8 ) & 0xff , background & 0xff ] ;
690+ const primary = `rgb(${ SHIMMER_PRIMARY . join ( ', ' ) } )` ;
691+ const dark = `rgb(${ SHIMMER_PRIMARY . map ( ( channel , i ) => Math . round ( channel + ( backdrop [ i ] ! - channel ) * SHIMMER_DEPTH ) ) . join ( ', ' ) } )` ;
692+
693+ const ring = ctx . createConicGradient ( turn / SHIMMER_PERIOD * Math . PI * 2 , cx , cy ) ;
694+ for ( const start of [ 0 , 0.5 ] ) {
695+ ring . addColorStop ( start , primary ) ;
696+ ring . addColorStop ( start + 0.12 , dark ) ;
697+ ring . addColorStop ( start + 0.24 , primary ) ;
698+ }
699+ ring . addColorStop ( 1 , primary ) ;
700+
701+ trace ( ) ;
702+ ctx . strokeStyle = ring ;
703+ ctx . lineWidth = Math . round ( 2 * resolution ) ;
704+ ctx . shadowColor = primary ;
705+ ctx . shadowBlur = 8 * resolution ;
706+ ctx . stroke ( ) ;
707+ ctx . restore ( ) ;
708+ }
709+
613710// ── WAVEFORM paint ─────────────────────────────────────
614711//
615712// Renders an audio asset's pre-computed peaks as a bar chart inside the
@@ -829,6 +926,8 @@ export function renderNode(world: World, entity: Entity): void {
829926 renderContent ( world , entity , buildEffects ( world , entity ) ) ;
830927 }
831928
929+ renderShimmer ( world , entity ) ;
930+
832931 ctx . restore ( ) ;
833932}
834933
0 commit comments