fundus 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +4 -3
  2. package/build/client/_app/immutable/assets/LibraryContextMenu.B6By2-yb.css +1 -0
  3. package/build/client/_app/immutable/assets/LibraryContextMenu.B6By2-yb.css.br +0 -0
  4. package/build/client/_app/immutable/assets/LibraryContextMenu.B6By2-yb.css.gz +0 -0
  5. package/build/client/_app/immutable/chunks/B9HdDE5n.js +1 -0
  6. package/build/client/_app/immutable/chunks/B9HdDE5n.js.br +0 -0
  7. package/build/client/_app/immutable/chunks/B9HdDE5n.js.gz +0 -0
  8. package/build/client/_app/immutable/chunks/{DFRlZOKz.js → CAQ5okPf.js} +2 -2
  9. package/build/client/_app/immutable/chunks/CAQ5okPf.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/CAQ5okPf.js.gz +0 -0
  11. package/build/client/_app/immutable/chunks/Ccz62-Hi.js +1 -0
  12. package/build/client/_app/immutable/chunks/Ccz62-Hi.js.br +0 -0
  13. package/build/client/_app/immutable/chunks/Ccz62-Hi.js.gz +0 -0
  14. package/build/client/_app/immutable/chunks/ChVOZl2x.js +1 -0
  15. package/build/client/_app/immutable/chunks/ChVOZl2x.js.br +0 -0
  16. package/build/client/_app/immutable/chunks/ChVOZl2x.js.gz +0 -0
  17. package/build/client/_app/immutable/chunks/CiBv7EZv.js +108 -0
  18. package/build/client/_app/immutable/chunks/CiBv7EZv.js.br +0 -0
  19. package/build/client/_app/immutable/chunks/CiBv7EZv.js.gz +0 -0
  20. package/build/client/_app/immutable/chunks/UFAWUL-4.js +1 -0
  21. package/build/client/_app/immutable/chunks/UFAWUL-4.js.br +0 -0
  22. package/build/client/_app/immutable/chunks/UFAWUL-4.js.gz +0 -0
  23. package/build/client/_app/immutable/chunks/YWIvyYJl.js +1 -0
  24. package/build/client/_app/immutable/chunks/YWIvyYJl.js.br +0 -0
  25. package/build/client/_app/immutable/chunks/YWIvyYJl.js.gz +0 -0
  26. package/build/client/_app/immutable/chunks/{BMORe2Gb.js → YpiA5Xry.js} +1 -1
  27. package/build/client/_app/immutable/chunks/YpiA5Xry.js.br +0 -0
  28. package/build/client/_app/immutable/chunks/{BMORe2Gb.js.gz → YpiA5Xry.js.gz} +0 -0
  29. package/build/client/_app/immutable/chunks/{DpFtnEPx.js → jjXOSnwf.js} +1 -1
  30. package/build/client/_app/immutable/chunks/jjXOSnwf.js.br +0 -0
  31. package/build/client/_app/immutable/chunks/jjXOSnwf.js.gz +0 -0
  32. package/build/client/_app/immutable/entry/app.Rv8f5zSo.js +2 -0
  33. package/build/client/_app/immutable/entry/app.Rv8f5zSo.js.br +0 -0
  34. package/build/client/_app/immutable/entry/app.Rv8f5zSo.js.gz +0 -0
  35. package/build/client/_app/immutable/entry/start.C5jRg_CF.js +1 -0
  36. package/build/client/_app/immutable/entry/start.C5jRg_CF.js.br +1 -0
  37. package/build/client/_app/immutable/entry/start.C5jRg_CF.js.gz +0 -0
  38. package/build/client/_app/immutable/nodes/0.Gv5d6CcW.js +1 -0
  39. package/build/client/_app/immutable/nodes/0.Gv5d6CcW.js.br +0 -0
  40. package/build/client/_app/immutable/nodes/0.Gv5d6CcW.js.gz +0 -0
  41. package/build/client/_app/immutable/nodes/1.8J9y5zyF.js +1 -0
  42. package/build/client/_app/immutable/nodes/1.8J9y5zyF.js.br +0 -0
  43. package/build/client/_app/immutable/nodes/1.8J9y5zyF.js.gz +0 -0
  44. package/build/client/_app/immutable/nodes/2.BNvTqsKZ.js +3 -0
  45. package/build/client/_app/immutable/nodes/2.BNvTqsKZ.js.br +0 -0
  46. package/build/client/_app/immutable/nodes/2.BNvTqsKZ.js.gz +0 -0
  47. package/build/client/_app/immutable/nodes/3.CmlRI2XA.js +1 -0
  48. package/build/client/_app/immutable/nodes/3.CmlRI2XA.js.br +0 -0
  49. package/build/client/_app/immutable/nodes/3.CmlRI2XA.js.gz +0 -0
  50. package/build/client/_app/immutable/nodes/4.CHTKk5QR.js +2 -0
  51. package/build/client/_app/immutable/nodes/4.CHTKk5QR.js.br +0 -0
  52. package/build/client/_app/immutable/nodes/4.CHTKk5QR.js.gz +0 -0
  53. package/build/client/_app/version.json +1 -1
  54. package/build/client/_app/version.json.br +0 -0
  55. package/build/client/_app/version.json.gz +0 -0
  56. package/build/handler.js +7 -7
  57. package/build/index.js +7 -7
  58. package/build/server/chunks/chunks/{exports.js-aVVdkXgn.js → exports.js-BcB5q0h5.js} +2 -2
  59. package/build/server/chunks/chunks/{exports.js-aVVdkXgn.js.map → exports.js-BcB5q0h5.js.map} +1 -1
  60. package/build/server/chunks/chunks/{internal.js-C6cIfe5d.js → internal.js-B6eePlP4.js} +2 -2
  61. package/build/server/chunks/chunks/{internal.js-C6cIfe5d.js.map → internal.js-B6eePlP4.js.map} +1 -1
  62. package/build/server/chunks/chunks/internal2.js-BlXt2rfE.js.map +1 -1
  63. package/build/server/chunks/chunks/{shared.js-BNd0HSxp.js → shared.js-BO7vJlPc.js} +2 -2
  64. package/build/server/chunks/chunks/{shared.js-BNd0HSxp.js.map → shared.js-BO7vJlPc.js.map} +1 -1
  65. package/build/server/chunks/chunks/{utils.js-6HSqnONB.js → utils.js-BTV5xnNv.js} +3 -3
  66. package/build/server/chunks/chunks/{utils.js-6HSqnONB.js.map → utils.js-BTV5xnNv.js.map} +1 -1
  67. package/build/server/chunks/{handler-rG16rikT.js → handler-DQdjBhko.js} +5 -5
  68. package/build/server/chunks/{handler-rG16rikT.js.map → handler-DQdjBhko.js.map} +1 -1
  69. package/build/server/chunks/{index.js-DYK_SfL5.js → index.js-DtKY5QD2.js} +5 -5
  70. package/build/server/chunks/{index.js-DYK_SfL5.js.map → index.js-DtKY5QD2.js.map} +1 -1
  71. package/build/server/chunks/{manifest.js-h2lDpEPs.js → manifest.js-CJMiboeI.js} +7 -7
  72. package/build/server/chunks/{manifest.js-h2lDpEPs.js.map → manifest.js-CJMiboeI.js.map} +1 -1
  73. package/build/server/chunks/nodes/{0.js-Db28RwnA.js → 0.js-Ce__dNtc.js} +2 -2
  74. package/build/server/chunks/nodes/{0.js-Db28RwnA.js.map → 0.js-Ce__dNtc.js.map} +1 -1
  75. package/build/server/chunks/nodes/1.js-BIwe5V8L.js +7 -0
  76. package/build/server/chunks/nodes/{1.js-D26Uv3YT.js.map → 1.js-BIwe5V8L.js.map} +1 -1
  77. package/build/server/chunks/nodes/2.js-Bj7c0eH1.js +7 -0
  78. package/build/server/chunks/nodes/{2.js-BeRlEFid.js.map → 2.js-Bj7c0eH1.js.map} +1 -1
  79. package/build/server/chunks/nodes/3.js-BMOf9FTK.js +7 -0
  80. package/build/server/chunks/nodes/{3.js-BRUNmCyG.js.map → 3.js-BMOf9FTK.js.map} +1 -1
  81. package/build/server/chunks/nodes/4.js-CNLb7Xc5.js +7 -0
  82. package/build/server/chunks/nodes/{4.js-CTHWqDf1.js.map → 4.js-CNLb7Xc5.js.map} +1 -1
  83. package/dist/runtime/chroma-key-video-lifecycle.d.ts +2 -2
  84. package/dist/runtime/chroma-key-video-lifecycle.js +221 -124
  85. package/dist/runtime/components/ChromaKeyVideo.svelte +13 -2
  86. package/dist/runtime/components/Slice.svelte +22 -12
  87. package/dist/runtime/components/Slice.svelte.d.ts +3 -6
  88. package/dist/runtime/components/SliceCanvas.svelte +51 -40
  89. package/dist/runtime/components/SliceCanvas.svelte.d.ts +2 -2
  90. package/dist/runtime/components/SliceDom.svelte +96 -42
  91. package/dist/runtime/components/SliceDom.svelte.d.ts +2 -2
  92. package/dist/runtime/slice-source-lifecycle.d.ts +14 -0
  93. package/dist/runtime/slice-source-lifecycle.js +70 -0
  94. package/package.json +4 -1
  95. package/build/client/_app/immutable/assets/LibraryContextMenu.BSnKt_Is.css +0 -1
  96. package/build/client/_app/immutable/assets/LibraryContextMenu.BSnKt_Is.css.br +0 -0
  97. package/build/client/_app/immutable/assets/LibraryContextMenu.BSnKt_Is.css.gz +0 -0
  98. package/build/client/_app/immutable/chunks/BAva9YXm.js +0 -1
  99. package/build/client/_app/immutable/chunks/BAva9YXm.js.br +0 -0
  100. package/build/client/_app/immutable/chunks/BAva9YXm.js.gz +0 -0
  101. package/build/client/_app/immutable/chunks/BGAUmugt.js +0 -1
  102. package/build/client/_app/immutable/chunks/BGAUmugt.js.br +0 -0
  103. package/build/client/_app/immutable/chunks/BGAUmugt.js.gz +0 -0
  104. package/build/client/_app/immutable/chunks/BMORe2Gb.js.br +0 -0
  105. package/build/client/_app/immutable/chunks/CBygkKLq.js +0 -1
  106. package/build/client/_app/immutable/chunks/CBygkKLq.js.br +0 -0
  107. package/build/client/_app/immutable/chunks/CBygkKLq.js.gz +0 -0
  108. package/build/client/_app/immutable/chunks/Cil0r9BR.js +0 -108
  109. package/build/client/_app/immutable/chunks/Cil0r9BR.js.br +0 -0
  110. package/build/client/_app/immutable/chunks/Cil0r9BR.js.gz +0 -0
  111. package/build/client/_app/immutable/chunks/D-p-QSzz.js +0 -1
  112. package/build/client/_app/immutable/chunks/D-p-QSzz.js.br +0 -0
  113. package/build/client/_app/immutable/chunks/D-p-QSzz.js.gz +0 -0
  114. package/build/client/_app/immutable/chunks/D15yxLq9.js +0 -1
  115. package/build/client/_app/immutable/chunks/D15yxLq9.js.br +0 -0
  116. package/build/client/_app/immutable/chunks/D15yxLq9.js.gz +0 -0
  117. package/build/client/_app/immutable/chunks/DFRlZOKz.js.br +0 -0
  118. package/build/client/_app/immutable/chunks/DFRlZOKz.js.gz +0 -0
  119. package/build/client/_app/immutable/chunks/DpFtnEPx.js.br +0 -4
  120. package/build/client/_app/immutable/chunks/DpFtnEPx.js.gz +0 -0
  121. package/build/client/_app/immutable/entry/app.BaP08B0_.js +0 -2
  122. package/build/client/_app/immutable/entry/app.BaP08B0_.js.br +0 -0
  123. package/build/client/_app/immutable/entry/app.BaP08B0_.js.gz +0 -0
  124. package/build/client/_app/immutable/entry/start.DntfygIK.js +0 -1
  125. package/build/client/_app/immutable/entry/start.DntfygIK.js.br +0 -0
  126. package/build/client/_app/immutable/entry/start.DntfygIK.js.gz +0 -0
  127. package/build/client/_app/immutable/nodes/0.6A6JK0Xm.js +0 -1
  128. package/build/client/_app/immutable/nodes/0.6A6JK0Xm.js.br +0 -0
  129. package/build/client/_app/immutable/nodes/0.6A6JK0Xm.js.gz +0 -0
  130. package/build/client/_app/immutable/nodes/1.8K7wLW9R.js +0 -1
  131. package/build/client/_app/immutable/nodes/1.8K7wLW9R.js.br +0 -0
  132. package/build/client/_app/immutable/nodes/1.8K7wLW9R.js.gz +0 -0
  133. package/build/client/_app/immutable/nodes/2.CB_PeUBC.js +0 -3
  134. package/build/client/_app/immutable/nodes/2.CB_PeUBC.js.br +0 -0
  135. package/build/client/_app/immutable/nodes/2.CB_PeUBC.js.gz +0 -0
  136. package/build/client/_app/immutable/nodes/3.qIkVQyP6.js +0 -1
  137. package/build/client/_app/immutable/nodes/3.qIkVQyP6.js.br +0 -0
  138. package/build/client/_app/immutable/nodes/3.qIkVQyP6.js.gz +0 -0
  139. package/build/client/_app/immutable/nodes/4.SmoVIy5D.js +0 -2
  140. package/build/client/_app/immutable/nodes/4.SmoVIy5D.js.br +0 -0
  141. package/build/client/_app/immutable/nodes/4.SmoVIy5D.js.gz +0 -0
  142. package/build/server/chunks/nodes/1.js-D26Uv3YT.js +0 -7
  143. package/build/server/chunks/nodes/2.js-BeRlEFid.js +0 -7
  144. package/build/server/chunks/nodes/3.js-BRUNmCyG.js +0 -7
  145. package/build/server/chunks/nodes/4.js-CTHWqDf1.js +0 -7
@@ -5,63 +5,82 @@ function copyChromaKey(parameters) {
5
5
  keyColor: [...parameters.keyColor]
6
6
  };
7
7
  }
8
- /** Internal media state machine for a single, stable video element. */
8
+ function copyPlaybackSettings(from, to) {
9
+ to.loop = from.loop;
10
+ to.playbackRate = from.playbackRate;
11
+ }
12
+ /** Internal media state machine that stages replacements before presenting them. */
9
13
  export class ChromaKeyVideoLifecycle {
10
- #video;
14
+ #videos;
11
15
  #renderer;
12
- #active = null;
16
+ #onPresentedVideoChange;
17
+ #presented = null;
18
+ #loading = null;
13
19
  #sourceGeneration = 0;
14
20
  #playing = false;
15
21
  #disposed = false;
22
+ #frameHeld = false;
16
23
  #loopGeneration = 0;
17
24
  #loopRunning = false;
18
25
  #pendingFrame = null;
19
- constructor(video, renderer) {
20
- this.#video = video;
26
+ constructor(video, renderer, standbyVideo = video, onPresentedVideoChange) {
27
+ this.#videos = [video, standbyVideo];
21
28
  this.#renderer = renderer;
22
- video.addEventListener('play', this.#handlePlay);
23
- video.addEventListener('pause', this.#handleStop);
24
- video.addEventListener('ended', this.#handleStop);
25
- video.addEventListener('seeked', this.#handleSeeked);
26
- video.addEventListener('loadeddata', this.#handleLoadedData);
27
- video.addEventListener('error', this.#handleError);
29
+ this.#onPresentedVideoChange = onPresentedVideoChange;
30
+ for (const element of new Set(this.#videos))
31
+ this.#listen(element);
28
32
  }
29
33
  switchSource(snapshot) {
30
34
  if (this.#disposed)
31
35
  return;
32
- const previous = this.#active;
33
- if (previous &&
34
- (previous.status === 'loading' || previous.status === 'settled') &&
35
- previous.rawVideoUrl === snapshot.rawVideoUrl &&
36
- previous.maskUrl === snapshot.maskUrl) {
36
+ const loading = this.#loading;
37
+ if (loading && this.#matches(loading, snapshot) && loading.status === 'loading')
38
+ return;
39
+ const presented = this.#presented;
40
+ if (presented && this.#matches(presented, snapshot) && presented.status === 'settled') {
41
+ this.#cancelLoading();
42
+ presented.chromaKey = copyChromaKey(snapshot.chromaKey);
37
43
  return;
38
44
  }
39
- this.#stopLoop();
40
- this.#invalidatePlayRequest(previous);
45
+ this.#cancelLoading();
46
+ const loadingVideo = this.#loadingVideoFor(presented);
47
+ const playAfterHandoff = presented
48
+ ? this.#playing &&
49
+ !presented.video.ended &&
50
+ (!presented.video.paused || presented.playRequestPending)
51
+ : this.#playing;
52
+ if (presented && loadingVideo === presented.video) {
53
+ this.#invalidatePlayRequest(presented);
54
+ this.#stopLoop();
55
+ presented.status = 'disposed';
56
+ this.#presented = null;
57
+ presented.video.pause();
58
+ }
41
59
  const active = {
42
60
  generation: ++this.#sourceGeneration,
43
61
  status: 'loading',
62
+ video: loadingVideo,
44
63
  rawVideoUrl: snapshot.rawVideoUrl,
45
64
  normalizedVideoUrl: snapshot.resolvedVideoUrl,
46
65
  maskUrl: snapshot.maskUrl,
66
+ maskImage: null,
47
67
  chromaKey: copyChromaKey(snapshot.chromaKey),
48
68
  mediaSettled: false,
49
69
  maskSettled: !snapshot.maskUrl,
70
+ playAfterHandoff,
50
71
  playRequestToken: 0,
51
72
  playRequestPending: false,
52
73
  playRejectedForCurrentIntent: false
53
74
  };
54
- this.#active = active;
55
- this.#video.pause();
56
- this.#renderer.setMask(null);
57
- // Keep the last presented canvas frame visible while the replacement video
58
- // and mask settle. Clearing here exposes a transparent frame between the old
59
- // draw and the new source's first decodable frame.
60
- this.#video.src = snapshot.resolvedVideoUrl;
75
+ this.#loading = active;
76
+ if (!this.#frameHeld)
77
+ this.#renderer.setMask(null);
78
+ loadingVideo.pause();
79
+ loadingVideo.src = snapshot.resolvedVideoUrl;
61
80
  // Reading the reflected property gives the browser-normalized absolute URL.
62
- active.normalizedVideoUrl = this.#video.src;
63
- this.#video.load();
64
- this.#primeMedia();
81
+ active.normalizedVideoUrl = loadingVideo.src;
82
+ loadingVideo.load();
83
+ this.#primeMedia(active);
65
84
  if (snapshot.maskUrl)
66
85
  this.#loadMask(active, snapshot.maskUrl);
67
86
  }
@@ -70,30 +89,34 @@ export class ChromaKeyVideoLifecycle {
70
89
  return;
71
90
  const changed = this.#playing !== playing;
72
91
  this.#playing = playing;
73
- const active = this.#active;
92
+ const presented = this.#presented;
93
+ const loading = this.#loading;
94
+ if (loading?.status === 'loading')
95
+ loading.playAfterHandoff = playing;
74
96
  if (!playing) {
75
- this.#invalidatePlayRequest(active);
97
+ this.#invalidatePlayRequest(presented);
76
98
  this.#stopLoop();
77
- // Keep the muted loading warm-up alive until the browser has produced a
78
- // decodable frame. Some engines treat preload="auto" as metadata-only and
79
- // do not reach loadeddata until playback has started.
80
- if (!active || active.status !== 'loading' || active.mediaSettled) {
81
- this.#video.pause();
82
- }
99
+ presented?.video.pause();
83
100
  return;
84
101
  }
85
- if (changed && active)
86
- active.playRejectedForCurrentIntent = false;
87
- this.#requestPlayback(active);
102
+ if (changed && presented)
103
+ presented.playRejectedForCurrentIntent = false;
104
+ if (presented) {
105
+ presented.playAfterHandoff = true;
106
+ this.#requestPlayback(presented);
107
+ }
88
108
  }
89
109
  setChromaKey(parameters) {
90
110
  if (this.#disposed)
91
111
  return;
92
- const active = this.#active;
112
+ const active = this.#loading ?? this.#presented;
93
113
  if (!active || active.status === 'failed' || active.status === 'disposed')
94
114
  return;
95
115
  active.chromaKey = copyChromaKey(parameters);
96
- if (active.status === 'settled' && this.#video.paused && this.#isCurrentResource(active)) {
116
+ if (active === this.#presented &&
117
+ active.status === 'settled' &&
118
+ active.video.paused &&
119
+ this.#isCurrentResource(active)) {
97
120
  this.#draw(active);
98
121
  }
99
122
  }
@@ -102,19 +125,52 @@ export class ChromaKeyVideoLifecycle {
102
125
  return;
103
126
  this.#disposed = true;
104
127
  this.#sourceGeneration++;
105
- const active = this.#active;
106
- this.#invalidatePlayRequest(active);
128
+ this.#invalidatePlayRequest(this.#presented);
129
+ this.#invalidatePlayRequest(this.#loading);
107
130
  this.#stopLoop();
108
- if (active)
109
- active.status = 'disposed';
110
- this.#active = null;
111
- this.#video.removeEventListener('play', this.#handlePlay);
112
- this.#video.removeEventListener('pause', this.#handleStop);
113
- this.#video.removeEventListener('ended', this.#handleStop);
114
- this.#video.removeEventListener('seeked', this.#handleSeeked);
115
- this.#video.removeEventListener('loadeddata', this.#handleLoadedData);
116
- this.#video.removeEventListener('error', this.#handleError);
117
- this.#video.pause();
131
+ if (this.#presented)
132
+ this.#presented.status = 'disposed';
133
+ if (this.#loading)
134
+ this.#loading.status = 'disposed';
135
+ this.#presented = null;
136
+ this.#loading = null;
137
+ for (const video of new Set(this.#videos)) {
138
+ this.#unlisten(video);
139
+ video.pause();
140
+ }
141
+ }
142
+ #listen(video) {
143
+ video.addEventListener('play', this.#handlePlay);
144
+ video.addEventListener('pause', this.#handleStop);
145
+ video.addEventListener('ended', this.#handleStop);
146
+ video.addEventListener('seeked', this.#handleSeeked);
147
+ video.addEventListener('loadeddata', this.#handleLoadedData);
148
+ video.addEventListener('error', this.#handleError);
149
+ }
150
+ #unlisten(video) {
151
+ video.removeEventListener('play', this.#handlePlay);
152
+ video.removeEventListener('pause', this.#handleStop);
153
+ video.removeEventListener('ended', this.#handleStop);
154
+ video.removeEventListener('seeked', this.#handleSeeked);
155
+ video.removeEventListener('loadeddata', this.#handleLoadedData);
156
+ video.removeEventListener('error', this.#handleError);
157
+ }
158
+ #matches(active, snapshot) {
159
+ return active.rawVideoUrl === snapshot.rawVideoUrl && active.maskUrl === snapshot.maskUrl;
160
+ }
161
+ #loadingVideoFor(presented) {
162
+ if (!presented)
163
+ return this.#videos[0];
164
+ return this.#videos[0] === presented.video ? this.#videos[1] : this.#videos[0];
165
+ }
166
+ #cancelLoading() {
167
+ const loading = this.#loading;
168
+ if (!loading)
169
+ return;
170
+ this.#invalidatePlayRequest(loading);
171
+ loading.status = 'disposed';
172
+ loading.video.pause();
173
+ this.#loading = null;
118
174
  }
119
175
  #loadMask(active, maskUrl) {
120
176
  const image = new Image();
@@ -122,122 +178,161 @@ export class ChromaKeyVideoLifecycle {
122
178
  image
123
179
  .decode()
124
180
  .then(() => {
125
- if (!this.#isActive(active) || active.status !== 'loading')
181
+ if (!this.#isLoading(active))
126
182
  return;
127
- this.#renderer.setMask(image);
183
+ active.maskImage = image;
128
184
  active.maskSettled = true;
129
185
  this.#settleIfReady(active);
130
186
  })
131
187
  .catch(() => {
132
- if (!this.#isActive(active) || active.status !== 'loading')
188
+ if (!this.#isLoading(active))
133
189
  return;
134
190
  console.warn(`Failed to load chroma-key mask "${maskUrl}".`);
135
191
  active.maskSettled = true;
136
192
  this.#settleIfReady(active);
137
193
  });
138
194
  }
139
- #primeMedia() {
195
+ #primeMedia(active) {
140
196
  let request;
141
197
  try {
142
- request = this.#video.play();
198
+ request = active.video.play();
143
199
  }
144
200
  catch {
145
201
  return;
146
202
  }
147
203
  Promise.resolve(request).catch(() => {
148
- // Muted warm-up is best-effort. If preload still reaches loadeddata, the
149
- // normal settled playback request below gets its own retry state.
204
+ // Muted warm-up is best-effort. preload may still reach loadeddata.
150
205
  });
151
206
  }
152
- #handleLoadedData = () => {
153
- const active = this.#active;
154
- if (!active || active.status !== 'loading')
207
+ #handleLoadedData = (event) => {
208
+ const active = this.#loading;
209
+ if (!active || active.video !== event.currentTarget || !this.#isLoading(active))
155
210
  return;
156
211
  if (!this.#isCurrentResource(active))
157
212
  return;
158
213
  active.mediaSettled = true;
159
- if (!this.#playing)
160
- this.#video.pause();
214
+ // Freeze the staged source at its first decodable frame until its mask is ready.
215
+ active.video.pause();
161
216
  this.#settleIfReady(active);
162
217
  };
163
- #handleError = () => {
164
- const active = this.#active;
165
- if (!active || active.status === 'failed' || active.status === 'disposed')
218
+ #handleError = (event) => {
219
+ const video = event.currentTarget;
220
+ const loading = this.#loading;
221
+ if (loading?.video === video && loading.status === 'loading') {
222
+ loading.status = 'failed';
223
+ this.#invalidatePlayRequest(loading);
224
+ loading.video.pause();
225
+ // A failed replacement never erases an already presented frame.
226
+ if (!this.#presented && !this.#frameHeld)
227
+ this.#clear();
166
228
  return;
167
- active.status = 'failed';
168
- this.#invalidatePlayRequest(active);
229
+ }
230
+ const presented = this.#presented;
231
+ if (!presented || presented.video !== video || presented.status !== 'settled')
232
+ return;
233
+ presented.status = 'failed';
234
+ this.#invalidatePlayRequest(presented);
169
235
  this.#stopLoop();
170
- this.#video.pause();
171
- this.#renderer.clear();
236
+ presented.video.pause();
237
+ if (!this.#loading)
238
+ this.#clear();
172
239
  };
173
- #handlePlay = () => {
174
- const active = this.#active;
175
- // Loading playback only warms the hidden media element up to loadeddata.
176
- // Drawing and the frame loop remain gated on video + mask settlement.
177
- if (active?.status === 'loading')
240
+ #handlePlay = (event) => {
241
+ const video = event.currentTarget;
242
+ if (this.#loading?.video === video)
178
243
  return;
179
- if (!active || active.status !== 'settled') {
180
- this.#stopLoop();
181
- this.#video.pause();
244
+ const presented = this.#presented;
245
+ if (!presented || presented.video !== video || presented.status !== 'settled') {
246
+ if (video && 'pause' in video)
247
+ video.pause();
182
248
  return;
183
249
  }
184
- active.playRejectedForCurrentIntent = false;
185
- if (!this.#video.paused)
186
- this.#startLoop(active);
250
+ presented.playRejectedForCurrentIntent = false;
251
+ presented.playAfterHandoff = true;
252
+ if (this.#loading?.status === 'loading')
253
+ this.#loading.playAfterHandoff = true;
254
+ if (!presented.video.paused)
255
+ this.#startLoop(presented);
187
256
  };
188
- #handleStop = () => {
189
- const active = this.#active;
257
+ #handleStop = (event) => {
258
+ const presented = this.#presented;
259
+ if (!presented || presented.video !== event.currentTarget || presented.status !== 'settled')
260
+ return;
261
+ this.#invalidatePlayRequest(presented);
190
262
  this.#stopLoop();
191
- if (active?.status === 'settled')
192
- this.#draw(active);
263
+ this.#draw(presented);
264
+ presented.playAfterHandoff = false;
265
+ if (this.#loading?.status === 'loading')
266
+ this.#loading.playAfterHandoff = false;
193
267
  };
194
- #handleSeeked = () => {
195
- const active = this.#active;
196
- if (!active || active.status !== 'settled' || !this.#video.paused)
268
+ #handleSeeked = (event) => {
269
+ const presented = this.#presented;
270
+ if (!presented ||
271
+ presented.video !== event.currentTarget ||
272
+ presented.status !== 'settled' ||
273
+ !presented.video.paused) {
197
274
  return;
198
- this.#draw(active);
275
+ }
276
+ this.#draw(presented);
199
277
  };
200
278
  #settleIfReady(active) {
201
- if (!this.#isActive(active) ||
202
- active.status !== 'loading' ||
279
+ if (!this.#isLoading(active) ||
203
280
  !active.mediaSettled ||
204
- !active.maskSettled) {
281
+ !active.maskSettled ||
282
+ !this.#isCurrentResource(active)) {
205
283
  return;
206
284
  }
207
- if (!this.#isCurrentResource(active)) {
208
- active.mediaSettled = false;
209
- return;
285
+ const previous = this.#presented;
286
+ this.#stopLoop();
287
+ this.#invalidatePlayRequest(previous);
288
+ if (previous && previous.video !== active.video) {
289
+ copyPlaybackSettings(previous.video, active.video);
210
290
  }
211
- if (!this.#playing && !this.#video.paused)
212
- this.#video.pause();
291
+ active.video.pause();
213
292
  active.status = 'settled';
293
+ this.#loading = null;
294
+ this.#presented = active;
295
+ this.#renderer.setMask(active.maskImage);
214
296
  this.#draw(active);
215
- if (this.#playing && !this.#video.paused)
216
- this.#startLoop(active);
217
- else
297
+ this.#onPresentedVideoChange?.(active.video);
298
+ if (previous && previous !== active) {
299
+ previous.status = 'disposed';
300
+ previous.video.pause();
301
+ }
302
+ if (this.#playing && active.playAfterHandoff)
218
303
  this.#requestPlayback(active);
219
304
  }
220
305
  #draw(active) {
221
- if (!this.#isActive(active) ||
306
+ if (this.#disposed ||
307
+ this.#presented !== active ||
222
308
  active.status !== 'settled' ||
223
309
  !this.#isCurrentResource(active)) {
224
310
  return;
225
311
  }
226
- this.#renderer.draw(this.#video, active.chromaKey);
312
+ this.#renderer.draw(active.video, active.chromaKey);
313
+ this.#frameHeld = true;
314
+ }
315
+ #clear() {
316
+ this.#renderer.clear();
317
+ this.#frameHeld = false;
227
318
  }
228
319
  #isCurrentResource(active) {
229
- return (this.#video.readyState >= HAVE_CURRENT_DATA &&
230
- this.#video.currentSrc === active.normalizedVideoUrl);
320
+ return (active.video.readyState >= HAVE_CURRENT_DATA &&
321
+ active.video.currentSrc === active.normalizedVideoUrl);
231
322
  }
232
- #isActive(active) {
233
- return (!this.#disposed && this.#active === active && active.generation === this.#sourceGeneration);
323
+ #isLoading(active) {
324
+ return (!this.#disposed &&
325
+ this.#loading === active &&
326
+ active.status === 'loading' &&
327
+ active.generation === this.#sourceGeneration);
234
328
  }
235
329
  #requestPlayback(active) {
236
- if (!active ||
237
- !this.#isActive(active) ||
330
+ if (this.#disposed ||
331
+ this.#presented !== active ||
238
332
  active.status !== 'settled' ||
239
333
  !this.#playing ||
240
- !this.#video.paused ||
334
+ !active.playAfterHandoff ||
335
+ !active.video.paused ||
241
336
  active.playRequestPending ||
242
337
  active.playRejectedForCurrentIntent) {
243
338
  return;
@@ -246,7 +341,7 @@ export class ChromaKeyVideoLifecycle {
246
341
  active.playRequestPending = true;
247
342
  let request;
248
343
  try {
249
- request = this.#video.play();
344
+ request = active.video.play();
250
345
  }
251
346
  catch {
252
347
  this.#finishPlayRequest(active, requestToken, false);
@@ -255,7 +350,7 @@ export class ChromaKeyVideoLifecycle {
255
350
  Promise.resolve(request).then(() => this.#finishPlayRequest(active, requestToken, true), () => this.#finishPlayRequest(active, requestToken, false));
256
351
  }
257
352
  #finishPlayRequest(active, requestToken, succeeded) {
258
- if (!this.#isActive(active) || active.playRequestToken !== requestToken)
353
+ if (this.#disposed || this.#presented !== active || active.playRequestToken !== requestToken)
259
354
  return;
260
355
  active.playRequestPending = false;
261
356
  active.playRejectedForCurrentIntent = !succeeded;
@@ -268,9 +363,10 @@ export class ChromaKeyVideoLifecycle {
268
363
  }
269
364
  #startLoop(active) {
270
365
  if (this.#loopRunning ||
271
- !this.#isActive(active) ||
366
+ this.#disposed ||
367
+ this.#presented !== active ||
272
368
  active.status !== 'settled' ||
273
- this.#video.paused) {
369
+ active.video.paused) {
274
370
  return;
275
371
  }
276
372
  this.#loopRunning = true;
@@ -279,6 +375,7 @@ export class ChromaKeyVideoLifecycle {
279
375
  }
280
376
  #scheduleNextFrame(active, loopGeneration) {
281
377
  const sourceGeneration = active.generation;
378
+ const video = active.video;
282
379
  let handle = 0;
283
380
  const callback = () => {
284
381
  const pending = this.#pendingFrame;
@@ -287,28 +384,28 @@ export class ChromaKeyVideoLifecycle {
287
384
  pending.handle !== handle ||
288
385
  this.#loopGeneration !== loopGeneration ||
289
386
  !this.#loopRunning ||
290
- !this.#isActive(active) ||
387
+ this.#presented !== active ||
291
388
  active.generation !== sourceGeneration ||
292
389
  active.status !== 'settled' ||
293
- this.#video.paused) {
390
+ video.paused) {
294
391
  return;
295
392
  }
296
393
  this.#pendingFrame = null;
297
394
  this.#draw(active);
298
395
  if (this.#loopRunning &&
299
396
  this.#loopGeneration === loopGeneration &&
300
- this.#isActive(active) &&
301
- !this.#video.paused) {
397
+ this.#presented === active &&
398
+ !video.paused) {
302
399
  this.#scheduleNextFrame(active, loopGeneration);
303
400
  }
304
401
  };
305
- if (this.#video.requestVideoFrameCallback) {
306
- handle = this.#video.requestVideoFrameCallback(callback);
307
- this.#pendingFrame = { generation: loopGeneration, kind: 'rvfc', handle };
402
+ if (video.requestVideoFrameCallback) {
403
+ handle = video.requestVideoFrameCallback(callback);
404
+ this.#pendingFrame = { generation: loopGeneration, kind: 'rvfc', handle, video };
308
405
  }
309
406
  else {
310
407
  handle = requestAnimationFrame(callback);
311
- this.#pendingFrame = { generation: loopGeneration, kind: 'raf', handle };
408
+ this.#pendingFrame = { generation: loopGeneration, kind: 'raf', handle, video };
312
409
  }
313
410
  }
314
411
  #stopLoop() {
@@ -319,7 +416,7 @@ export class ChromaKeyVideoLifecycle {
319
416
  if (!pending)
320
417
  return;
321
418
  if (pending.kind === 'rvfc')
322
- this.#video.cancelVideoFrameCallback?.(pending.handle);
419
+ pending.video.cancelVideoFrameCallback?.(pending.handle);
323
420
  else
324
421
  cancelAnimationFrame(pending.handle);
325
422
  }
@@ -33,6 +33,8 @@
33
33
  }: Props = $props();
34
34
 
35
35
  let canvasElement = $state<HTMLCanvasElement>();
36
+ let primaryVideoElement = $state<HTMLVideoElement>();
37
+ let standbyVideoElement = $state<HTMLVideoElement>();
36
38
  let webglUnavailable = $state(false);
37
39
 
38
40
  let renderer: ChromaKeyGl | null = null;
@@ -49,7 +51,15 @@
49
51
  return;
50
52
  }
51
53
  const activeRenderer = renderer;
52
- const activeLifecycle = new ChromaKeyVideoLifecycle(videoElement!, activeRenderer);
54
+ videoElement = primaryVideoElement!;
55
+ const activeLifecycle = new ChromaKeyVideoLifecycle(
56
+ primaryVideoElement!,
57
+ activeRenderer,
58
+ standbyVideoElement!,
59
+ (activeVideo) => {
60
+ videoElement = activeVideo;
61
+ }
62
+ );
53
63
  lifecycle = activeLifecycle;
54
64
 
55
65
  return () => {
@@ -102,7 +112,8 @@
102
112
  {/if}
103
113
  {:else}
104
114
  <canvas bind:this={canvasElement}></canvas>
105
- <video bind:this={videoElement} {loop} muted playsinline preload="auto"></video>
115
+ <video bind:this={primaryVideoElement} {loop} muted playsinline preload="auto"></video>
116
+ <video bind:this={standbyVideoElement} {loop} muted playsinline preload="auto"></video>
106
117
  {/if}
107
118
  </div>
108
119
 
@@ -1,23 +1,23 @@
1
1
  <!--
2
- The slice component hosts render: one component, backend chosen
3
- per use. Dispatches between the two private implementations — SliceDom
4
- (default) and SliceCanvas — which share the same geometry
5
- (`computeRenderCells`) and therefore render the same cells.
2
+ The slice component hosts rendering: one component, backend chosen per use.
3
+ Dispatches between the two private implementations — SliceDom (default) and
4
+ SliceCanvas — which share `computeRenderCells` geometry. The DOM backend maps
5
+ that geometry to one CSS border-image paint; Canvas draws the cells into one
6
+ canvas backing store.
6
7
  -->
7
8
  <script lang="ts">
9
+ import { onDestroy } from 'svelte';
8
10
  import type { SliceManifestEntry } from '../../core/index.js';
11
+ import { SliceSourceLifecycle, type LoadedSliceSnapshot } from '../slice-source-lifecycle.js';
9
12
  import SliceDom from './SliceDom.svelte';
10
13
  import SliceCanvas from './SliceCanvas.svelte';
11
14
 
12
15
  interface Props {
13
16
  src: SliceManifestEntry;
14
17
  /**
15
- * Rendering backend. `dom` (default) paints one absolutely-positioned
16
- * `<img>` window per cell; `canvas` draws every cell into a single
17
- * `<canvas>`. Both produce the same cells — but the DOM backend can
18
- * flicker in some situations (cells are separate elements the browser
19
- * composites independently, so a resize mid-animation can tear), so
20
- * hosts pick `canvas` where that hurts.
18
+ * Rendering backend. `dom` (default) paints the shared slice geometry as
19
+ * one CSS `border-image`; `canvas` draws the same cells into one `<canvas>`
20
+ * with a device-pixel-ratio-sized backing store.
21
21
  */
22
22
  backend?: 'dom' | 'canvas';
23
23
  /** Accessible label for the rendered graphic. */
@@ -26,10 +26,20 @@
26
26
  }
27
27
 
28
28
  let { src, backend = 'dom', alt = '', class: className = '' }: Props = $props();
29
+ let snapshot = $state.raw<LoadedSliceSnapshot | null>(null);
30
+ const sourceLifecycle = new SliceSourceLifecycle((loaded) => {
31
+ snapshot = loaded;
32
+ });
33
+
34
+ $effect(() => {
35
+ sourceLifecycle.request(src);
36
+ });
37
+
38
+ onDestroy(() => sourceLifecycle.dispose());
29
39
  </script>
30
40
 
31
41
  {#if backend === 'canvas'}
32
- <SliceCanvas {src} {alt} class={className} />
42
+ <SliceCanvas {snapshot} {alt} class={className} />
33
43
  {:else}
34
- <SliceDom {src} {alt} class={className} />
44
+ <SliceDom {snapshot} {alt} class={className} />
35
45
  {/if}
@@ -2,12 +2,9 @@ import type { SliceManifestEntry } from '../../core/index.js';
2
2
  interface Props {
3
3
  src: SliceManifestEntry;
4
4
  /**
5
- * Rendering backend. `dom` (default) paints one absolutely-positioned
6
- * `<img>` window per cell; `canvas` draws every cell into a single
7
- * `<canvas>`. Both produce the same cells — but the DOM backend can
8
- * flicker in some situations (cells are separate elements the browser
9
- * composites independently, so a resize mid-animation can tear), so
10
- * hosts pick `canvas` where that hurts.
5
+ * Rendering backend. `dom` (default) paints the shared slice geometry as
6
+ * one CSS `border-image`; `canvas` draws the same cells into one `<canvas>`
7
+ * with a device-pixel-ratio-sized backing store.
11
8
  */
12
9
  backend?: 'dom' | 'canvas';
13
10
  /** Accessible label for the rendered graphic. */