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.
- package/BREAKING-CHANGES.md +18 -0
- package/README.md +112 -20
- package/dev/audio-builder.ts +4 -2
- package/dev/canvas-recorder.css +59 -0
- package/dev/canvas-recorder.ts +679 -51
- package/dev/delay-files.ts +1 -1
- package/dev/font-utils.ts +8 -1
- package/dev/format-editor.ts +734 -0
- package/dev/format-string.ts +110 -0
- package/dev/local-file-row.ts +107 -0
- package/dev/multi-text-dialog.ts +360 -0
- package/dev/multi-text-panel.ts +11 -0
- package/dev/player-body.ts +86 -6
- package/dev/rect-snap.ts +9 -9
- package/dev/slide-panel.ts +1 -1
- package/dev/split-dialog.ts +13 -2
- package/dev/static-image-panel.ts +35 -5
- package/dev/timeline-display.ts +50 -5
- package/dev/transcripts.ts +5 -3
- package/dev/video-clip-panel.ts +201 -15
- package/dist/dev/audio-builder.d.ts +1 -1
- package/dist/dev/audio-builder.d.ts.map +1 -1
- package/dist/dev/audio-builder.js +3 -2
- package/dist/dev/audio-builder.js.map +1 -1
- package/dist/dev/canvas-recorder.css +59 -0
- package/dist/dev/canvas-recorder.js +612 -50
- package/dist/dev/canvas-recorder.js.map +1 -1
- package/dist/dev/delay-files.js +1 -1
- package/dist/dev/delay-files.js.map +1 -1
- package/dist/dev/font-utils.d.ts.map +1 -1
- package/dist/dev/font-utils.js +9 -1
- package/dist/dev/font-utils.js.map +1 -1
- package/dist/dev/format-editor.d.ts +75 -0
- package/dist/dev/format-editor.d.ts.map +1 -0
- package/dist/dev/format-editor.js +642 -0
- package/dist/dev/format-editor.js.map +1 -0
- package/dist/dev/format-string.d.ts +62 -0
- package/dist/dev/format-string.d.ts.map +1 -0
- package/dist/dev/format-string.js +86 -0
- package/dist/dev/format-string.js.map +1 -0
- package/dist/dev/local-file-row.d.ts +22 -0
- package/dist/dev/local-file-row.d.ts.map +1 -0
- package/dist/dev/local-file-row.js +84 -0
- package/dist/dev/local-file-row.js.map +1 -0
- package/dist/dev/multi-text-dialog.d.ts +30 -0
- package/dist/dev/multi-text-dialog.d.ts.map +1 -0
- package/dist/dev/multi-text-dialog.js +297 -0
- package/dist/dev/multi-text-dialog.js.map +1 -0
- package/dist/dev/multi-text-panel.d.ts +2 -0
- package/dist/dev/multi-text-panel.d.ts.map +1 -1
- package/dist/dev/multi-text-panel.js +4 -0
- package/dist/dev/multi-text-panel.js.map +1 -1
- package/dist/dev/player-body.d.ts +1 -1
- package/dist/dev/player-body.d.ts.map +1 -1
- package/dist/dev/player-body.js +86 -6
- package/dist/dev/player-body.js.map +1 -1
- package/dist/dev/rect-snap.d.ts +1 -1
- package/dist/dev/rect-snap.js +7 -7
- package/dist/dev/rect-snap.js.map +1 -1
- package/dist/dev/slide-panel.d.ts +1 -1
- package/dist/dev/slide-panel.d.ts.map +1 -1
- package/dist/dev/slide-panel.js +1 -1
- package/dist/dev/slide-panel.js.map +1 -1
- package/dist/dev/split-dialog.d.ts.map +1 -1
- package/dist/dev/split-dialog.js +14 -2
- package/dist/dev/split-dialog.js.map +1 -1
- package/dist/dev/static-image-panel.d.ts +2 -0
- package/dist/dev/static-image-panel.d.ts.map +1 -1
- package/dist/dev/static-image-panel.js +29 -5
- package/dist/dev/static-image-panel.js.map +1 -1
- package/dist/dev/timeline-display.d.ts +23 -2
- package/dist/dev/timeline-display.d.ts.map +1 -1
- package/dist/dev/timeline-display.js +47 -5
- package/dist/dev/timeline-display.js.map +1 -1
- package/dist/dev/transcripts.d.ts.map +1 -1
- package/dist/dev/transcripts.js +5 -3
- package/dist/dev/transcripts.js.map +1 -1
- package/dist/dev/video-clip-panel.d.ts +8 -0
- package/dist/dev/video-clip-panel.d.ts.map +1 -1
- package/dist/dev/video-clip-panel.js +175 -9
- package/dist/dev/video-clip-panel.js.map +1 -1
- package/dist/lib/index.d.ts +3 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +3 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/main-control.d.ts +50 -4
- package/dist/lib/main-control.d.ts.map +1 -1
- package/dist/lib/main-control.js +94 -12
- package/dist/lib/main-control.js.map +1 -1
- package/dist/src/binary-search.js +1 -1
- package/dist/src/binary-search.js.map +1 -1
- package/dist/src/corner-rounder.d.ts +17 -0
- package/dist/src/corner-rounder.d.ts.map +1 -0
- package/dist/src/corner-rounder.js +89 -0
- package/dist/src/corner-rounder.js.map +1 -0
- package/dist/src/cross-fade.d.ts +1 -1
- package/dist/src/cross-fade.d.ts.map +1 -1
- package/dist/src/cross-fade.js +3 -3
- package/dist/src/cross-fade.js.map +1 -1
- package/dist/src/fancy-text.d.ts +116 -0
- package/dist/src/fancy-text.d.ts.map +1 -0
- package/dist/src/fancy-text.js +211 -0
- package/dist/src/fancy-text.js.map +1 -0
- package/dist/src/glib/letters-base.d.ts +1 -1
- package/dist/src/glib/letters-base.d.ts.map +1 -1
- package/dist/src/glib/letters-base.js +4 -3
- package/dist/src/glib/letters-base.js.map +1 -1
- package/dist/src/glib/line-font.d.ts +2 -2
- package/dist/src/glib/line-font.d.ts.map +1 -1
- package/dist/src/glib/line-font.js +2 -2
- package/dist/src/glib/line-font.js.map +1 -1
- package/dist/src/glib/paragraph-layout.d.ts +2 -2
- package/dist/src/glib/paragraph-layout.d.ts.map +1 -1
- package/dist/src/glib/paragraph-layout.js +1 -1
- package/dist/src/glib/paragraph-layout.js.map +1 -1
- package/dist/src/glib/path-shape-splitter.d.ts +1 -1
- package/dist/src/glib/path-shape-splitter.d.ts.map +1 -1
- package/dist/src/glib/path-shape-splitter.js +1 -1
- package/dist/src/glib/path-shape-splitter.js.map +1 -1
- package/dist/src/glib/path-shape.js +2 -2
- package/dist/src/glib/path-shape.js.map +1 -1
- package/dist/src/interpolate.d.ts +2 -2
- package/dist/src/interpolate.d.ts.map +1 -1
- package/dist/src/interpolate.js +1 -1
- package/dist/src/interpolate.js.map +1 -1
- package/dist/src/lattice.d.ts +1 -1
- package/dist/src/lattice.d.ts.map +1 -1
- package/dist/src/local-files.d.ts +129 -0
- package/dist/src/local-files.d.ts.map +1 -0
- package/dist/src/local-files.js +327 -0
- package/dist/src/local-files.js.map +1 -0
- package/dist/src/morph-animation.d.ts +1 -1
- package/dist/src/morph-animation.d.ts.map +1 -1
- package/dist/src/morph-animation.js +2 -2
- package/dist/src/morph-animation.js.map +1 -1
- package/dist/src/peano-fourier/fourier-shared.d.ts +1 -1
- package/dist/src/peano-fourier/fourier-shared.d.ts.map +1 -1
- package/dist/src/peano-fourier/fourier-shared.js +17 -4
- package/dist/src/peano-fourier/fourier-shared.js.map +1 -1
- package/dist/src/schedule-helper.d.ts +4 -4
- package/dist/src/schedule-helper.d.ts.map +1 -1
- package/dist/src/schedule-helper.js +2 -2
- package/dist/src/schedule-helper.js.map +1 -1
- package/dist/src/showable.d.ts +36 -4
- package/dist/src/showable.d.ts.map +1 -1
- package/dist/src/showable.js +37 -1
- package/dist/src/showable.js.map +1 -1
- package/dist/src/slide-components/arrow.d.ts +4 -4
- package/dist/src/slide-components/arrow.d.ts.map +1 -1
- package/dist/src/slide-components/arrow.js +2 -2
- package/dist/src/slide-components/arrow.js.map +1 -1
- package/dist/src/slide-components/cross-fade-transition.d.ts +3 -3
- package/dist/src/slide-components/cross-fade-transition.d.ts.map +1 -1
- package/dist/src/slide-components/cross-fade-transition.js +3 -3
- package/dist/src/slide-components/cross-fade-transition.js.map +1 -1
- package/dist/src/slide-components/duration-agnostic.d.ts +3 -3
- package/dist/src/slide-components/duration-agnostic.d.ts.map +1 -1
- package/dist/src/slide-components/duration-agnostic.js +1 -1
- package/dist/src/slide-components/duration-agnostic.js.map +1 -1
- package/dist/src/slide-components/fixed-duration.d.ts +1 -1
- package/dist/src/slide-components/fixed-duration.d.ts.map +1 -1
- package/dist/src/slide-components/fixed-duration.js +1 -1
- package/dist/src/slide-components/fixed-duration.js.map +1 -1
- package/dist/src/slide-components/fourier.d.ts +6 -6
- package/dist/src/slide-components/fourier.d.ts.map +1 -1
- package/dist/src/slide-components/fourier.js +6 -6
- package/dist/src/slide-components/fourier.js.map +1 -1
- package/dist/src/slide-components/frame-counter.d.ts +2 -2
- package/dist/src/slide-components/frame-counter.d.ts.map +1 -1
- package/dist/src/slide-components/frame-counter.js +1 -1
- package/dist/src/slide-components/frame-counter.js.map +1 -1
- package/dist/src/slide-components/function-graph.d.ts +2 -2
- package/dist/src/slide-components/function-graph.d.ts.map +1 -1
- package/dist/src/slide-components/function-graph.js +3 -3
- package/dist/src/slide-components/function-graph.js.map +1 -1
- package/dist/src/slide-components/halftone-shadow.d.ts +4 -4
- package/dist/src/slide-components/halftone-shadow.d.ts.map +1 -1
- package/dist/src/slide-components/halftone-shadow.js +2 -2
- package/dist/src/slide-components/halftone-shadow.js.map +1 -1
- package/dist/src/slide-components/handwriting-effect.d.ts +5 -5
- package/dist/src/slide-components/handwriting-effect.d.ts.map +1 -1
- package/dist/src/slide-components/handwriting-effect.js +5 -5
- package/dist/src/slide-components/handwriting-effect.js.map +1 -1
- package/dist/src/slide-components/hold.d.ts +3 -3
- package/dist/src/slide-components/hold.d.ts.map +1 -1
- package/dist/src/slide-components/hold.js +3 -3
- package/dist/src/slide-components/hold.js.map +1 -1
- package/dist/src/slide-components/image.d.ts +3 -3
- package/dist/src/slide-components/image.d.ts.map +1 -1
- package/dist/src/slide-components/image.js +3 -3
- package/dist/src/slide-components/image.js.map +1 -1
- package/dist/src/slide-components/in-parallel.d.ts +3 -3
- package/dist/src/slide-components/in-parallel.d.ts.map +1 -1
- package/dist/src/slide-components/in-parallel.js +2 -2
- package/dist/src/slide-components/in-parallel.js.map +1 -1
- package/dist/src/slide-components/in-series.d.ts +4 -4
- package/dist/src/slide-components/in-series.d.ts.map +1 -1
- package/dist/src/slide-components/in-series.js +5 -1
- package/dist/src/slide-components/in-series.js.map +1 -1
- package/dist/src/slide-components/live-duration.d.ts +1 -1
- package/dist/src/slide-components/live-duration.d.ts.map +1 -1
- package/dist/src/slide-components/live-duration.js +1 -1
- package/dist/src/slide-components/live-duration.js.map +1 -1
- package/dist/src/slide-components/multi-text.d.ts +76 -15
- package/dist/src/slide-components/multi-text.d.ts.map +1 -1
- package/dist/src/slide-components/multi-text.js +170 -25
- package/dist/src/slide-components/multi-text.js.map +1 -1
- package/dist/src/slide-components/rectangle.d.ts +3 -3
- package/dist/src/slide-components/rectangle.d.ts.map +1 -1
- package/dist/src/slide-components/rectangle.js +1 -1
- package/dist/src/slide-components/rectangle.js.map +1 -1
- package/dist/src/slide-components/registry.d.ts +1 -1
- package/dist/src/slide-components/registry.d.ts.map +1 -1
- package/dist/src/slide-components/registry.js +18 -18
- package/dist/src/slide-components/registry.js.map +1 -1
- package/dist/src/slide-components/serialize.d.ts +1 -1
- package/dist/src/slide-components/serialize.d.ts.map +1 -1
- package/dist/src/slide-components/serialize.js +2 -2
- package/dist/src/slide-components/serialize.js.map +1 -1
- package/dist/src/slide-components/show-error.js +2 -2
- package/dist/src/slide-components/show-error.js.map +1 -1
- package/dist/src/slide-components/simple-text.d.ts +4 -4
- package/dist/src/slide-components/simple-text.d.ts.map +1 -1
- package/dist/src/slide-components/simple-text.js +4 -4
- package/dist/src/slide-components/simple-text.js.map +1 -1
- package/dist/src/slide-components/slide-component.d.ts +5 -5
- package/dist/src/slide-components/slide-component.d.ts.map +1 -1
- package/dist/src/slide-components/slide-component.js +4 -4
- package/dist/src/slide-components/slide-component.js.map +1 -1
- package/dist/src/slide-components/slide-left.d.ts +3 -3
- package/dist/src/slide-components/slide-left.d.ts.map +1 -1
- package/dist/src/slide-components/slide-left.js +3 -3
- package/dist/src/slide-components/slide-left.js.map +1 -1
- package/dist/src/slide-components/split-video-clip.d.ts +2 -2
- package/dist/src/slide-components/split-video-clip.d.ts.map +1 -1
- package/dist/src/slide-components/split-video-clip.js +3 -3
- package/dist/src/slide-components/split-video-clip.js.map +1 -1
- package/dist/src/slide-components/text-effect.d.ts +2 -2
- package/dist/src/slide-components/text-effect.d.ts.map +1 -1
- package/dist/src/slide-components/traditional-text.d.ts +4 -4
- package/dist/src/slide-components/traditional-text.d.ts.map +1 -1
- package/dist/src/slide-components/traditional-text.js +3 -3
- package/dist/src/slide-components/traditional-text.js.map +1 -1
- package/dist/src/slide-components/video-clip.d.ts +17 -5
- package/dist/src/slide-components/video-clip.d.ts.map +1 -1
- package/dist/src/slide-components/video-clip.js +52 -11
- package/dist/src/slide-components/video-clip.js.map +1 -1
- package/dist/src/slide-components/video-info.d.ts +11 -11
- package/dist/src/slide-components/video-info.d.ts.map +1 -1
- package/dist/src/slide-components/video-info.js +32 -21
- package/dist/src/slide-components/video-info.js.map +1 -1
- package/dist/src/slow-image-sources.d.ts +7 -1
- package/dist/src/slow-image-sources.d.ts.map +1 -1
- package/dist/src/slow-image-sources.js +36 -5
- package/dist/src/slow-image-sources.js.map +1 -1
- package/dist/src/snapshot.d.ts +2 -2
- package/dist/src/snapshot.d.ts.map +1 -1
- package/dist/src/snapshot.js +4 -4
- package/dist/src/snapshot.js.map +1 -1
- package/dist/src/stroke-colors.d.ts +1 -1
- package/dist/src/stroke-colors.d.ts.map +1 -1
- package/dist/src/stroke-colors.js +4 -4
- package/dist/src/stroke-colors.js.map +1 -1
- package/dist/src/utility.d.ts +3 -4
- package/dist/src/utility.d.ts.map +1 -1
- package/dist/src/utility.js +4 -4
- package/dist/src/utility.js.map +1 -1
- package/lib/index.ts +3 -0
- package/lib/main-control.ts +119 -12
- package/package.json +7 -5
- package/src/binary-search.ts +2 -2
- package/src/corner-rounder.ts +128 -0
- package/src/cross-fade.ts +3 -3
- package/src/fancy-text.ts +255 -0
- package/src/glib/letters-base.ts +4 -3
- package/src/glib/line-font.ts +2 -2
- package/src/glib/paragraph-layout.ts +2 -2
- package/src/glib/path-shape-splitter.ts +1 -1
- package/src/glib/path-shape.ts +2 -2
- package/src/interpolate.ts +3 -3
- package/src/lattice.ts +1 -1
- package/src/local-files.ts +389 -0
- package/src/morph-animation.ts +2 -2
- package/src/peano-fourier/fourier-shared.ts +17 -4
- package/src/schedule-helper.ts +4 -4
- package/src/showable.ts +54 -4
- package/src/slide-components/arrow.ts +4 -4
- package/src/slide-components/cross-fade-transition.ts +4 -4
- package/src/slide-components/duration-agnostic.ts +3 -3
- package/src/slide-components/fixed-duration.ts +1 -1
- package/src/slide-components/fourier.ts +9 -9
- package/src/slide-components/frame-counter.ts +2 -2
- package/src/slide-components/function-graph.ts +4 -4
- package/src/slide-components/halftone-shadow.ts +4 -4
- package/src/slide-components/handwriting-effect.ts +7 -7
- package/src/slide-components/hold.ts +4 -4
- package/src/slide-components/image.ts +5 -5
- package/src/slide-components/in-parallel.ts +4 -4
- package/src/slide-components/in-series.ts +7 -4
- package/src/slide-components/live-duration.ts +1 -1
- package/src/slide-components/multi-text.ts +195 -30
- package/src/slide-components/rectangle.ts +3 -3
- package/src/slide-components/registry.ts +19 -19
- package/src/slide-components/serialize.ts +2 -2
- package/src/slide-components/show-error.ts +2 -2
- package/src/slide-components/simple-text.ts +7 -7
- package/src/slide-components/slide-component.ts +7 -7
- package/src/slide-components/slide-left.ts +4 -4
- package/src/slide-components/split-video-clip.ts +4 -4
- package/src/slide-components/text-effect.ts +2 -2
- package/src/slide-components/traditional-text.ts +5 -5
- package/src/slide-components/video-clip.ts +64 -13
- package/src/slide-components/video-info.ts +33 -21
- package/src/slow-image-sources.ts +38 -6
- package/src/snapshot.ts +4 -4
- package/src/stroke-colors.ts +4 -4
- package/src/utility.ts +5 -5
package/dev/canvas-recorder.ts
CHANGED
|
@@ -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, ">");
|
|
859
1103
|
}
|
|
860
1104
|
|
|
861
|
-
|
|
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
|
|
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 =
|
|
876
|
-
canvas.height =
|
|
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: "
|
|
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
|
-
//
|
|
1172
|
+
// HEVC (H.265): Macs encode it in hardware, fast and well.
|
|
915
1173
|
const videoSource = new CanvasSource(canvas, {
|
|
916
1174
|
codec: "hevc",
|
|
917
|
-
|
|
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 (
|
|
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((
|
|
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 =
|
|
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 =
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
3182
|
+
out.push(
|
|
3183
|
+
`${indent}${parentExpr}.replaceableComponents!.push(${varName});`,
|
|
3184
|
+
);
|
|
2806
3185
|
|
|
2807
3186
|
return out;
|
|
2808
3187
|
}
|
|
2809
3188
|
|
|
2810
|
-
|
|
2811
|
-
|
|
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
|
|
2887
|
-
|
|
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
|
-
|
|
2890
|
-
|
|
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 ===
|
|
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
|
-
/**
|
|
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
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|