forked from asgeirtj/system_prompts_leaks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanimations-v2.jsx
More file actions
1483 lines (1388 loc) · 59.5 KB
/
Copy pathanimations-v2.jsx
File metadata and controls
1483 lines (1388 loc) · 59.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
// animations-v2.jsx — timeline animation engine with scene sequencing.
// Exports (on window): Stage, Sprite, TextSprite, ImageSprite, RectSprite,
// VideoSprite, PlaybackBar, Easing, interpolate, animate, clamp,
// useTime, useTimeline, useSprite, SceneStage, useScene.
//
// ALWAYS structure the piece as a scene sequence — even a single-scene
// piece is a one-entry list. Do NOT also load animations.jsx: v2 contains
// the whole engine (same globals; loading both means last-wins).
// <x-import component-from-global-scope="MyPiece"
// from="./animations-v2.jsx ./my-piece.jsx"></x-import>
//
// THE AUTHORING CONTRACT — this is what makes the host timeline's
// trim/reorder gestures write back into YOUR file, so follow it
// exactly:
// 1. Declare the scene list as a JSON string literal in a plain inline
// <script> of the main document (NOT type="text/babel", NOT a sibling
// .jsx — only vanilla inline scripts are addressable for write-back):
// <script>window.OM_SCENES = '[{"name":"Opening","dur":3},{"name":"Peak","dur":4.5}]';</script>
// 2. Pass the string through untouched: <SceneStage scenes={window.OM_SCENES} ...>
// 3. Map scene names to components via the children object.
// IMPORTANT — the exportable-video contract: SceneStage/Stage OWNS it
// (the data-om-exportable-video-with-duration-secs attribute, the
// data-om-seek-to-time-frame listener, the svg/foreignObject wrapper,
// and font inlining). NEVER put the exportable attribute on any other
// element — wrapping the stage in a second "exportable root" makes the
// host timeline and the video exporter bind to the wrong element, and
// playback control / export silently break.
// 4. ALSO declare the playback setting the same way — this is what makes
// the host timeline's Repeat control write back into your file:
// <script>window.OM_PLAYBACK = '{"mode":"loop"}';</script>
// and pass it through untouched: <SceneStage playback={window.OM_PLAYBACK} ...>
// Values: '{"mode":"loop"}' (play forever, the default) or
// '{"mode":"times","count":N}' (play N times, then hold the last
// frame). Omitting it keeps loop behavior but leaves the host
// control read-only for this document.
//
// IMPORTANT — the exportable-video contract: SceneStage/Stage OWNS it
// (the data-om-exportable-video-with-duration-secs attribute, the
// data-om-seek-to-time-frame listener, the svg/foreignObject wrapper,
// and font inlining). NEVER put the exportable attribute on any other
// element — wrapping the stage in a second "exportable root" makes the
// host timeline and the video exporter bind to the wrong element, and
// playback control / export silently break.
//
// <SceneStage width={1280} height={720} scenes={window.OM_SCENES}
// bg="#0b0b0e">
// {{ 'Opening': Opening, 'Peak': Peak }}
// </SceneStage>
//
// SceneStage({width, height, scenes, bg, autoplay=true, loop=true,
// transition='cut', children}) — wraps Stage. Scenes play in authored order; total
// duration is the sum of durs, kept in sync with the exportable attr
// automatically. The host timeline shows the scenes as blocks: dragging
// an edge retimes one scene, dragging a block's body reorders — and every
// edit lands in the JSON literal in source, then the composition reflows
// live (no reload) via the data-om-timeline-scenes-update event. (The
// time ruler above the blocks is a seek surface — click or drag scrubs;
// it never edits timing.)
//
// TIMING IS USER-EDITABLE (time-stretch): when the user changes a scene's
// length, the engine remaps your scene clock so the SAME choreography
// plays faster or slower — never cut off. That only works for motion
// driven by the scene clock, so inside a scene component ALWAYS animate
// from useScene()'s {localTime, progress} (never your own clock, never
// useTime directly).
//
// The same rule is what makes video export exact AND fast: the exporter
// seeks each frame with a synchronous commit and may serialize the stage
// the moment the seek event returns — anything painted from useEffect or
// your own requestAnimationFrame lags that commit and exports stale.
// Render everything visible from the scene clock's values and this is
// automatic. (Nested <VideoSprite> videos are handled by the exporter.)
//
// TRANSITIONS: scene boundaries are hard cuts by default
// (transition="cut") — exactly one scene is mounted at any time. Scene
// layers are keyed by scene index, so inactive scenes are fully unmounted
// (they do zero per-frame work) and a scene never leaks component state
// into a neighbor, even when two adjacent scenes use the same component.
// transition="overlap" is opt-in and for OPAQUE scenes only: during
// playback the outgoing scene stays mounted beneath the incoming one for
// ~2 frames, frozen at the frame it had just rendered, so the moments
// where the incoming scene hasn't painted real content yet (an <img>
// still decoding, a <video> before its first frame) show the outgoing
// scene rather than a flash of stage background. It cannot fix content
// that paints WRONG — a video whose first frame paints black paints
// black over the underlay too. Only use it
// when every scene paints the full frame — a scene on a transparent stage
// background will show the previous scene through it (ghosting); keep
// "cut" for those. Paused seeks and video-export frame seeks
// (data-om-seek-to-time-frame) never overlap — a seeked frame always
// renders exactly one scene's state. Playback driven by the EDITOR's
// play bar counts as playback too: the host marks its play-loop seeks
// (detail.playing === true on the same seek event) and the engine reads
// the marked stream as continuous playback, so overlap may engage —
// including across the loop seam, matching self-driven playback — while
// unmarked seeks (scrubs, steps, export frames) keep the
// exactly-one-scene rule. A tick-sized forward step or drag
// WHILE PLAYING reads as playback and may briefly overlap (bounded, ~2
// frames). The loop wrap (last scene back to the first, when loop is on —
// the default) is a boundary like any other and overlaps too, so the
// frame-match contract below applies across the loop seam as well.
//
// THE FRAME-MATCH CONTRACT (this is what makes boundaries seamless, in
// BOTH modes): a scene's entry/exit effects must be 0 at progress 0 and
// at progress 1 — its first and last rendered frames are the settled
// composition, with entrances and exits choreographed strictly inside
// (0, 1). No entry-only squash/rotation/opacity: a scene whose frame at
// progress 0 is mid-squash, rotated, or transparent pops at every cut and
// ghosts under overlap.
//
// The provided sprites bake in entry/exit fades (entryDur/exitDur), so a
// sprite that spans a scene edge violates the contract by construction:
// set entryDur={0} on sprites alive at the scene's first frame and
// exitDur={0} on sprites alive at its last, or inset the sprite's span so
// its fades complete inside the scene. The flip side: a scene that exits
// to fully transparent shows NOTHING at its last frame, so "overlap"
// would hold an empty underlay — following the contract is what makes
// overlap worth turning on.
//
// Scene entries are independent component instances, even when two names
// map to the same component — state never carries across a boundary. For
// one continuous component spanning a retimable stretch (a <video> that
// must keep playing through), use a single scene entry with extra fields
// driving its phases, not two entries of the same component.
//
// Each scene entry may carry extra fields ({"name":"Peak","dur":4,
// "text":"ACME"}) — the active scene component receives the whole entry as
// `scene` plus {localTime, progress, dur, index, count}, and can call
// useScene() anywhere below. Scenes own their entrances/exits — ramp any
// effect up only AFTER progress 0 and settle it back to 0 BEFORE progress
// 1, per THE FRAME-MATCH CONTRACT above. The optional "nat" field is the engine's
// time-stretch anchor — the host timeline manages it; don't set it by
// hand.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
// ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
const Easing = {
linear: (t) => t,
// Quad
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
// Cubic
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => (--t) * t * t + 1,
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
// Quart
easeInQuart: (t) => t * t * t * t,
easeOutQuart: (t) => 1 - (--t) * t * t * t,
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
// Expo
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
easeInOutExpo: (t) => {
if (t === 0) return 0;
if (t === 1) return 1;
if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
return 1 - 0.5 * Math.pow(2, -20 * t + 10);
},
// Sine
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
// Back (overshoot)
easeOutBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
easeInBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return c3 * t * t * t - c1 * t * t;
},
easeInOutBack: (t) => {
const c1 = 1.70158, c2 = c1 * 1.525;
return t < 0.5
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
},
// Elastic
easeOutElastic: (t) => {
const c4 = (2 * Math.PI) / 3;
if (t === 0) return 0;
if (t === 1) return 1;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},
};
// ── Core interpolation helpers ──────────────────────────────────────────────
// Clamp a value to [min, max]
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
// Popmotion-style: linearly maps t across input keyframes to output values,
// with optional easing per segment (single fn or array of fns).
function interpolate(input, output, ease = Easing.linear) {
return (t) => {
if (t <= input[0]) return output[0];
if (t >= input[input.length - 1]) return output[output.length - 1];
for (let i = 0; i < input.length - 1; i++) {
if (t >= input[i] && t <= input[i + 1]) {
const span = input[i + 1] - input[i];
const local = span === 0 ? 0 : (t - input[i]) / span;
const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
const eased = easeFn(local);
return output[i] + (output[i + 1] - output[i]) * eased;
}
}
return output[output.length - 1];
};
}
// animate({from, to, start, end, ease})(t) — simpler single-segment tween.
// Returns `from` before `start`, `to` after `end`.
function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
return (t) => {
if (t <= start) return from;
if (t >= end) return to;
const local = (t - start) / (end - start);
return from + (to - from) * ease(local);
};
}
// ── Timeline context ────────────────────────────────────────────────────────
const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
const useTime = () => React.useContext(TimelineContext).time;
const useTimeline = () => React.useContext(TimelineContext);
// ── Sprite ──────────────────────────────────────────────────────────────────
// Renders children only when the playhead is inside [start, end]. Provides
// a sub-context with `localTime` (seconds since start) and `progress` (0..1).
//
// <Sprite start={2} end={5}>
// {({ localTime, progress }) => <Thing x={progress * 100} />}
// </Sprite>
//
// Or as a plain wrapper — children can call useSprite() themselves.
const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
const useSprite = () => React.useContext(SpriteContext);
function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
const { time } = useTimeline();
const visible = time >= start && time <= end;
if (!visible && !keepMounted) return null;
const duration = end - start;
const localTime = Math.max(0, time - start);
const progress = duration > 0 && isFinite(duration)
? clamp(localTime / duration, 0, 1)
: 0;
const value = { localTime, progress, duration, visible };
return (
<SpriteContext.Provider value={value}>
{typeof children === 'function' ? children(value) : children}
</SpriteContext.Provider>
);
}
// ── Sample sprite components ────────────────────────────────────────────────
// TextSprite: fades/slides text in on entry, holds, then fades out on exit.
// Props: text, x, y, size, color, font, entryDur, exitDur, align
function TextSprite({
text,
x = 0, y = 0,
size = 48,
color = '#111',
font = 'Inter, system-ui, sans-serif',
weight = 600,
entryDur = 0.45,
exitDur = 0.35,
entryEase = Easing.easeOutBack,
exitEase = Easing.easeInCubic,
align = 'left',
letterSpacing = '-0.01em',
}) {
const { localTime, duration } = useSprite();
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let ty = 0;
if (localTime < entryDur) {
const t = entryEase(clamp(localTime / entryDur, 0, 1));
opacity = t;
ty = (1 - t) * 16;
} else if (localTime > exitStart) {
const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
ty = -t * 8;
}
const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';
return (
<div style={{
position: 'absolute',
left: x, top: y,
transform: `translate(${translateX}, ${ty}px)`,
opacity,
fontFamily: font,
fontSize: size,
fontWeight: weight,
color,
letterSpacing,
whiteSpace: 'pre',
lineHeight: 1.1,
willChange: 'transform, opacity',
}}>
{text}
</div>
);
}
// ImageSprite: scales + fades in; optional Ken Burns drift during hold.
function ImageSprite({
src,
x = 0, y = 0,
width = 400, height = 300,
entryDur = 0.6,
exitDur = 0.4,
kenBurns = false,
kenBurnsScale = 1.08,
radius = 12,
fit = 'cover',
placeholder = null, // {label: string} for striped placeholder
}) {
const { localTime, duration } = useSprite();
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let scale = 1;
if (localTime < entryDur) {
const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
opacity = t;
scale = 0.96 + 0.04 * t;
} else if (localTime > exitStart) {
const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
} else if (kenBurns) {
const holdSpan = exitStart - entryDur;
const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
scale = 1 + (kenBurnsScale - 1) * holdT;
}
const content = placeholder ? (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
color: '#6b6458',
fontFamily: 'JetBrains Mono, ui-monospace, monospace',
fontSize: 13,
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>
{placeholder.label || 'image'}
</div>
) : (
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
);
return (
<div style={{
position: 'absolute',
left: x, top: y,
width, height,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center',
borderRadius: radius,
overflow: 'hidden',
willChange: 'transform, opacity',
}}>
{content}
</div>
);
}
// RectSprite: simple rectangle that animates position/size/color via props.
// Useful demo primitive — takes a `render` fn for per-frame customization.
function RectSprite({
x = 0, y = 0,
width = 100, height = 100,
color = '#111',
radius = 8,
entryDur = 0.4,
exitDur = 0.3,
render, // optional: (ctx) => style overrides
}) {
const spriteCtx = useSprite();
const { localTime, duration } = spriteCtx;
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let scale = 1;
if (localTime < entryDur) {
const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
opacity = clamp(localTime / entryDur, 0, 1);
scale = 0.4 + 0.6 * t;
} else if (localTime > exitStart) {
const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
scale = 1 - 0.15 * t;
}
const overrides = render ? render(spriteCtx) : {};
return (
<div style={{
position: 'absolute',
left: x, top: y,
width, height,
background: color,
borderRadius: radius,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center',
willChange: 'transform, opacity',
...overrides,
}} />
);
}
// ── Font inlining ───────────────────────────────────────────────────────────
// Copy every @font-face rule from the page into a <style> inside the svg's
// foreignObject, with font URLs rewritten to data: URLs. Makes the svg
// self-describing so serializing it alone (video export fast path) still
// renders with the right fonts. Sets data-om-fonts-inlined on the svg when
// done so the exporter can wait for it.
function useInlineFontsInto(svgRef) {
React.useEffect(() => {
const svg = svgRef.current;
const host = svg && svg.querySelector('foreignObject > div');
if (!svg || !host) return;
let cancelled = false;
(async () => {
const rules = [];
for (const ss of document.styleSheets) {
let cssRules;
try { cssRules = ss.cssRules; } catch {
// Cross-origin sheet without crossorigin attr (e.g. the standard
// fonts.googleapis.com <link>) — fetch the CSS text directly and
// regex-extract the @font-face blocks.
if (ss.href) {
try {
const txt = await fetch(ss.href).then(r => { if (!r.ok) throw 0; return r.text(); });
for (const ff of (txt.match(/@font-face\s*{[^}]*}/g) || []))
rules.push({ css: ff, base: ss.href });
} catch {}
}
continue;
}
if (!cssRules) continue;
for (const r of cssRules) {
if (r.type === CSSRule.FONT_FACE_RULE) {
rules.push({ css: r.cssText, base: ss.href || location.href });
}
}
}
const toDataURL = (url) => fetch(url)
.then(r => { if (!r.ok) throw 0; return r.blob(); })
.then(b => new Promise(res => {
const fr = new FileReader();
fr.onload = () => res(fr.result);
fr.onerror = () => res(url);
fr.readAsDataURL(b);
}))
.catch(() => url);
const parts = await Promise.all(rules.map(async ({ css, base }) => {
const re = /url\((['"]?)([^'")]+)\1\)/g;
let out = css, m;
while ((m = re.exec(css))) {
const u = m[2];
if (u.startsWith('data:')) continue;
let abs; try { abs = new URL(u, base).href; } catch { continue; }
out = out.split(m[0]).join(`url("${await toDataURL(abs)}")`);
}
return out;
}));
if (cancelled || !parts.length) {
svg.setAttribute('data-om-fonts-inlined', 'true');
return;
}
const style = document.createElement('style');
style.textContent = parts.join('\n');
host.insertBefore(style, host.firstChild);
svg.setAttribute('data-om-fonts-inlined', 'true');
})();
return () => { cancelled = true; };
}, []);
}
function Stage({
width = 1280,
height = 720,
duration = 10,
background = '#f6f4ef',
fps = 60,
loop = true,
autoplay = true,
// Parsed playback object ({mode:'loop'} | {mode:'times',count:N}) or
// null. When present it overrides the legacy loop prop — SceneStage
// passes the validated value from the OM_PLAYBACK authoring contract.
playback = null,
persistKey = 'animstage',
children,
}) {
// Props arrive as strings when Stage is mounted via <x-import> (DC
// projects) — coerce so style={{width}} gets a number React can px-ify.
width = +width || 1280; height = +height || 720;
duration = +duration || 10; fps = +fps || 60;
if (typeof loop === 'string') loop = loop !== 'false';
if (typeof autoplay === 'string') autoplay = autoplay !== 'false';
const playTimes = playback && playback.mode === 'times' ? playback.count : null;
const loopEff = playback ? playback.mode === 'loop' : loop;
const [time, setTime] = React.useState(() => {
try {
const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
return isFinite(v) ? clamp(v, 0, duration) : 0;
} catch { return 0; }
});
const [playing, setPlaying] = React.useState(autoplay);
// The external-playback latch: true while the HOST play bar is driving
// time forward as genuine continuous playback (its play-loop seeks
// carry detail.playing === true). The engine's own clock stays paused
// the whole time — exactly one clock ever drives — so this is a
// separate bit, not a second meaning for `playing`. Set and cleared
// in the seek handler below; decays via SS_EXT_PLAY_MS when the
// marked stream stops without a parting unmarked seek.
const [extPlay, setExtPlay] = React.useState(false);
const extPlayTimerRef = React.useRef(null);
const [hoverTime, setHoverTime] = React.useState(null);
const [scale, setScale] = React.useState(1);
const stageRef = React.useRef(null);
const canvasRef = React.useRef(null);
const rafRef = React.useRef(null);
const lastTsRef = React.useRef(null);
// Persist playhead
React.useEffect(() => {
try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
}, [time, persistKey]);
// Auto-scale to fit viewport
React.useEffect(() => {
if (!stageRef.current) return;
const el = stageRef.current;
const measure = () => {
const barH = 44; // playback bar height
const s = Math.min(
el.clientWidth / width,
(el.clientHeight - barH) / height
);
setScale(Math.max(0.05, s));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, [width, height]);
// Passes completed since playback last started. Lives in a ref so the
// per-frame wrap can count without re-running this effect; reset on
// every (re)start so a fresh play (or a host restart) gets the full
// run count again.
const passesRef = React.useRef(0);
// Animation loop
React.useEffect(() => {
if (!playing) {
lastTsRef.current = null;
return;
}
passesRef.current = 0;
const step = (ts) => {
if (lastTsRef.current == null) lastTsRef.current = ts;
const dt = (ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
setTime((t) => {
let next = t + dt;
if (next >= duration) {
if (playTimes !== null) {
// Play N times then hold the last frame — the partial pass a
// mid-timeline start produces counts as a pass, so the piece
// never runs longer than N full durations.
passesRef.current += 1;
if (passesRef.current >= playTimes) {
next = duration;
setPlaying(false);
} else {
next = next % duration;
}
} else if (loopEff) {
next = next % duration;
} else {
next = duration; setPlaying(false);
}
}
return next;
});
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
lastTsRef.current = null;
};
}, [playing, duration, loopEff, playTimes]);
// Keyboard: space = play/pause, ← → = seek
React.useEffect(() => {
const onKey = (e) => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.code === 'Space') {
e.preventDefault();
setPlaying(p => !p);
} else if (e.code === 'ArrowLeft') {
setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
} else if (e.code === 'ArrowRight') {
setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
} else if (e.key === '0' || e.code === 'Home') {
setTime(0);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [duration]);
// Video-export protocol + the editor's play bar: hosts dispatch this
// event per frame; pause + sync the playhead so the frame shows exactly
// that timestamp. The host play bar marks its play-loop seeks with
// detail.playing === true — the mark latches extPlay (playback is
// playback even when a host clock drives it), while ANY unmarked seek
// (scrub, step, export frame, the transport's pause park) clears the
// latch in the same commit it retimes, so a seeked frame still renders
// exactly one scene's state. The engine's own clock pauses either way.
React.useEffect(() => {
const el = canvasRef.current;
if (!el) return;
// Sync-seek capability: a dispatcher that marks its seek with
// detail.sync === true gets the commit applied via ReactDOM.flushSync,
// so the stage DOM reflects the seeked frame the moment dispatchEvent
// returns. The video exporter keys off the data-om-sync-seek
// advertisement to drop its two-display-refresh settle (that wait only
// exists to let React's async commit land — serialization needs the
// committed DOM, not the paint). Feature-detected: a runtime without
// ReactDOM.flushSync never advertises and every seek takes the async
// path. Unmarked seeks (scrubs, the host play bar) stay async — a
// forced sync render per pointermove would tax the editor for no one.
const canSyncSeek =
typeof ReactDOM !== 'undefined' &&
typeof ReactDOM.flushSync === 'function';
const onSeek = (e) => {
const apply = () => {
setPlaying(false);
const hostPlay = !!(e.detail && e.detail.playing === true);
if (extPlayTimerRef.current) {
clearTimeout(extPlayTimerRef.current);
extPlayTimerRef.current = null;
}
if (hostPlay) {
// Watchdog: the latch is only as alive as its seek stream. If the
// host stops without a parting seek (tab jank, bar unmount), the
// latch decays on its own — and the expiry setState is itself the
// render that lets SceneSwitch drop an open window, so expiry can
// never strand a frozen two-layer frame.
extPlayTimerRef.current = setTimeout(() => {
extPlayTimerRef.current = null;
setExtPlay(false);
}, SS_EXT_PLAY_MS);
}
setExtPlay(hostPlay);
setTime(clamp(e.detail.time, 0, duration));
};
// flushSync is safe here: a native DOM listener runs outside React's
// lifecycle, and the exporter's dispatchEvent is synchronous, so the
// commit lands in the same JS task — the engine's own rAF loop can
// never interleave between seek and serialize.
if (canSyncSeek && e.detail && e.detail.sync === true) {
ReactDOM.flushSync(apply);
} else {
apply();
}
};
el.addEventListener('data-om-seek-to-time-frame', onSeek);
if (canSyncSeek) el.setAttribute('data-om-sync-seek', 'true');
return () => {
el.removeEventListener('data-om-seek-to-time-frame', onSeek);
el.removeAttribute('data-om-sync-seek');
if (extPlayTimerRef.current) {
clearTimeout(extPlayTimerRef.current);
extPlayTimerRef.current = null;
}
// Drop the latch too: this cleanup runs on every duration change
// (an agent edit can retime mid-host-play, no gesture involved) and
// the new effect instance arms no watchdog — clearing only the
// timer could strand extPlay true forever if the marked stream died
// in the gap. Fail toward cut: the next marked seek re-latches.
setExtPlay(false);
};
}, [duration]);
// Inline @font-face rules into the svg's foreignObject so the svg is
// self-describing — serializing it alone (for video export) then renders
// with the right fonts. Sets data-om-fonts-inlined once done.
useInlineFontsInto(canvasRef);
const displayTime = hoverTime != null ? hoverTime : time;
const ctxValue = React.useMemo(
// extPlaying is ADDITIVE: "time is advancing under an external
// driver's continuous playback". `playing` keeps meaning the
// engine's OWN clock — the hidden PlaybackBar glyph (and through it
// the host's clock-reporter/adoption channel) reads that — and
// SceneSwitch is the one consumer that widens to either.
() => ({
time: displayTime, duration, playing,
extPlaying: extPlay,
setTime, setPlaying,
}),
[displayTime, duration, playing, extPlay]
);
return (
<div
ref={stageRef}
style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column',
alignItems: 'center',
background: '#0a0a0a',
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
{/* Canvas area — vertically centered in remaining space */}
<div style={{
flex: 1,
width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
minHeight: 0,
}}>
<svg
ref={canvasRef}
width={width} height={height}
data-om-exportable-video-with-duration-secs={duration}
style={{
transform: `scale(${scale})`,
transformOrigin: 'center',
flexShrink: 0,
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
display: 'block',
}}
>
<foreignObject x="0" y="0" width="100%" height="100%">
<div
xmlns="http://www.w3.org/1999/xhtml"
style={{
width, height,
background,
position: 'relative',
overflow: 'hidden',
}}
>
<TimelineContext.Provider value={ctxValue}>
{children}
</TimelineContext.Provider>
</div>
</foreignObject>
</svg>
</div>
{/* Playback bar — stacked below canvas, never overlapping */}
<PlaybackBar
time={displayTime}
actualTime={time}
duration={duration}
playing={playing}
onPlayPause={() => setPlaying(p => !p)}
onReset={() => { setTime(0); }}
onSeek={(t) => setTime(t)}
onHover={(t) => setHoverTime(t)}
/>
</div>
);
}
// ── Playback bar ────────────────────────────────────────────────────────────
// Play/pause, return-to-begin, scrub track, time display.
// Uses fixed-width time fields so layout doesn't thrash.
function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek, onHover }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const timeFromEvent = React.useCallback((e) => {
const rect = trackRef.current.getBoundingClientRect();
const x = clamp((e.clientX - rect.left) / rect.width, 0, 1);
return x * duration;
}, [duration]);
const onTrackMove = (e) => {
if (!trackRef.current) return;
const t = timeFromEvent(e);
if (dragging) {
onSeek(t);
} else {
onHover(t);
}
};
const onTrackLeave = () => {
if (!dragging) onHover(null);
};
const onTrackDown = (e) => {
setDragging(true);
const t = timeFromEvent(e);
onSeek(t);
onHover(null);
};
React.useEffect(() => {
if (!dragging) return;
const onUp = () => setDragging(false);
const onMove = (e) => {
if (!trackRef.current) return;
const t = timeFromEvent(e);
onSeek(t);
};
window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
return () => {
window.removeEventListener('mouseup', onUp);
window.removeEventListener('mousemove', onMove);
};
}, [dragging, timeFromEvent, onSeek]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
const fmt = (t) => {
const total = Math.max(0, t);
const m = Math.floor(total / 60);
const s = Math.floor(total % 60);
const cs = Math.floor((total * 100) % 100);
return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
};
const mono = 'JetBrains Mono, ui-monospace, SFMono-Regular, monospace';
return (
<div data-omelette-chrome style={{
// Slimmed to visually match the host editor bar's basic row (the
// single-scrubber look): transport first, tighter metrics, quieter
// chrome. Shown only outside the app — the host bar suppresses this
// whenever it is present.
display: 'flex', alignItems: 'center', gap: 10,
padding: '6px 12px',
background: 'rgba(20,20,20,0.92)',
borderTop: '1px solid rgba(255,255,255,0.08)',
width: '100%',
maxWidth: 680,
alignSelf: 'center',
borderRadius: 6,
color: '#f6f4ef',
fontFamily: 'Inter, system-ui, sans-serif',
userSelect: 'none',
flexShrink: 0,
}}>
<IconButton onClick={onPlayPause} title="Play/pause (space)">
{playing ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<rect x="3" y="2" width="3" height="10" fill="currentColor"/>
<rect x="8" y="2" width="3" height="10" fill="currentColor"/>
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 2l9 5-9 5V2z" fill="currentColor"/>
</svg>
)}
</IconButton>
<IconButton onClick={onReset} title="Return to start (0)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M3 2v10M12 2L5 7l7 5V2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
</svg>
</IconButton>
{/* Current time: fixed width so it doesn't thrash */}
<div style={{
fontFamily: mono,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
width: 64, textAlign: 'right',
color: '#f6f4ef',
}}>
{fmt(time)}
</div>
{/* Scrub track */}
<div
ref={trackRef}
onMouseMove={onTrackMove}
onMouseLeave={onTrackLeave}
onMouseDown={onTrackDown}
style={{
flex: 1,
height: 22,
position: 'relative',
cursor: 'pointer',
display: 'flex', alignItems: 'center',
}}
>
<div style={{
position: 'absolute',
left: 0, right: 0, height: 4,
background: 'rgba(255,255,255,0.12)',
borderRadius: 2,
}}/>
<div style={{
position: 'absolute',
left: 0, width: `${pct}%`, height: 4,
background: 'oklch(72% 0.12 250)',
borderRadius: 2,
}}/>
<div style={{
position: 'absolute',
left: `${pct}%`, top: '50%',
width: 12, height: 12,
marginLeft: -6, marginTop: -6,
background: '#fff',
borderRadius: 6,
boxShadow: '0 2px 4px rgba(0,0,0,0.4)',
}}/>
</div>
{/* Duration: fixed width */}
<div style={{
fontFamily: mono,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
width: 64, textAlign: 'left',
color: 'rgba(246,244,239,0.55)',
}}>
{fmt(duration)}
</div>
{typeof VideoEncoder !== 'undefined' && (
<IconButton
title="Export video"
onClick={() => window.parent.postMessage({ type: 'omelette:request-video-export' }, '*')}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 2v7m0 0L4 6m3 3l3-3M2 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</IconButton>
)}
</div>
);
}
function IconButton({ children, onClick, title }) {
const [hover, setHover] = React.useState(false);
return (
<button
onClick={onClick}
title={title}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}