canvas-recorder-lib 0.5.0 → 0.6.0

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 (317) hide show
  1. package/BREAKING-CHANGES.md +18 -0
  2. package/README.md +112 -20
  3. package/dev/audio-builder.ts +4 -2
  4. package/dev/canvas-recorder.css +59 -0
  5. package/dev/canvas-recorder.ts +679 -51
  6. package/dev/delay-files.ts +1 -1
  7. package/dev/font-utils.ts +8 -1
  8. package/dev/format-editor.ts +734 -0
  9. package/dev/format-string.ts +110 -0
  10. package/dev/local-file-row.ts +107 -0
  11. package/dev/multi-text-dialog.ts +360 -0
  12. package/dev/multi-text-panel.ts +11 -0
  13. package/dev/player-body.ts +86 -6
  14. package/dev/rect-snap.ts +9 -9
  15. package/dev/slide-panel.ts +1 -1
  16. package/dev/split-dialog.ts +13 -2
  17. package/dev/static-image-panel.ts +35 -5
  18. package/dev/timeline-display.ts +50 -5
  19. package/dev/transcripts.ts +5 -3
  20. package/dev/video-clip-panel.ts +201 -15
  21. package/dist/dev/audio-builder.d.ts +1 -1
  22. package/dist/dev/audio-builder.d.ts.map +1 -1
  23. package/dist/dev/audio-builder.js +3 -2
  24. package/dist/dev/audio-builder.js.map +1 -1
  25. package/dist/dev/canvas-recorder.css +59 -0
  26. package/dist/dev/canvas-recorder.js +612 -50
  27. package/dist/dev/canvas-recorder.js.map +1 -1
  28. package/dist/dev/delay-files.js +1 -1
  29. package/dist/dev/delay-files.js.map +1 -1
  30. package/dist/dev/font-utils.d.ts.map +1 -1
  31. package/dist/dev/font-utils.js +9 -1
  32. package/dist/dev/font-utils.js.map +1 -1
  33. package/dist/dev/format-editor.d.ts +75 -0
  34. package/dist/dev/format-editor.d.ts.map +1 -0
  35. package/dist/dev/format-editor.js +642 -0
  36. package/dist/dev/format-editor.js.map +1 -0
  37. package/dist/dev/format-string.d.ts +62 -0
  38. package/dist/dev/format-string.d.ts.map +1 -0
  39. package/dist/dev/format-string.js +86 -0
  40. package/dist/dev/format-string.js.map +1 -0
  41. package/dist/dev/local-file-row.d.ts +22 -0
  42. package/dist/dev/local-file-row.d.ts.map +1 -0
  43. package/dist/dev/local-file-row.js +84 -0
  44. package/dist/dev/local-file-row.js.map +1 -0
  45. package/dist/dev/multi-text-dialog.d.ts +30 -0
  46. package/dist/dev/multi-text-dialog.d.ts.map +1 -0
  47. package/dist/dev/multi-text-dialog.js +297 -0
  48. package/dist/dev/multi-text-dialog.js.map +1 -0
  49. package/dist/dev/multi-text-panel.d.ts +2 -0
  50. package/dist/dev/multi-text-panel.d.ts.map +1 -1
  51. package/dist/dev/multi-text-panel.js +4 -0
  52. package/dist/dev/multi-text-panel.js.map +1 -1
  53. package/dist/dev/player-body.d.ts +1 -1
  54. package/dist/dev/player-body.d.ts.map +1 -1
  55. package/dist/dev/player-body.js +86 -6
  56. package/dist/dev/player-body.js.map +1 -1
  57. package/dist/dev/rect-snap.d.ts +1 -1
  58. package/dist/dev/rect-snap.js +7 -7
  59. package/dist/dev/rect-snap.js.map +1 -1
  60. package/dist/dev/slide-panel.d.ts +1 -1
  61. package/dist/dev/slide-panel.d.ts.map +1 -1
  62. package/dist/dev/slide-panel.js +1 -1
  63. package/dist/dev/slide-panel.js.map +1 -1
  64. package/dist/dev/split-dialog.d.ts.map +1 -1
  65. package/dist/dev/split-dialog.js +14 -2
  66. package/dist/dev/split-dialog.js.map +1 -1
  67. package/dist/dev/static-image-panel.d.ts +2 -0
  68. package/dist/dev/static-image-panel.d.ts.map +1 -1
  69. package/dist/dev/static-image-panel.js +29 -5
  70. package/dist/dev/static-image-panel.js.map +1 -1
  71. package/dist/dev/timeline-display.d.ts +23 -2
  72. package/dist/dev/timeline-display.d.ts.map +1 -1
  73. package/dist/dev/timeline-display.js +47 -5
  74. package/dist/dev/timeline-display.js.map +1 -1
  75. package/dist/dev/transcripts.d.ts.map +1 -1
  76. package/dist/dev/transcripts.js +5 -3
  77. package/dist/dev/transcripts.js.map +1 -1
  78. package/dist/dev/video-clip-panel.d.ts +8 -0
  79. package/dist/dev/video-clip-panel.d.ts.map +1 -1
  80. package/dist/dev/video-clip-panel.js +175 -9
  81. package/dist/dev/video-clip-panel.js.map +1 -1
  82. package/dist/lib/index.d.ts +3 -0
  83. package/dist/lib/index.d.ts.map +1 -1
  84. package/dist/lib/index.js +3 -0
  85. package/dist/lib/index.js.map +1 -1
  86. package/dist/lib/main-control.d.ts +50 -4
  87. package/dist/lib/main-control.d.ts.map +1 -1
  88. package/dist/lib/main-control.js +94 -12
  89. package/dist/lib/main-control.js.map +1 -1
  90. package/dist/src/binary-search.js +1 -1
  91. package/dist/src/binary-search.js.map +1 -1
  92. package/dist/src/corner-rounder.d.ts +17 -0
  93. package/dist/src/corner-rounder.d.ts.map +1 -0
  94. package/dist/src/corner-rounder.js +89 -0
  95. package/dist/src/corner-rounder.js.map +1 -0
  96. package/dist/src/cross-fade.d.ts +1 -1
  97. package/dist/src/cross-fade.d.ts.map +1 -1
  98. package/dist/src/cross-fade.js +3 -3
  99. package/dist/src/cross-fade.js.map +1 -1
  100. package/dist/src/fancy-text.d.ts +116 -0
  101. package/dist/src/fancy-text.d.ts.map +1 -0
  102. package/dist/src/fancy-text.js +211 -0
  103. package/dist/src/fancy-text.js.map +1 -0
  104. package/dist/src/glib/letters-base.d.ts +1 -1
  105. package/dist/src/glib/letters-base.d.ts.map +1 -1
  106. package/dist/src/glib/letters-base.js +4 -3
  107. package/dist/src/glib/letters-base.js.map +1 -1
  108. package/dist/src/glib/line-font.d.ts +2 -2
  109. package/dist/src/glib/line-font.d.ts.map +1 -1
  110. package/dist/src/glib/line-font.js +2 -2
  111. package/dist/src/glib/line-font.js.map +1 -1
  112. package/dist/src/glib/paragraph-layout.d.ts +2 -2
  113. package/dist/src/glib/paragraph-layout.d.ts.map +1 -1
  114. package/dist/src/glib/paragraph-layout.js +1 -1
  115. package/dist/src/glib/paragraph-layout.js.map +1 -1
  116. package/dist/src/glib/path-shape-splitter.d.ts +1 -1
  117. package/dist/src/glib/path-shape-splitter.d.ts.map +1 -1
  118. package/dist/src/glib/path-shape-splitter.js +1 -1
  119. package/dist/src/glib/path-shape-splitter.js.map +1 -1
  120. package/dist/src/glib/path-shape.js +2 -2
  121. package/dist/src/glib/path-shape.js.map +1 -1
  122. package/dist/src/interpolate.d.ts +2 -2
  123. package/dist/src/interpolate.d.ts.map +1 -1
  124. package/dist/src/interpolate.js +1 -1
  125. package/dist/src/interpolate.js.map +1 -1
  126. package/dist/src/lattice.d.ts +1 -1
  127. package/dist/src/lattice.d.ts.map +1 -1
  128. package/dist/src/local-files.d.ts +129 -0
  129. package/dist/src/local-files.d.ts.map +1 -0
  130. package/dist/src/local-files.js +327 -0
  131. package/dist/src/local-files.js.map +1 -0
  132. package/dist/src/morph-animation.d.ts +1 -1
  133. package/dist/src/morph-animation.d.ts.map +1 -1
  134. package/dist/src/morph-animation.js +2 -2
  135. package/dist/src/morph-animation.js.map +1 -1
  136. package/dist/src/peano-fourier/fourier-shared.d.ts +1 -1
  137. package/dist/src/peano-fourier/fourier-shared.d.ts.map +1 -1
  138. package/dist/src/peano-fourier/fourier-shared.js +17 -4
  139. package/dist/src/peano-fourier/fourier-shared.js.map +1 -1
  140. package/dist/src/schedule-helper.d.ts +4 -4
  141. package/dist/src/schedule-helper.d.ts.map +1 -1
  142. package/dist/src/schedule-helper.js +2 -2
  143. package/dist/src/schedule-helper.js.map +1 -1
  144. package/dist/src/showable.d.ts +36 -4
  145. package/dist/src/showable.d.ts.map +1 -1
  146. package/dist/src/showable.js +37 -1
  147. package/dist/src/showable.js.map +1 -1
  148. package/dist/src/slide-components/arrow.d.ts +4 -4
  149. package/dist/src/slide-components/arrow.d.ts.map +1 -1
  150. package/dist/src/slide-components/arrow.js +2 -2
  151. package/dist/src/slide-components/arrow.js.map +1 -1
  152. package/dist/src/slide-components/cross-fade-transition.d.ts +3 -3
  153. package/dist/src/slide-components/cross-fade-transition.d.ts.map +1 -1
  154. package/dist/src/slide-components/cross-fade-transition.js +3 -3
  155. package/dist/src/slide-components/cross-fade-transition.js.map +1 -1
  156. package/dist/src/slide-components/duration-agnostic.d.ts +3 -3
  157. package/dist/src/slide-components/duration-agnostic.d.ts.map +1 -1
  158. package/dist/src/slide-components/duration-agnostic.js +1 -1
  159. package/dist/src/slide-components/duration-agnostic.js.map +1 -1
  160. package/dist/src/slide-components/fixed-duration.d.ts +1 -1
  161. package/dist/src/slide-components/fixed-duration.d.ts.map +1 -1
  162. package/dist/src/slide-components/fixed-duration.js +1 -1
  163. package/dist/src/slide-components/fixed-duration.js.map +1 -1
  164. package/dist/src/slide-components/fourier.d.ts +6 -6
  165. package/dist/src/slide-components/fourier.d.ts.map +1 -1
  166. package/dist/src/slide-components/fourier.js +6 -6
  167. package/dist/src/slide-components/fourier.js.map +1 -1
  168. package/dist/src/slide-components/frame-counter.d.ts +2 -2
  169. package/dist/src/slide-components/frame-counter.d.ts.map +1 -1
  170. package/dist/src/slide-components/frame-counter.js +1 -1
  171. package/dist/src/slide-components/frame-counter.js.map +1 -1
  172. package/dist/src/slide-components/function-graph.d.ts +2 -2
  173. package/dist/src/slide-components/function-graph.d.ts.map +1 -1
  174. package/dist/src/slide-components/function-graph.js +3 -3
  175. package/dist/src/slide-components/function-graph.js.map +1 -1
  176. package/dist/src/slide-components/halftone-shadow.d.ts +4 -4
  177. package/dist/src/slide-components/halftone-shadow.d.ts.map +1 -1
  178. package/dist/src/slide-components/halftone-shadow.js +2 -2
  179. package/dist/src/slide-components/halftone-shadow.js.map +1 -1
  180. package/dist/src/slide-components/handwriting-effect.d.ts +5 -5
  181. package/dist/src/slide-components/handwriting-effect.d.ts.map +1 -1
  182. package/dist/src/slide-components/handwriting-effect.js +5 -5
  183. package/dist/src/slide-components/handwriting-effect.js.map +1 -1
  184. package/dist/src/slide-components/hold.d.ts +3 -3
  185. package/dist/src/slide-components/hold.d.ts.map +1 -1
  186. package/dist/src/slide-components/hold.js +3 -3
  187. package/dist/src/slide-components/hold.js.map +1 -1
  188. package/dist/src/slide-components/image.d.ts +3 -3
  189. package/dist/src/slide-components/image.d.ts.map +1 -1
  190. package/dist/src/slide-components/image.js +3 -3
  191. package/dist/src/slide-components/image.js.map +1 -1
  192. package/dist/src/slide-components/in-parallel.d.ts +3 -3
  193. package/dist/src/slide-components/in-parallel.d.ts.map +1 -1
  194. package/dist/src/slide-components/in-parallel.js +2 -2
  195. package/dist/src/slide-components/in-parallel.js.map +1 -1
  196. package/dist/src/slide-components/in-series.d.ts +4 -4
  197. package/dist/src/slide-components/in-series.d.ts.map +1 -1
  198. package/dist/src/slide-components/in-series.js +5 -1
  199. package/dist/src/slide-components/in-series.js.map +1 -1
  200. package/dist/src/slide-components/live-duration.d.ts +1 -1
  201. package/dist/src/slide-components/live-duration.d.ts.map +1 -1
  202. package/dist/src/slide-components/live-duration.js +1 -1
  203. package/dist/src/slide-components/live-duration.js.map +1 -1
  204. package/dist/src/slide-components/multi-text.d.ts +76 -15
  205. package/dist/src/slide-components/multi-text.d.ts.map +1 -1
  206. package/dist/src/slide-components/multi-text.js +170 -25
  207. package/dist/src/slide-components/multi-text.js.map +1 -1
  208. package/dist/src/slide-components/rectangle.d.ts +3 -3
  209. package/dist/src/slide-components/rectangle.d.ts.map +1 -1
  210. package/dist/src/slide-components/rectangle.js +1 -1
  211. package/dist/src/slide-components/rectangle.js.map +1 -1
  212. package/dist/src/slide-components/registry.d.ts +1 -1
  213. package/dist/src/slide-components/registry.d.ts.map +1 -1
  214. package/dist/src/slide-components/registry.js +18 -18
  215. package/dist/src/slide-components/registry.js.map +1 -1
  216. package/dist/src/slide-components/serialize.d.ts +1 -1
  217. package/dist/src/slide-components/serialize.d.ts.map +1 -1
  218. package/dist/src/slide-components/serialize.js +2 -2
  219. package/dist/src/slide-components/serialize.js.map +1 -1
  220. package/dist/src/slide-components/show-error.js +2 -2
  221. package/dist/src/slide-components/show-error.js.map +1 -1
  222. package/dist/src/slide-components/simple-text.d.ts +4 -4
  223. package/dist/src/slide-components/simple-text.d.ts.map +1 -1
  224. package/dist/src/slide-components/simple-text.js +4 -4
  225. package/dist/src/slide-components/simple-text.js.map +1 -1
  226. package/dist/src/slide-components/slide-component.d.ts +5 -5
  227. package/dist/src/slide-components/slide-component.d.ts.map +1 -1
  228. package/dist/src/slide-components/slide-component.js +4 -4
  229. package/dist/src/slide-components/slide-component.js.map +1 -1
  230. package/dist/src/slide-components/slide-left.d.ts +3 -3
  231. package/dist/src/slide-components/slide-left.d.ts.map +1 -1
  232. package/dist/src/slide-components/slide-left.js +3 -3
  233. package/dist/src/slide-components/slide-left.js.map +1 -1
  234. package/dist/src/slide-components/split-video-clip.d.ts +2 -2
  235. package/dist/src/slide-components/split-video-clip.d.ts.map +1 -1
  236. package/dist/src/slide-components/split-video-clip.js +3 -3
  237. package/dist/src/slide-components/split-video-clip.js.map +1 -1
  238. package/dist/src/slide-components/text-effect.d.ts +2 -2
  239. package/dist/src/slide-components/text-effect.d.ts.map +1 -1
  240. package/dist/src/slide-components/traditional-text.d.ts +4 -4
  241. package/dist/src/slide-components/traditional-text.d.ts.map +1 -1
  242. package/dist/src/slide-components/traditional-text.js +3 -3
  243. package/dist/src/slide-components/traditional-text.js.map +1 -1
  244. package/dist/src/slide-components/video-clip.d.ts +17 -5
  245. package/dist/src/slide-components/video-clip.d.ts.map +1 -1
  246. package/dist/src/slide-components/video-clip.js +52 -11
  247. package/dist/src/slide-components/video-clip.js.map +1 -1
  248. package/dist/src/slide-components/video-info.d.ts +11 -11
  249. package/dist/src/slide-components/video-info.d.ts.map +1 -1
  250. package/dist/src/slide-components/video-info.js +32 -21
  251. package/dist/src/slide-components/video-info.js.map +1 -1
  252. package/dist/src/slow-image-sources.d.ts +7 -1
  253. package/dist/src/slow-image-sources.d.ts.map +1 -1
  254. package/dist/src/slow-image-sources.js +36 -5
  255. package/dist/src/slow-image-sources.js.map +1 -1
  256. package/dist/src/snapshot.d.ts +2 -2
  257. package/dist/src/snapshot.d.ts.map +1 -1
  258. package/dist/src/snapshot.js +4 -4
  259. package/dist/src/snapshot.js.map +1 -1
  260. package/dist/src/stroke-colors.d.ts +1 -1
  261. package/dist/src/stroke-colors.d.ts.map +1 -1
  262. package/dist/src/stroke-colors.js +4 -4
  263. package/dist/src/stroke-colors.js.map +1 -1
  264. package/dist/src/utility.d.ts +3 -4
  265. package/dist/src/utility.d.ts.map +1 -1
  266. package/dist/src/utility.js +4 -4
  267. package/dist/src/utility.js.map +1 -1
  268. package/lib/index.ts +3 -0
  269. package/lib/main-control.ts +119 -12
  270. package/package.json +7 -5
  271. package/src/binary-search.ts +2 -2
  272. package/src/corner-rounder.ts +128 -0
  273. package/src/cross-fade.ts +3 -3
  274. package/src/fancy-text.ts +255 -0
  275. package/src/glib/letters-base.ts +4 -3
  276. package/src/glib/line-font.ts +2 -2
  277. package/src/glib/paragraph-layout.ts +2 -2
  278. package/src/glib/path-shape-splitter.ts +1 -1
  279. package/src/glib/path-shape.ts +2 -2
  280. package/src/interpolate.ts +3 -3
  281. package/src/lattice.ts +1 -1
  282. package/src/local-files.ts +389 -0
  283. package/src/morph-animation.ts +2 -2
  284. package/src/peano-fourier/fourier-shared.ts +17 -4
  285. package/src/schedule-helper.ts +4 -4
  286. package/src/showable.ts +54 -4
  287. package/src/slide-components/arrow.ts +4 -4
  288. package/src/slide-components/cross-fade-transition.ts +4 -4
  289. package/src/slide-components/duration-agnostic.ts +3 -3
  290. package/src/slide-components/fixed-duration.ts +1 -1
  291. package/src/slide-components/fourier.ts +9 -9
  292. package/src/slide-components/frame-counter.ts +2 -2
  293. package/src/slide-components/function-graph.ts +4 -4
  294. package/src/slide-components/halftone-shadow.ts +4 -4
  295. package/src/slide-components/handwriting-effect.ts +7 -7
  296. package/src/slide-components/hold.ts +4 -4
  297. package/src/slide-components/image.ts +5 -5
  298. package/src/slide-components/in-parallel.ts +4 -4
  299. package/src/slide-components/in-series.ts +7 -4
  300. package/src/slide-components/live-duration.ts +1 -1
  301. package/src/slide-components/multi-text.ts +195 -30
  302. package/src/slide-components/rectangle.ts +3 -3
  303. package/src/slide-components/registry.ts +19 -19
  304. package/src/slide-components/serialize.ts +2 -2
  305. package/src/slide-components/show-error.ts +2 -2
  306. package/src/slide-components/simple-text.ts +7 -7
  307. package/src/slide-components/slide-component.ts +7 -7
  308. package/src/slide-components/slide-left.ts +4 -4
  309. package/src/slide-components/split-video-clip.ts +4 -4
  310. package/src/slide-components/text-effect.ts +2 -2
  311. package/src/slide-components/traditional-text.ts +5 -5
  312. package/src/slide-components/video-clip.ts +64 -13
  313. package/src/slide-components/video-info.ts +33 -21
  314. package/src/slow-image-sources.ts +38 -6
  315. package/src/snapshot.ts +4 -4
  316. package/src/stroke-colors.ts +4 -4
  317. package/src/utility.ts +5 -5
@@ -80,6 +80,7 @@ import {
80
80
  import { buildSlideComponentPanel } from "./slide-panel.ts";
81
81
  import { buildVideoClipPanel } from "./video-clip-panel.ts";
82
82
  import { buildTextFramePanel, CanvasState } from "./multi-text-panel.ts";
83
+ import { openMultiTextDialog } from "./multi-text-dialog.ts";
83
84
  import {
84
85
  ownerOf,
85
86
  parseSoundClips,
@@ -110,8 +111,15 @@ import {
110
111
  import { transcriptFileName, transcriptJson } from "./transcript-export.ts";
111
112
  import { wordFlag } from "./transcript-align.ts";
112
113
  import type { Device } from "./transcribe-core.ts";
113
- import { canvasRecorder } from "../lib/main-control.ts";
114
+ import { canvasRecorder, type VideoSize } from "../lib/main-control.ts";
114
115
  import { watchServiceWorkerReady } from "./delay-files.ts";
116
+ import {
117
+ checkLocalFiles,
118
+ localFilesNeedingPermission,
119
+ localFileUrlsIn,
120
+ onLocalFilesChange,
121
+ requestLocalFileAccess,
122
+ } from "../src/local-files.ts";
115
123
  import { DurationAgnosticComponent } from "../src/slide-components/duration-agnostic.ts";
116
124
  import {
117
125
  componentChoices,
@@ -317,6 +325,166 @@ viewport.addEventListener(
317
325
  { passive: false },
318
326
  );
319
327
 
328
+ // MARK: Video size
329
+
330
+ /**
331
+ * The four size fields: the saved file's pixels, and the drawing units.
332
+ * See development-plans/configurable-video-size.md.
333
+ */
334
+ const videoSizeInputs = {
335
+ pixelWidth: getById("videoPixelWidth", HTMLInputElement),
336
+ pixelHeight: getById("videoPixelHeight", HTMLInputElement),
337
+ width: getById("videoUnitWidth", HTMLInputElement),
338
+ height: getById("videoUnitHeight", HTMLInputElement),
339
+ } satisfies Record<keyof VideoSize, HTMLInputElement>;
340
+ const videoSizeWarning = getById("videoSizeWarning", HTMLSpanElement);
341
+
342
+ /**
343
+ * Where the user's size is remembered. One entry per video, shared by every window
344
+ * and session on this origin. localStorage, not IndexedDB: it's synchronous, so a
345
+ * saved size is in place before the first frame, and its `storage` event tells the
346
+ * other windows about a change for free.
347
+ */
348
+ const videoSizeStorageKey = `canvas-recorder-size|${startRequest.name}`;
349
+
350
+ /** The size this video's user saved, if any. */
351
+ function readSavedVideoSize(): VideoSize | undefined {
352
+ try {
353
+ const parsed: unknown = JSON.parse(
354
+ localStorage.getItem(videoSizeStorageKey) ?? "null",
355
+ );
356
+ if (typeof parsed !== "object" || parsed === null) return undefined;
357
+ const record = parsed as Record<string, unknown>;
358
+ const size = {
359
+ pixelWidth: record.pixelWidth,
360
+ pixelHeight: record.pixelHeight,
361
+ width: record.width,
362
+ height: record.height,
363
+ };
364
+ const valid = Object.values(size).every(
365
+ (value) => typeof value === "number" && Number.isFinite(value) && value > 0,
366
+ );
367
+ return valid ? (size as VideoSize) : undefined;
368
+ } catch {
369
+ return undefined;
370
+ }
371
+ }
372
+
373
+ /** Use the saved size, unless the program locked it. A bad saved size is ignored. */
374
+ function applySavedVideoSize() {
375
+ if (canvasRecorder.sizeLocked) return;
376
+ const saved = readSavedVideoSize();
377
+ if (!saved) return;
378
+ try {
379
+ canvasRecorder.size = saved;
380
+ } catch (error) {
381
+ console.warn("Ignoring the saved video size.", saved, error);
382
+ }
383
+ }
384
+ applySavedVideoSize();
385
+
386
+ /** Copy the size into the fields, except one the user is typing in. */
387
+ function showVideoSize(size: VideoSize) {
388
+ for (const [key, input] of Object.entries(videoSizeInputs)) {
389
+ input.readOnly = canvasRecorder.sizeLocked;
390
+ if (document.activeElement !== input) {
391
+ input.value = String(size[key as keyof VideoSize]);
392
+ }
393
+ }
394
+ const pixelAspect = size.pixelWidth / size.pixelHeight;
395
+ const unitAspect = size.width / size.height;
396
+ const stretched = Math.abs(pixelAspect / unitAspect - 1) > 0.001;
397
+ videoSizeWarning.textContent = stretched ? "⚠ stretched" : "";
398
+ videoSizeWarning.title = stretched
399
+ ? `The pixels are ${pixelAspect.toFixed(3)} : 1 and the units are ${unitAspect.toFixed(3)} : 1, so the picture is stretched to fit.`
400
+ : "";
401
+ getById("videoSizeControls", HTMLSpanElement).title = canvasRecorder.sizeLocked
402
+ ? "The size of the video. This program sets it, so it can't be changed here."
403
+ : "The size of the video. Pixels: the saved file. Units: the coordinates everything is drawn in. Remembered for this video.";
404
+ }
405
+ showVideoSize(canvasRecorder.size);
406
+
407
+ /**
408
+ * The user asked for this size, in the size fields or with a button like the Video File
409
+ * panel's "Make video this size". Use it, and remember it for this video.
410
+ * @returns False if the size is locked, or `size` isn't usable. Nothing changes then.
411
+ */
412
+ function setVideoSizeFromGui(size: VideoSize): boolean {
413
+ if (canvasRecorder.sizeLocked) return false;
414
+ try {
415
+ canvasRecorder.size = size;
416
+ } catch {
417
+ // E.g. 0.4 pixels, which rounds to 0. Normal while typing.
418
+ return false;
419
+ }
420
+ try {
421
+ localStorage.setItem(videoSizeStorageKey, JSON.stringify(canvasRecorder.size));
422
+ } catch {
423
+ // Private browsing, or storage is full. The size still works until the page reloads.
424
+ }
425
+ return true;
426
+ }
427
+
428
+ for (const [key, input] of Object.entries(videoSizeInputs)) {
429
+ input.addEventListener("input", () => {
430
+ const value = input.valueAsNumber;
431
+ // Empty, 0 or negative is normal while typing. Wait for a usable number.
432
+ if (!(Number.isFinite(value) && value > 0)) return;
433
+ setVideoSizeFromGui({ ...canvasRecorder.size, [key]: value });
434
+ });
435
+ // Show the value as stored, e.g. rounded to whole pixels, once the user is done typing.
436
+ input.addEventListener("blur", () => showVideoSize(canvasRecorder.size));
437
+ }
438
+
439
+ canvasRecorder.onSizeChange((size) => {
440
+ showVideoSize(size);
441
+ if (isRecording) return;
442
+ if (zoomSelect.value === "fit") {
443
+ zoomToFit();
444
+ } else {
445
+ applyZoom(currentZoomFactor);
446
+ }
447
+ });
448
+
449
+ // Another window changed this video's size.
450
+ addEventListener("storage", (event) => {
451
+ if (event.key === videoSizeStorageKey) applySavedVideoSize();
452
+ });
453
+
454
+ // MARK: Local files
455
+
456
+ /**
457
+ * 🔓: shown when some of the video's files are on this computer, and the browser
458
+ * needs a click before it will read them. See src/local-files.ts.
459
+ */
460
+ const localFilesBtn = getById("localFilesBtn", HTMLButtonElement);
461
+
462
+ function updateLocalFilesButton() {
463
+ const names = localFilesNeedingPermission();
464
+ localFilesBtn.hidden = names.length === 0;
465
+ localFilesBtn.textContent =
466
+ names.length === 1 ? "🔓 Allow local file" : `🔓 Allow ${names.length} local files`;
467
+ localFilesBtn.title =
468
+ "The browser needs your OK before it reads these files again:\n" + names.join("\n");
469
+ }
470
+ onLocalFilesChange(updateLocalFilesButton);
471
+
472
+ /** Ask about every file that needs it. Then the sounds can load. */
473
+ async function allowLocalFiles() {
474
+ await requestLocalFileAccess();
475
+ // The images and video clips try again by themselves. The sounds are loaded all at once.
476
+ void initAudio();
477
+ }
478
+ localFilesBtn.addEventListener("click", () => void allowLocalFiles());
479
+
480
+ /**
481
+ * Find out, without asking the user anything, whether the video's local files can be read.
482
+ * Any that can't put the 🔓 button on screen.
483
+ */
484
+ function checkVideosLocalFiles() {
485
+ void checkLocalFiles(localFileUrlsIn(toShow));
486
+ }
487
+
320
488
  /**
321
489
  * @param timeInMs Draw the animation at this time.
322
490
  * @param live True for interactive display (schedule markers drawn on top).
@@ -635,6 +803,8 @@ let audioBuilder = new AudioBuilder(toShow.duration);
635
803
  let reloadGeneration = 0;
636
804
 
637
805
  async function initAudio(): Promise<void> {
806
+ // The sound clips are being re-read, so this is a good time to look for files needing 🔓.
807
+ checkVideosLocalFiles();
638
808
  const myGen = ++reloadGeneration;
639
809
  audioReady = false;
640
810
  stopAudio();
@@ -766,11 +936,15 @@ playPositionSeconds.addEventListener("input", () => {
766
936
  });
767
937
 
768
938
  // Cmd+Z / Ctrl+Z opens the history (undo) dialog when no text field is focused.
939
+ // A contenteditable, like the Multi Text editor, is a text field with its own undo,
940
+ // and an editor that handled the key itself has said so with preventDefault().
769
941
  addEventListener("keydown", (event) => {
770
942
  if (!(event.metaKey || event.ctrlKey) || event.key !== "z") return;
943
+ if (event.defaultPrevented) return;
771
944
  if (
772
945
  event.target instanceof HTMLInputElement ||
773
- event.target instanceof HTMLTextAreaElement
946
+ event.target instanceof HTMLTextAreaElement ||
947
+ (event.target instanceof HTMLElement && event.target.isContentEditable)
774
948
  )
775
949
  return;
776
950
  event.preventDefault();
@@ -823,6 +997,76 @@ getById("back5s", HTMLButtonElement).addEventListener("click", () => {
823
997
  */
824
998
  const FPS = canvasRecorder.fps;
825
999
 
1000
+ // MARK: Frames
1001
+
1002
+ /**
1003
+ * Frame *n* covers the times from n / FPS (inclusive) to (n + 1) / FPS (exclusive), and is
1004
+ * always drawn at its center, (n + ½) / FPS. That's true for the whole video and for any part
1005
+ * of it, so separately saved pieces line up exactly.
1006
+ *
1007
+ * These measure in frames from the start of the whole video, with the centers at whole numbers.
1008
+ * The play position is stored to 0.1 ms (see {@link loadPlayPositionSeconds}), so a time that
1009
+ * was meant to be a center is allowed to miss by a little.
1010
+ */
1011
+ const FRAME_TOLERANCE = 0.01;
1012
+
1013
+ /** `ms` in frames, shifted so the frame centers are whole numbers. */
1014
+ function framePosition(ms: number): number {
1015
+ return (ms * FPS) / 1000 - 0.5;
1016
+ }
1017
+
1018
+ /** Where frame `n` is drawn, in ms from the start of the whole video. */
1019
+ function frameCenterMs(n: number): number {
1020
+ return ((n + 0.5) * 1000) / FPS;
1021
+ }
1022
+
1023
+ /** The first frame drawn at or after `ms`. */
1024
+ function firstFrameAtOrAfter(ms: number): number {
1025
+ return Math.ceil(framePosition(ms) - FRAME_TOLERANCE);
1026
+ }
1027
+
1028
+ /** How many frames are drawn from `startMs` (inclusive) to `endMs` (exclusive). */
1029
+ function framesBetween(startMs: number, endMs: number): number {
1030
+ return Math.max(0, firstFrameAtOrAfter(endMs) - firstFrameAtOrAfter(startMs));
1031
+ }
1032
+
1033
+ /**
1034
+ * Move one frame forward (+1) or back (-1), pausing playback.
1035
+ *
1036
+ * From a frame's center, that's exactly one frame. From anywhere else, it's the nearest
1037
+ * center in that direction, so the playhead always ends up on a time that's really rendered.
1038
+ * It stays inside the play limits.
1039
+ */
1040
+ function stepOneFrame(direction: 1 | -1): void {
1041
+ const position = framePosition(playPositionSeconds.valueAsNumber * 1000);
1042
+ let frame =
1043
+ direction > 0
1044
+ ? Math.floor(position + FRAME_TOLERANCE) + 1
1045
+ : Math.ceil(position - FRAME_TOLERANCE) - 1;
1046
+ const first = firstFrameAtOrAfter(sectionStartTime);
1047
+ const last = firstFrameAtOrAfter(sectionEndTime) - 1;
1048
+ frame = Math.max(first, Math.min(last, frame));
1049
+ playCheckBox.checked = false;
1050
+ stopAudio();
1051
+ loadPlayPositionSeconds(frameCenterMs(frame));
1052
+ loadPlayPositionRange();
1053
+ }
1054
+
1055
+ // Option-← and Option-→: one frame back or forward. Like the other hot keys, these use Option.
1056
+ // They work even in a text field, where they would otherwise move the cursor one word.
1057
+ // Arrow keys never cause a keypress event, so this has to be keydown.
1058
+ addEventListener("keydown", (event) => {
1059
+ if (!event.altKey || event.shiftKey || event.metaKey || event.ctrlKey) return;
1060
+ if (suspendLiveAnimationLoop) return; // Recording.
1061
+ if (event.code === "ArrowRight") {
1062
+ stepOneFrame(1);
1063
+ event.preventDefault();
1064
+ } else if (event.code === "ArrowLeft") {
1065
+ stepOneFrame(-1);
1066
+ event.preventDefault();
1067
+ }
1068
+ });
1069
+
826
1070
  /**
827
1071
  * This is the status of the save. It has no purpose during live / realtime operation
828
1072
  * so it initialize it to blank.
@@ -858,7 +1102,21 @@ function escapeHtml(text: string): string {
858
1102
  .replace(/>/g, "&gt;");
859
1103
  }
860
1104
 
861
- async function startRecording(saveStartMs = 0, saveEndMs = toShow.duration) {
1105
+ /**
1106
+ * Record the video and save it to a file the user picks.
1107
+ * @param saveStartMs Where to start, in the video's time.
1108
+ * @param saveEndMs Where to stop.
1109
+ * @param pixelWidth The saved file's width. Usually {@link canvasRecorder.pixelWidth}, but the
1110
+ * Save dialog can change it, just for this file. Nothing else sees the change: the picture
1111
+ * is still drawn in canvasRecorder's units, and simply stretched to this size.
1112
+ * @param pixelHeight The saved file's height. See `pixelWidth`.
1113
+ */
1114
+ async function startRecording(
1115
+ saveStartMs = 0,
1116
+ saveEndMs = toShow.duration,
1117
+ pixelWidth = canvasRecorder.pixelWidth,
1118
+ pixelHeight = canvasRecorder.pixelHeight,
1119
+ ) {
862
1120
  stopAudio();
863
1121
  startRecordingButton.disabled = true;
864
1122
  liveControls.disabled = true;
@@ -867,13 +1125,13 @@ async function startRecording(saveStartMs = 0, saveEndMs = toShow.duration) {
867
1125
 
868
1126
  suspendLiveAnimationLoop = true;
869
1127
 
870
- // Lock canvas at 4K for the duration of recording.
1128
+ // Lock the canvas at the output size for the duration of recording.
871
1129
  // Disable all size-changing code paths so the encoder sees a constant frame size.
872
1130
  isRecording = true;
873
1131
  viewportResizeObserver.disconnect();
874
1132
  zoomControls.inert = true;
875
- canvas.width = canvasRecorder.pixelWidth;
876
- canvas.height = canvasRecorder.pixelHeight;
1133
+ canvas.width = pixelWidth;
1134
+ canvas.height = pixelHeight;
877
1135
  canvas.style.width = "";
878
1136
  canvas.style.height = "";
879
1137
  canvas.style.maxWidth = "100%";
@@ -897,7 +1155,7 @@ async function startRecording(saveStartMs = 0, saveEndMs = toShow.duration) {
897
1155
  suggestedName: "canvas-recording.mp4",
898
1156
  types: [
899
1157
  {
900
- description: "WebM Video",
1158
+ description: "MP4 Video",
901
1159
  accept: { "video/mp4": [".mp4"] },
902
1160
  },
903
1161
  ],
@@ -911,21 +1169,28 @@ async function startRecording(saveStartMs = 0, saveEndMs = toShow.duration) {
911
1169
  target: new StreamTarget(writableStream, { chunked: true }), // Batch writes for speed
912
1170
  });
913
1171
 
914
- // Canvas source (VP9 for good quality/size on macOS)
1172
+ // HEVC (H.265): Macs encode it in hardware, fast and well.
915
1173
  const videoSource = new CanvasSource(canvas, {
916
1174
  codec: "hevc",
917
- bitrate: 16_000_000, // ~8Mbps — tune higher for better quality
1175
+ // 16 Mbps. This file is the master that YouTube re-encodes, so a higher rate costs only
1176
+ // file size. See "Export bit rate" in development-plans/review-2026-11.md.
1177
+ bitrate: 16_000_000,
918
1178
  });
919
1179
 
920
1180
  output.addVideoTrack(videoSource, { frameRate: FPS });
921
1181
 
1182
+ // Frames come from the whole video's grid, even for part of it. See firstFrameAtOrAfter().
1183
+ const firstFrame = firstFrameAtOrAfter(saveStartMs);
1184
+ // The file starts where its first frame starts, so the sound starts there too.
1185
+ const audioStartMs = (firstFrame * 1000) / FPS;
1186
+
922
1187
  const fullAudioBuffer = audioBuilder.getAudioBuffer();
923
1188
  let audioBuffer: AudioBuffer;
924
- if (saveStartMs === 0 && saveEndMs >= toShow.duration) {
1189
+ if (firstFrame === 0 && saveEndMs >= toShow.duration) {
925
1190
  audioBuffer = fullAudioBuffer;
926
1191
  } else {
927
1192
  const sr = fullAudioBuffer.sampleRate;
928
- const s0 = Math.round((saveStartMs / 1000) * sr);
1193
+ const s0 = Math.round((audioStartMs / 1000) * sr);
929
1194
  const s1 = Math.min(
930
1195
  Math.round((saveEndMs / 1000) * sr),
931
1196
  fullAudioBuffer.length,
@@ -960,7 +1225,7 @@ async function startRecording(saveStartMs = 0, saveEndMs = toShow.duration) {
960
1225
  let frameNumber = 0;
961
1226
  let warnedNoFramePromises = false;
962
1227
  while (!canceled) {
963
- const timeInMs = saveStartMs + (frameNumber + 0.5) * frameDuration;
1228
+ const timeInMs = frameCenterMs(firstFrame + frameNumber);
964
1229
  if (timeInMs >= saveEndMs) {
965
1230
  break;
966
1231
  }
@@ -1096,6 +1361,85 @@ const saveInfoStart = getById("saveInfoStart", HTMLTableCellElement);
1096
1361
  const saveInfoEnd = getById("saveInfoEnd", HTMLTableCellElement);
1097
1362
  const saveInfoDuration = getById("saveInfoDuration", HTMLTableCellElement);
1098
1363
  const saveInfoFrames = getById("saveInfoFrames", HTMLTableCellElement);
1364
+ const saveWidthInput = getById("saveWidth", HTMLInputElement);
1365
+ const saveHeightInput = getById("saveHeight", HTMLInputElement);
1366
+ const saveKeepAspectCheckbox = getById("saveKeepAspect", HTMLInputElement);
1367
+ const saveSizeNote = getById("saveSizeNote", HTMLDivElement);
1368
+ const saveOkBtn = getById("saveOkBtn", HTMLButtonElement);
1369
+
1370
+ // MARK: Save size
1371
+
1372
+ /**
1373
+ * Why the Save dialog's size can't be used, or undefined if it can.
1374
+ *
1375
+ * HEVC stores color at half resolution (4:2:0), so both numbers must be even.
1376
+ */
1377
+ function saveSizeProblem(width: number, height: number): string | undefined {
1378
+ if (!(Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0)) {
1379
+ return "The size must be two whole numbers of pixels.";
1380
+ }
1381
+ if (width % 2 || height % 2) {
1382
+ return "Both numbers must be even.";
1383
+ }
1384
+ return undefined;
1385
+ }
1386
+
1387
+ /** Explain the Save dialog's size, and enable OK only when everything is usable. */
1388
+ function updateSaveSizeNote(): void {
1389
+ const width = saveWidthInput.valueAsNumber;
1390
+ const height = saveHeightInput.valueAsNumber;
1391
+ const { pixelWidth, pixelHeight } = canvasRecorder;
1392
+ const problem = saveSizeProblem(width, height);
1393
+ if (problem) {
1394
+ saveSizeNote.textContent = problem;
1395
+ saveSizeNote.style.color = "red";
1396
+ } else if (width === pixelWidth && height === pixelHeight) {
1397
+ saveSizeNote.textContent = "The video's own size.";
1398
+ saveSizeNote.style.color = "#555";
1399
+ } else {
1400
+ const stretched =
1401
+ Math.abs((width / height) / (pixelWidth / pixelHeight) - 1) > 0.001;
1402
+ saveSizeNote.textContent = stretched
1403
+ ? `Stretched: the video is ${pixelWidth} × ${pixelHeight}.`
1404
+ : `${Math.round((width / pixelWidth) * 100)}% of the video's ${pixelWidth} × ${pixelHeight}.`;
1405
+ saveSizeNote.style.color = stretched ? "#a06000" : "#555";
1406
+ }
1407
+ const startMs = (saveStartSecondsInput.valueAsNumber || 0) * 1000;
1408
+ const endMs = (saveEndSecondsInput.valueAsNumber || 0) * 1000;
1409
+ saveOkBtn.disabled = problem !== undefined || endMs <= startMs;
1410
+ }
1411
+
1412
+ /** Set the Save dialog's size to the video's own. */
1413
+ function resetSaveSize(): void {
1414
+ saveWidthInput.value = String(canvasRecorder.pixelWidth);
1415
+ saveHeightInput.value = String(canvasRecorder.pixelHeight);
1416
+ updateSaveSizeNote();
1417
+ }
1418
+
1419
+ /**
1420
+ * With Keep aspect ratio checked, typing one number sets the other.
1421
+ * The shape always comes from the video's own size, not the last pair typed, so it can't drift.
1422
+ * The other number is rounded to an even number of pixels.
1423
+ */
1424
+ function matchSaveAspect(changed: "width" | "height"): void {
1425
+ if (saveKeepAspectCheckbox.checked) {
1426
+ const aspect = canvasRecorder.pixelWidth / canvasRecorder.pixelHeight;
1427
+ const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
1428
+ if (changed === "width") {
1429
+ const width = saveWidthInput.valueAsNumber;
1430
+ if (width > 0) saveHeightInput.value = String(even(width / aspect));
1431
+ } else {
1432
+ const height = saveHeightInput.valueAsNumber;
1433
+ if (height > 0) saveWidthInput.value = String(even(height * aspect));
1434
+ }
1435
+ }
1436
+ updateSaveSizeNote();
1437
+ }
1438
+
1439
+ saveWidthInput.addEventListener("input", () => matchSaveAspect("width"));
1440
+ saveHeightInput.addEventListener("input", () => matchSaveAspect("height"));
1441
+ saveKeepAspectCheckbox.addEventListener("input", () => matchSaveAspect("width"));
1442
+ getById("saveVideoSizeBtn", HTMLButtonElement).addEventListener("click", resetSaveSize);
1099
1443
 
1100
1444
  function formatTimecode(ms: number): string {
1101
1445
  const totalFrames = Math.round((ms * FPS) / 1000);
@@ -1116,10 +1460,9 @@ function updateSaveDialogInfo(): void {
1116
1460
  saveInfoEnd.textContent = formatTimecode(endMs);
1117
1461
  saveInfoDuration.textContent = formatTimecode(durationMs);
1118
1462
  saveInfoDuration.style.color = invalid ? "red" : "";
1119
- saveInfoFrames.textContent = Math.floor(
1120
- (durationMs * FPS) / 1000,
1121
- ).toLocaleString();
1463
+ saveInfoFrames.textContent = framesBetween(startMs, endMs).toLocaleString();
1122
1464
  saveInfoFrames.style.color = invalid ? "red" : "";
1465
+ updateSaveSizeNote();
1123
1466
  }
1124
1467
 
1125
1468
  function setSaveRange(startMs: number, endMs: number): void {
@@ -1186,17 +1529,49 @@ getById("saveDialogCancelBtn", HTMLButtonElement).addEventListener(
1186
1529
  () => saveDialog.close(),
1187
1530
  );
1188
1531
 
1189
- getById("saveOkBtn", HTMLButtonElement).addEventListener("click", () => {
1532
+ const saveLocalFilesRow = getById("saveLocalFilesRow", HTMLDivElement);
1533
+ const saveLocalFilesNote = getById("saveLocalFilesNote", HTMLSpanElement);
1534
+ const saveLocalFilesBtn = getById("saveLocalFilesBtn", HTMLButtonElement);
1535
+
1536
+ /**
1537
+ * Check every local file the video uses, before a long recording starts, so it can't stop
1538
+ * halfway to wait for a permission nobody is there to give.
1539
+ * @returns True if they're all ready. Otherwise the Save dialog says what's wrong.
1540
+ */
1541
+ async function saveDialogLocalFilesReady(): Promise<boolean> {
1542
+ const problems = await checkLocalFiles(localFileUrlsIn(toShow));
1543
+ saveLocalFilesRow.hidden = problems.length === 0;
1544
+ const needPermission = problems.some((problem) => problem.reason === "permission");
1545
+ saveLocalFilesBtn.hidden = !needPermission;
1546
+ saveLocalFilesNote.textContent = problems.map((problem) => problem.message).join(" ");
1547
+ return problems.length === 0;
1548
+ }
1549
+
1550
+ saveLocalFilesBtn.addEventListener("click", async () => {
1551
+ await allowLocalFiles();
1552
+ await saveDialogLocalFilesReady();
1553
+ });
1554
+
1555
+ saveOkBtn.addEventListener("click", async () => {
1190
1556
  const startMs = (saveStartSecondsInput.valueAsNumber || 0) * 1000;
1191
1557
  const endMs = (saveEndSecondsInput.valueAsNumber || 0) * 1000;
1558
+ const width = saveWidthInput.valueAsNumber;
1559
+ const height = saveHeightInput.valueAsNumber;
1560
+ if (saveSizeProblem(width, height)) return;
1561
+ // Quick: no prompts, just IndexedDB. The click still counts for the file picker afterward.
1562
+ if (!(await saveDialogLocalFilesReady())) return;
1192
1563
  saveDialog.close();
1193
- startRecording(startMs, endMs);
1564
+ startRecording(startMs, endMs, width, height);
1194
1565
  });
1195
1566
 
1196
1567
  startRecordingButton.addEventListener("click", () => {
1197
1568
  populateSaveChapterSelect();
1569
+ // Always start from the video's own size, not whatever was used last time.
1570
+ resetSaveSize();
1198
1571
  setSaveRange(0, toShow.duration);
1572
+ saveLocalFilesRow.hidden = true;
1199
1573
  saveDialog.showModal();
1574
+ void saveDialogLocalFilesReady();
1200
1575
  });
1201
1576
 
1202
1577
  /**
@@ -1596,10 +1971,7 @@ const veRootParent: ShowableParent = {
1596
1971
  clearTimeout(_durationAudioTimer);
1597
1972
  _durationAudioTimer = setTimeout(() => void initAudio(), 300);
1598
1973
  // Redraw the timeline so block positions/sizes update during drags.
1599
- if (_veRootSelectable) {
1600
- timelineDisplay.setChapterDuration(_veRootSelectable.duration);
1601
- timelineDisplay.setBlocks(_buildTimelineBlocks(_veRootSelectable));
1602
- }
1974
+ refreshTimeline();
1603
1975
  scheduleChapterRefresh();
1604
1976
  },
1605
1977
  };
@@ -1621,10 +1993,7 @@ const toShowParent: ShowableParent = {
1621
1993
  markDirty();
1622
1994
  clearTimeout(_durationAudioTimer);
1623
1995
  _durationAudioTimer = setTimeout(() => void initAudio(), 300);
1624
- if (_veRootSelectable) {
1625
- timelineDisplay.setChapterDuration(_veRootSelectable.duration);
1626
- timelineDisplay.setBlocks(_buildTimelineBlocks(_veRootSelectable));
1627
- }
1996
+ refreshTimeline();
1628
1997
  scheduleChapterRefresh();
1629
1998
  },
1630
1999
  };
@@ -1842,7 +2211,7 @@ transcriptView = new TranscriptView({
1842
2211
  _durationAudioTimer = setTimeout(() => void initAudio(), 300);
1843
2212
  // Show the owner's Sound Clips, with the remaining piece highlighted.
1844
2213
  selectInChapter(owner);
1845
- if (_veRootSelectable) _updateTimeline(_veRootSelectable);
2214
+ refreshTimeline();
1846
2215
  },
1847
2216
  });
1848
2217
  getById("timelineCanvas", HTMLCanvasElement).after(transcriptView.element);
@@ -2190,8 +2559,19 @@ function _buildTimelineBlocks(selectable: Showable): TimelineBlock[] {
2190
2559
  return blocks;
2191
2560
  }
2192
2561
 
2562
+ /**
2563
+ * Redraw the timeline after an edit. It always shows the chapter, the root picked in the
2564
+ * `<select>`. Not {@link _veRootSelectable}: that's whatever the component editor shows, which
2565
+ * can be a child of the chapter. Passing both, one after the other, looked like a new chapter
2566
+ * every time, and reset the timeline's zoom and pan.
2567
+ */
2568
+ function refreshTimeline(): void {
2569
+ const chapter = currentRoot();
2570
+ if (chapter) _updateTimeline(chapter);
2571
+ }
2572
+
2193
2573
  function _updateTimeline(selectable: Showable): void {
2194
- timelineDisplay.setChapterDuration(selectable.duration);
2574
+ timelineDisplay.setChapter(selectable, selectable.duration);
2195
2575
  timelineDisplay.setBlocks(_buildTimelineBlocks(selectable));
2196
2576
  timelineDisplay.setSelectedId(selectedSlideChild ?? undefined);
2197
2577
  }
@@ -2679,10 +3059,6 @@ function buildDiffText(): string {
2679
3059
  : JSON.stringify(s.value);
2680
3060
  }
2681
3061
 
2682
- /**
2683
- * Generate TypeScript constructor + .set() lines for a removable component,
2684
- * omitting any property whose value matches the class default.
2685
- */
2686
3062
  /** Variable names already used in this diff, so generated code never declares one twice. */
2687
3063
  const usedVarNames = new Set<string>();
2688
3064
  function uniqueVarName(base: string): string {
@@ -2694,14 +3070,15 @@ function buildDiffText(): string {
2694
3070
 
2695
3071
  /**
2696
3072
  * TypeScript that recreates `comp`, a component added in the Visual Editor, and adds it to
2697
- * `parentExpr`. Covers everything the save file stores for such a component: schedules
3073
+ * `parentExpr`'s replaceable children, so the Visual Editor can still edit or delete it.
3074
+ * Covers everything the save file stores for such a component: schedules
2698
3075
  * and scalars that differ from the class's defaults, the user's name for it, its own
2699
3076
  * replaceable children (recursively), its duration, and its sound clips.
2700
3077
  */
2701
3078
  function generateComponentCode(
2702
3079
  comp: Showable,
2703
3080
  indent: string,
2704
- parentExpr = "this",
3081
+ parentExpr: string,
2705
3082
  ): string[] {
2706
3083
  const out: string[] = [];
2707
3084
  const rk = comp.registryKey ?? "unknown";
@@ -2802,13 +3179,45 @@ function buildDiffText(): string {
2802
3179
  );
2803
3180
  }
2804
3181
 
2805
- out.push(`${indent}${parentExpr}.addFixed({ child: ${varName} });`);
3182
+ out.push(
3183
+ `${indent}${parentExpr}.replaceableComponents!.push(${varName});`,
3184
+ );
2806
3185
 
2807
3186
  return out;
2808
3187
  }
2809
3188
 
2810
- function diffNode(
2811
- defaultEntry: SerializedFixedChild,
3189
+ /** A component's name in the diff: the user's name for it, or its own. */
3190
+ function displayName(comp: Showable): string {
3191
+ return comp.userEditableDescription ?? comp.description;
3192
+ }
3193
+
3194
+ /** `names`, with " #1", " #2"… added to any name used more than once. */
3195
+ function numberedNames(names: readonly string[]): string[] {
3196
+ const total = new Map<string, number>();
3197
+ for (const name of names) total.set(name, (total.get(name) ?? 0) + 1);
3198
+ const seen = new Map<string, number>();
3199
+ return names.map((name) => {
3200
+ if (total.get(name)! < 2) return name;
3201
+ const n = (seen.get(name) ?? 0) + 1;
3202
+ seen.set(name, n);
3203
+ return `${name} #${n}`;
3204
+ });
3205
+ }
3206
+
3207
+ /**
3208
+ * The differences in one component's own properties. The same for a fixed child, whose
3209
+ * default is its TypeScript, and for a replaceable child, whose default is its counterpart
3210
+ * in the TypeScript's list.
3211
+ */
3212
+ function diffProperties(
3213
+ defaultEntry: Pick<
3214
+ SerializedFixedChild,
3215
+ | "schedules"
3216
+ | "scalars"
3217
+ | "duration"
3218
+ | "userEditableDescription"
3219
+ | "soundClips"
3220
+ >,
2812
3221
  sel: Showable,
2813
3222
  path: string[],
2814
3223
  ): void {
@@ -2882,15 +3291,91 @@ function buildDiffText(): string {
2882
3291
  lines.push(`TypeScript: ${JSON.stringify(defClips)}`);
2883
3292
  lines.push(`Current: ${JSON.stringify(curClips)}`);
2884
3293
  }
3294
+ }
3295
+
3296
+ /**
3297
+ * The differences between a component's replaceable children and the TypeScript's.
3298
+ *
3299
+ * Children pair up by kind and name, in order, the way fixed children pair up by description.
3300
+ * Then any left over pair up by kind alone, so a child renamed in the Visual Editor is one
3301
+ * child with a new name, not one deleted and one added.
3302
+ *
3303
+ * A pair prints its property changes, the same way a fixed child does. A child with no
3304
+ * counterpart in the TypeScript prints as the TypeScript that makes it. A deleted child, or
3305
+ * an order that deleting and adding wouldn't give, gets one line. No changes, no lines.
3306
+ */
3307
+ function diffReplaceable(
3308
+ defaults: readonly SerializedChild[],
3309
+ sel: Showable,
3310
+ path: string[],
3311
+ ): void {
3312
+ // Only components with a registry key are saved, so only they can differ.
3313
+ const live = (sel.replaceableComponents?.get() ?? []).filter(
3314
+ (comp) => comp.registryKey !== undefined,
3315
+ );
3316
+ const key = (registryKey: string, name: string | undefined) =>
3317
+ `${registryKey}\0${name ?? ""}`;
3318
+ const liveFor = counterparts(
3319
+ defaults.map((d) => ({
3320
+ description: key(d.registryKey, d.userEditableDescription),
3321
+ })),
3322
+ live.map((comp) => ({
3323
+ description: key(comp.registryKey!, comp.userEditableDescription),
3324
+ comp,
3325
+ })),
3326
+ ).map((match) => match?.comp);
3327
+ for (const [index, d] of defaults.entries()) {
3328
+ liveFor[index] ??= live.find(
3329
+ (comp) => comp.registryKey === d.registryKey && !liveFor.includes(comp),
3330
+ );
3331
+ }
3332
+ const added = live.filter((comp) => !liveFor.includes(comp));
3333
+ const names = new Map(
3334
+ numberedNames(live.map(displayName)).map((name, index) => [
3335
+ live[index],
3336
+ name,
3337
+ ]),
3338
+ );
2885
3339
 
2886
- const currentComponents = sel.replaceableComponents?.get() ?? [];
2887
- if (currentComponents.length > 0) {
3340
+ const expectedOrder = [
3341
+ ...liveFor.filter((comp) => comp !== undefined),
3342
+ ...added,
3343
+ ];
3344
+ if (expectedOrder.some((comp, index) => comp !== live[index])) {
2888
3345
  printPathIfNeeded(path);
2889
- for (const comp of currentComponents) {
2890
- lines.push(...generateComponentCode(comp, " "));
3346
+ lines.push(
3347
+ ` [new order: ${live.map((comp) => names.get(comp)).join(", ")}]`,
3348
+ );
3349
+ }
3350
+
3351
+ for (const [index, d] of defaults.entries()) {
3352
+ const comp = liveFor[index];
3353
+ if (comp) {
3354
+ const childPath = [...path, names.get(comp)!];
3355
+ diffProperties(d, comp, childPath);
3356
+ diffReplaceable(d.components ?? [], comp, childPath);
3357
+ } else {
3358
+ printPathIfNeeded([...path, d.userEditableDescription ?? d.registryKey]);
3359
+ lines.push(` [deleted]`);
2891
3360
  }
2892
3361
  }
2893
3362
 
3363
+ const parentExpr = toVarName(path.at(-1) ?? "") || "parent";
3364
+ for (const comp of added) {
3365
+ printPathIfNeeded([...path, names.get(comp)!]);
3366
+ lines.push(` [added]`);
3367
+ lines.push(...generateComponentCode(comp, " ", parentExpr));
3368
+ }
3369
+ }
3370
+
3371
+ function diffNode(
3372
+ defaultEntry: SerializedFixedChild,
3373
+ sel: Showable,
3374
+ path: string[],
3375
+ ): void {
3376
+ diffProperties(defaultEntry, sel, path);
3377
+ diffReplaceable(defaultEntry.components ?? [], sel, path);
3378
+
2894
3379
  const liveFixed = getFixedComponents(sel);
2895
3380
  const defaultFixed = defaultEntry.fixedComponents ?? [];
2896
3381
  const liveFor = counterparts(defaultFixed, liveFixed);
@@ -2919,10 +3404,12 @@ function buildDiffText(): string {
2919
3404
  diffNode(tsDefaultsTree, toShow, [toShow.description]);
2920
3405
  }
2921
3406
 
2922
- if (lines.length === 1) lines.push("(no differences found)");
3407
+ if (lines.length === 0) lines.push("(no differences found)");
2923
3408
 
2924
3409
  return lines.join("\n");
2925
3410
  }
3411
+ /** The diff file's text, from the console, with or without syncing the file. */
3412
+ philDebug.diffText = () => console.log(buildDiffText());
2926
3413
 
2927
3414
  // MARK: Load-source helpers
2928
3415
 
@@ -3740,9 +4227,19 @@ function buildScheduleSection(
3740
4227
  return playPositionSeconds.valueAsNumber * 1000;
3741
4228
  }
3742
4229
 
3743
- /** Time relative to the start of the selected scene — what keyframe.time values use. */
4230
+ /**
4231
+ * Where the component that owns this schedule starts, measured from the start of the Visual
4232
+ * Editor's root. 0 for anything in a parallel container at the top, which is why measuring
4233
+ * from the root used to look right. A clip in an In Series starts later.
4234
+ */
4235
+ function ownerStartMs() {
4236
+ const root = currentRoot();
4237
+ return root ? (startWithin(root, selectable) ?? 0) : 0;
4238
+ }
4239
+
4240
+ /** Time relative to the start of the component that owns this schedule — what keyframe.time values use. */
3744
4241
  function localTimeMs() {
3745
- return currentTimeMs() - rootStartMs;
4242
+ return currentTimeMs() - rootStartMs - ownerStartMs();
3746
4243
  }
3747
4244
 
3748
4245
  function addKeyframeAtCurrentTime() {
@@ -4553,7 +5050,7 @@ function buildScheduleSection(
4553
5050
  goToBtn.addEventListener("click", () => {
4554
5051
  const globalMs = Math.max(
4555
5052
  sectionStartTime,
4556
- Math.min(sectionEndTime, kf.time + rootStartMs),
5053
+ Math.min(sectionEndTime, kf.time + rootStartMs + ownerStartMs()),
4557
5054
  );
4558
5055
  stopAudio();
4559
5056
  loadPlayPositionSeconds(globalMs);
@@ -4694,10 +5191,13 @@ function updateComponentEditor(selectable: Showable) {
4694
5191
 
4695
5192
  if (replaceable && replaceables) {
4696
5193
  const moveChild = (fromIdx: number, toIdx: number) => {
4697
- const arr = replaceables.get();
4698
- const [item] = arr.splice(fromIdx, 1);
4699
- arr.splice(toIdx, 0, item);
4700
- replaceables.replace(arr);
5194
+ // In a series, the playhead follows the picture it was showing.
5195
+ keepingScene(selectable, () => {
5196
+ const arr = replaceables.get();
5197
+ const [item] = arr.splice(fromIdx, 1);
5198
+ arr.splice(toIdx, 0, item);
5199
+ replaceables.replace(arr);
5200
+ });
4701
5201
  activeRootComponentEditor?.resetAll();
4702
5202
  updateComponentEditor(selectable);
4703
5203
  };
@@ -4735,11 +5235,20 @@ function updateComponentEditor(selectable: Showable) {
4735
5235
  const deleteBtn = document.createElement("button");
4736
5236
  deleteBtn.type = "button";
4737
5237
  deleteBtn.textContent = "🗑";
5238
+ deleteBtn.title = "Delete component";
4738
5239
  deleteBtn.addEventListener("click", () => {
5240
+ // In a series, everything after this child moves earlier, and the playhead goes with it.
5241
+ const root = currentRoot();
5242
+ const childStart = root ? startWithin(root, child) : undefined;
5243
+ const childDuration = child.duration;
5244
+ const chainEndMs = seriesChainEndMs(selectable);
4739
5245
  const arr = replaceables.get();
4740
5246
  const i = arr.indexOf(child);
4741
5247
  if (i !== -1) arr.splice(i, 1);
4742
5248
  replaceables.replace(arr);
5249
+ if (childStart !== undefined) {
5250
+ playheadAfterRemoving(childStart, childStart + childDuration, chainEndMs);
5251
+ }
4743
5252
  if (selectedSlideChild === child) {
4744
5253
  selectedSlideChild = null;
4745
5254
  updateScheduleEditor(selectable);
@@ -4880,6 +5389,16 @@ function updateComponentEditor(selectable: Showable) {
4880
5389
  const entry = key === undefined ? undefined : componentRegistry.get(key);
4881
5390
  if (!entry) return;
4882
5391
  const newChild = entry.create();
5392
+ if (newChild instanceof VideoClipComponent) {
5393
+ // The class default is 16 × 9, and saved files rely on defaults never changing.
5394
+ // So fill the canvas here, as a value of its own, whatever size the canvas is.
5395
+ newChild.destinationRectSchedule.set({
5396
+ x: 0,
5397
+ y: 0,
5398
+ width: canvasRecorder.width,
5399
+ height: canvasRecorder.height,
5400
+ });
5401
+ }
4883
5402
  addTarget.replaceableComponents!.push(newChild);
4884
5403
  afterStructureEdit(newChild);
4885
5404
  });
@@ -4960,7 +5479,7 @@ function buildSoundClipSection(selectable: Showable): HTMLElement {
4960
5479
  clearTimeout(_durationAudioTimer);
4961
5480
  _durationAudioTimer = setTimeout(() => void initAudio(), 300);
4962
5481
  updateScheduleEditor(selectable);
4963
- if (_veRootSelectable) _updateTimeline(_veRootSelectable);
5482
+ refreshTimeline();
4964
5483
  }
4965
5484
 
4966
5485
  /** Every place a sound can live in this video, and when each starts. */
@@ -5072,7 +5591,7 @@ function buildSoundClipSection(selectable: Showable): HTMLElement {
5072
5591
 
5073
5592
  const delBtn = document.createElement("button");
5074
5593
  delBtn.type = "button";
5075
- delBtn.textContent = "✕";
5594
+ delBtn.textContent = "🗑";
5076
5595
  delBtn.title = "Delete clip";
5077
5596
  delBtn.addEventListener("click", () => {
5078
5597
  clips.splice(i, 1);
@@ -5315,7 +5834,7 @@ function buildSoundClipSection(selectable: Showable): HTMLElement {
5315
5834
  // The new owner is in another chapter. Stay here; the clip is gone from this list.
5316
5835
  updateScheduleEditor(selectable);
5317
5836
  }
5318
- if (_veRootSelectable) _updateTimeline(_veRootSelectable);
5837
+ refreshTimeline();
5319
5838
  });
5320
5839
  ownerLabel.append(ownerSelect);
5321
5840
  const playsAt = document.createElement("span");
@@ -5370,6 +5889,9 @@ function buildNameSection(selectable: Showable): HTMLElement {
5370
5889
  resetBtn.disabled = selectable.userEditableDescription === undefined;
5371
5890
  const root = currentSaveTarget();
5372
5891
  if (root) updateComponentEditor(root);
5892
+ // The timeline's blocks and the Edit menu show the name, too.
5893
+ refreshTimeline();
5894
+ scheduleChapterRefresh();
5373
5895
  }
5374
5896
 
5375
5897
  input.addEventListener("input", () => syncName(input.value));
@@ -5450,6 +5972,13 @@ function updateScheduleEditor(selectable: Showable) {
5450
5972
 
5451
5973
  const videoPanel = videoClip
5452
5974
  ? buildVideoClipPanel(videoClip, {
5975
+ setVideoSize: setVideoSizeFromGui,
5976
+ urlChanged() {
5977
+ activeRootComponentEditor?.update(videoClip, videoClip.urlScalar);
5978
+ markDirty();
5979
+ // Rebuild so the URL field shows the new value.
5980
+ updateScheduleEditor(videoClip);
5981
+ },
5453
5982
  scalarsChanged() {
5454
5983
  activeRootComponentEditor?.update(
5455
5984
  videoClip,
@@ -5496,6 +6025,12 @@ function updateScheduleEditor(selectable: Showable) {
5496
6025
  if (selectable instanceof SingleImageComponent) {
5497
6026
  const image = selectable;
5498
6027
  const imagePanel = buildStaticImagePanel(image, {
6028
+ urlChanged() {
6029
+ activeRootComponentEditor?.update(image, image.urlSchedule);
6030
+ markDirty();
6031
+ // Rebuild so the URL field shows the new value.
6032
+ updateScheduleEditor(image);
6033
+ },
5499
6034
  rectKeyframeChanged(kf) {
5500
6035
  // The same sync a drag on the canvas uses, so ✎ edit mode survives.
5501
6036
  markerSyncCallbacks.get(kf)?.(kf.value);
@@ -5616,6 +6151,74 @@ function splitTarget(clip: VideoClipComponent): InSeriesComponent | string {
5616
6151
  return parent;
5617
6152
  }
5618
6153
 
6154
+ // MARK: Keep the scene
6155
+
6156
+ /** Move the playhead to this time, measured from the start of the Visual Editor's root. */
6157
+ function seekInRoot(ms: number): void {
6158
+ stopAudio();
6159
+ loadPlayPositionSeconds(rootStartMs + ms);
6160
+ loadPlayPositionRange();
6161
+ // An edit moved the playhead. Keep it on the timeline's screen if it was there.
6162
+ timelineDisplay.movePlayMs(ms);
6163
+ }
6164
+
6165
+ /**
6166
+ * Before an edit removes time from `series`: where its chain of series ends, measured from the
6167
+ * start of the root. Removing time moves everything up to there earlier, and nothing after it.
6168
+ * Undefined if `series` isn't an In Series in the current root, so nothing moves.
6169
+ */
6170
+ function seriesChainEndMs(series: Showable): number | undefined {
6171
+ const root = currentRoot();
6172
+ if (!root || !(series instanceof InSeriesComponent)) return undefined;
6173
+ let top: Showable = series;
6174
+ while (top !== root && top.parent instanceof InSeriesComponent) {
6175
+ top = top.parent;
6176
+ }
6177
+ const start = startWithin(root, top);
6178
+ return start === undefined ? undefined : start + top.duration;
6179
+ }
6180
+
6181
+ /**
6182
+ * A series just lost the time from `fromMs` to `toMs` (measured from the start of the root), and
6183
+ * everything after it moved earlier to fill the gap. Move the playhead with it, so the preview
6184
+ * keeps showing the same picture. A playhead inside what's gone goes to where the gap was.
6185
+ * @param chainEndMs {@link seriesChainEndMs}, from before the edit.
6186
+ */
6187
+ function playheadAfterRemoving(
6188
+ fromMs: number,
6189
+ toMs: number,
6190
+ chainEndMs: number | undefined,
6191
+ ): void {
6192
+ if (chainEndMs === undefined) return;
6193
+ const at = playheadInRootMs();
6194
+ if (at < fromMs || at >= chainEndMs) return;
6195
+ seekInRoot(at >= toMs ? at - (toMs - fromMs) : fromMs);
6196
+ }
6197
+
6198
+ /**
6199
+ * Run `edit`, which reorders the children of `series`, then put the playhead on the same moment
6200
+ * of the same child, wherever that child is now.
6201
+ */
6202
+ function keepingScene(series: Showable, edit: () => void): void {
6203
+ const root = currentRoot();
6204
+ const seriesStart =
6205
+ root && series instanceof InSeriesComponent
6206
+ ? startWithin(root, series)
6207
+ : undefined;
6208
+ let anchor: { child: Showable; offsetMs: number } | undefined;
6209
+ if (seriesStart !== undefined) {
6210
+ const local = playheadInRootMs() - seriesStart;
6211
+ const found = series.children?.find(
6212
+ ({ start, child }) => local >= start && local < start + child.duration,
6213
+ );
6214
+ if (found) anchor = { child: found.child, offsetMs: local - found.start };
6215
+ }
6216
+ edit();
6217
+ if (!root || !anchor) return;
6218
+ const newStart = startWithin(root, anchor.child);
6219
+ if (newStart !== undefined) seekInRoot(newStart + anchor.offsetMs);
6220
+ }
6221
+
5619
6222
  /** How long a sound plays, for splitting: a clip whose file isn't decoded yet is taken to play forever. */
5620
6223
  function soundLengthForSplit(sound: SoundClip): number {
5621
6224
  return soundClipLengthMs(sound) ?? Infinity;
@@ -5651,8 +6254,16 @@ async function splitAtPlayhead(clip: VideoClipComponent): Promise<void> {
5651
6254
  : [second];
5652
6255
  const siblings = parent.replaceableComponents!.get();
5653
6256
  siblings.splice(siblings.indexOf(clip), 1, ...kept);
6257
+ const chainEndMs = seriesChainEndMs(parent);
5654
6258
  // Saves, rebuilds the audio, and redraws the timeline and the chapter list.
5655
6259
  parent.replaceableComponents!.replace(siblings);
6260
+ // Keep showing the same picture: a piece thrown away takes its time with it.
6261
+ const splitMs = clipStart + first.duration;
6262
+ if (choice.keep === "second") {
6263
+ playheadAfterRemoving(clipStart, splitMs, chainEndMs);
6264
+ } else if (choice.keep === "first") {
6265
+ playheadAfterRemoving(splitMs, splitMs + second.duration, chainEndMs);
6266
+ }
5656
6267
  selectedSoundClip = undefined;
5657
6268
  activeRootComponentEditor?.resetAll();
5658
6269
  if (root !== clip) {
@@ -5739,6 +6350,21 @@ function buildMultiTextFrame(text: MultiTextComponent): HTMLElement {
5739
6350
  textFrameCanvasStates.set(text, newState);
5740
6351
  applyCanvasState();
5741
6352
  },
6353
+ editText() {
6354
+ void openMultiTextDialog(text, {
6355
+ saveNow: _flushDirtyToDb,
6356
+ changed: markDirty,
6357
+ copyComponent: (component) =>
6358
+ buildComponents(serializeComponents([component]))[0],
6359
+ closed() {
6360
+ // The dialog covered the component list and this panel, so neither was kept up to date.
6361
+ activeRootComponentEditor?.resetAll();
6362
+ const root = currentSaveTarget();
6363
+ if (root) updateComponentEditor(root);
6364
+ updateScheduleEditor(text);
6365
+ },
6366
+ });
6367
+ },
5742
6368
  });
5743
6369
  // Adding a keyframe to Position, say, means there's no longer one frame.
5744
6370
  scheduleEditorRefreshers.push(applyCanvasState, panel.refresh);
@@ -7083,6 +7709,8 @@ function refreshEditorsAfterLoad(): void {
7083
7709
  activeRootComponentEditor?.resetAll();
7084
7710
  updateComponentEditor(current);
7085
7711
  updateScheduleEditor(current);
7712
+ // The loaded state may use local files that need 🔓.
7713
+ checkVideosLocalFiles();
7086
7714
  }
7087
7715
 
7088
7716
  /**