simview 3.2__py3-none-any.whl
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.
- simview/__init__.py +69 -0
- simview/__main__.py +174 -0
- simview/launcher.py +109 -0
- simview/live.py +204 -0
- simview/merge.py +593 -0
- simview/model.py +690 -0
- simview/py.typed +0 -0
- simview/scene.py +603 -0
- simview/server.py +739 -0
- simview/state.py +190 -0
- simview/static/css/controls.css +36 -0
- simview/static/js/SimView.js +601 -0
- simview/static/js/components/AnimationController.js +654 -0
- simview/static/js/components/BatchManager.js +198 -0
- simview/static/js/components/InteractionController.js +446 -0
- simview/static/js/components/InteractionControls.js +116 -0
- simview/static/js/components/Scene.js +166 -0
- simview/static/js/components/StateStore.js +188 -0
- simview/static/js/config.js +233 -0
- simview/static/js/main.js +3 -0
- simview/static/js/objects/Body.js +616 -0
- simview/static/js/objects/StaticObject.js +181 -0
- simview/static/js/objects/Terrain.js +514 -0
- simview/static/js/objects/utils.js +378 -0
- simview/static/js/ui/AnalysisPanel.js +233 -0
- simview/static/js/ui/BatchLegend.js +207 -0
- simview/static/js/ui/BodyStateWindow.js +521 -0
- simview/static/js/ui/Controls.js +586 -0
- simview/static/js/ui/ErrorMetrics.js +664 -0
- simview/static/js/ui/Legend.js +87 -0
- simview/static/js/ui/PlaybackControls.js +314 -0
- simview/static/js/ui/ScalarPlotter.js +575 -0
- simview/static/js/utils/blobCodec.js +73 -0
- simview/static/js/utils/bodyTransforms.js +151 -0
- simview/static/js/utils/csv.js +46 -0
- simview/static/js/utils/errorMath.js +69 -0
- simview/static/js/utils/injectStyles.js +16 -0
- simview/static/js/utils/interpolate.js +65 -0
- simview/static/js/utils/liveFollow.js +17 -0
- simview/static/js/utils/loadRecordingLibs.js +34 -0
- simview/static/js/utils/viewState.js +226 -0
- simview/static/lib/chroma-js-3.1.2/index.min.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/Color.js +49 -0
- simview/static/lib/chroma-js-3.1.2/src/chroma.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/colorbrewer.js +81 -0
- simview/static/lib/chroma-js-3.1.2/src/colors/w3cx11.js +164 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/average.js +88 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/bezier.js +86 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/blend.js +57 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/cubehelix.js +87 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/mix.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/random.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/generator/scale.js +394 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/_hsx.js +59 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hcg.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsi.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsl.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/hsv.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/index.js +1 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lch.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/lrgb.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/num.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklab.js +19 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/interpolator/rgb.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/cmyk2rgb.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/cmyk/rgb2cmyk.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/css2rgb.js +238 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/hsl2css.js +26 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lab2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/lch2css.js +24 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklab2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/oklch2css.js +16 -0
- simview/static/lib/chroma-js-3.1.2/src/io/css/rgb2css.js +61 -0
- simview/static/lib/chroma-js-3.1.2/src/io/gl/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/hcg2rgb.js +55 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hcg/rgb2hcg.js +23 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/hex2rgb.js +56 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/index.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hex/rgb2hex.js +29 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/hsi2rgb.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsi/rgb2hsi.js +31 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/hsl2rgb.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsl/rgb2hsl.js +45 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/hsv2rgb.js +47 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/hsv/rgb2hsv.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/input.js +4 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/index.js +28 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab-constants.js +120 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/lab2rgb.js +101 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lab/rgb2lab.js +67 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/hcl2rgb.js +9 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/index.js +35 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lab2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2lab.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/lch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/lch/rgb2lch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/named/index.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/index.js +32 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/num2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/num/rgb2num.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/oklab2rgb.js +34 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklab/rgb2oklab.js +37 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/oklch2rgb.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/io/oklch/rgb2oklch.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/io/rgb/index.js +44 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/index.js +22 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/rgb2temperature.js +30 -0
- simview/static/lib/chroma-js-3.1.2/src/io/temp/temperature2rgb.js +39 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/alpha.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/clipped.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/darken.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/get.js +13 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/luminance.js +54 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/mix.js +10 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/premultiply.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/saturate.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/set.js +43 -0
- simview/static/lib/chroma-js-3.1.2/src/ops/shade.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/analyze.js +191 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/clip_rgb.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrast.js +12 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/contrastAPCA.js +68 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/delta-e.js +62 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/distance.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/index.js +27 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/last.js +8 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/limit.js +5 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/multiply-matrices.js +36 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/scales.js +15 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/type.js +18 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/unpack.js +17 -0
- simview/static/lib/chroma-js-3.1.2/src/utils/valid.js +11 -0
- simview/static/lib/chroma-js-3.1.2/src/version.js +2 -0
- simview/static/lib/download.js +132 -0
- simview/static/lib/js-colormaps.js +19335 -0
- simview/static/lib/tar.js +334 -0
- simview/static/lib/three-0.174.0/addons/controls/OrbitControls.js +1556 -0
- simview/static/lib/three-0.174.0/addons/libs/lil-gui.module.min.js +8 -0
- simview/static/lib/three-0.174.0/three.core.js +48830 -0
- simview/static/lib/three-0.174.0/three.module.js +17313 -0
- simview/static/lib/uPlot.esm.js +6140 -0
- simview/static/lib/uPlot.min.css +2 -0
- simview/static/textures/contacts/red-cross0.png +0 -0
- simview/static/textures/points/ball0.png +0 -0
- simview/static/textures/points/ball1.png +0 -0
- simview/templates/index.html +81 -0
- simview/utils.py +41 -0
- simview-3.2.dist-info/METADATA +552 -0
- simview-3.2.dist-info/RECORD +163 -0
- simview-3.2.dist-info/WHEEL +5 -0
- simview-3.2.dist-info/entry_points.txt +2 -0
- simview-3.2.dist-info/licenses/LICENSE +28 -0
- simview-3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { PlaybackControls } from "../ui/PlaybackControls.js";
|
|
2
|
+
import { resolveStateBodies } from "../utils/bodyTransforms.js";
|
|
3
|
+
import { loadRecordingLibs } from "../utils/loadRecordingLibs.js";
|
|
4
|
+
import { interpolateTransformRows, lerpVectorRows } from "../utils/interpolate.js";
|
|
5
|
+
|
|
6
|
+
// Optional per-batch 3-vector attributes that get lerp'd alongside
|
|
7
|
+
// bodyTransform on the interpolated path. Everything else (contacts, and any
|
|
8
|
+
// other ragged/non-numeric field) snaps to the nearest frame instead.
|
|
9
|
+
const INTERPOLATED_VECTOR_ATTRS = ["velocity", "angularVelocity", "force", "torque"];
|
|
10
|
+
|
|
11
|
+
// Candidate MediaRecorder mimeTypes for the "webm" recording format, in
|
|
12
|
+
// preference order (VP9 first for better quality/size, VP8 as the widely
|
|
13
|
+
// supported fallback). Picked at runtime via MediaRecorder.isTypeSupported
|
|
14
|
+
// since browser support varies -- see pickSupportedMimeType().
|
|
15
|
+
const WEBM_MIME_CANDIDATES = [
|
|
16
|
+
"video/webm;codecs=vp9",
|
|
17
|
+
"video/webm;codecs=vp8",
|
|
18
|
+
"video/webm",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Some Chromium builds support recording directly to MP4 (H.264); when
|
|
22
|
+
// available it's exposed as an extra "mp4" option in the format dropdown
|
|
23
|
+
// (see PlaybackControls.js), purely as a nicety -- webm is always the
|
|
24
|
+
// fallback and works everywhere MediaRecorder does.
|
|
25
|
+
const MP4_MIME_CANDIDATES = ["video/mp4;codecs=avc1", "video/mp4"];
|
|
26
|
+
|
|
27
|
+
function pickSupportedMimeType(candidates) {
|
|
28
|
+
if (typeof MediaRecorder === "undefined" || !MediaRecorder.isTypeSupported) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return candidates.find((type) => MediaRecorder.isTypeSupported(type)) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Exported so PlaybackControls can decide whether to offer "mp4" in the
|
|
35
|
+
// format dropdown without duplicating the MediaRecorder feature-detection.
|
|
36
|
+
export function isMp4RecordingSupported() {
|
|
37
|
+
return pickSupportedMimeType(MP4_MIME_CANDIDATES) !== null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extensionForMimeType(mimeType) {
|
|
41
|
+
return mimeType.startsWith("video/mp4") ? "mp4" : "webm";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Triggers a browser download of `blob` named `filename` via a temporary,
|
|
45
|
+
// never-appended <a download> link -- no library needed for this (CCapture's
|
|
46
|
+
// webm path used to pull in a `download()` helper from lib/download.js; the
|
|
47
|
+
// PNG-sequence path below still uses that helper since tar.js/download.js
|
|
48
|
+
// stay vendored for it).
|
|
49
|
+
function downloadBlob(blob, filename) {
|
|
50
|
+
const url = URL.createObjectURL(blob);
|
|
51
|
+
const a = document.createElement("a");
|
|
52
|
+
a.href = url;
|
|
53
|
+
a.download = filename;
|
|
54
|
+
a.click();
|
|
55
|
+
// Revoke on a delay rather than immediately: some browsers kick off the
|
|
56
|
+
// download asynchronously, and revoking the URL too early can abort it.
|
|
57
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class AnimationController {
|
|
61
|
+
constructor(app, simulationTimestep) {
|
|
62
|
+
this.app = app;
|
|
63
|
+
this.playbackControls = null;
|
|
64
|
+
this.store = null;
|
|
65
|
+
this.isPlaying = false;
|
|
66
|
+
this.playbackSpeed = 1;
|
|
67
|
+
this.isRecording = false;
|
|
68
|
+
this.isLoadingRecorder = false;
|
|
69
|
+
this.startTime = null;
|
|
70
|
+
this.recordingFormat = "webm"; // Default recording format
|
|
71
|
+
|
|
72
|
+
// WEBM/MP4 recording state (native MediaRecorder path -- see
|
|
73
|
+
// startRecording/stopRecording/captureFrame below). null when not
|
|
74
|
+
// recording a video.
|
|
75
|
+
this._mediaRecorder = null;
|
|
76
|
+
this._captureTrack = null; // CanvasCaptureMediaStreamTrack, for requestFrame()
|
|
77
|
+
this._recordedChunks = null;
|
|
78
|
+
this._recordingMimeType = null;
|
|
79
|
+
this._recordingStopped = null; // Promise resolved once the MediaRecorder actually stops
|
|
80
|
+
|
|
81
|
+
// PNG-sequence recording state (tar.js-packed, see captureFrame below).
|
|
82
|
+
this._pngTar = null;
|
|
83
|
+
this._pngFrameCount = 0;
|
|
84
|
+
// Promises for PNG frames still being encoded/appended asynchronously
|
|
85
|
+
// (toBlob + arrayBuffer are both async) -- stopRecording awaits all of
|
|
86
|
+
// these before calling tar.save(), so the last frame(s) of the loop
|
|
87
|
+
// are never dropped from the archive.
|
|
88
|
+
this._pngPending = [];
|
|
89
|
+
this.currentStateIndex = 0;
|
|
90
|
+
this.totalTime = 0; // Total animation time
|
|
91
|
+
this.currentTime = 0; // Current time in the animation
|
|
92
|
+
this.simulationTimestep = simulationTimestep; // Simulation timestep
|
|
93
|
+
this.lastUpdateTime = null;
|
|
94
|
+
|
|
95
|
+
// Memoizes the two resolveStateBodies() results bracketing the
|
|
96
|
+
// current interpolation interval, keyed by their (lo, hi) state
|
|
97
|
+
// indices -- consecutive RAF ticks (or scrubs) landing in the same
|
|
98
|
+
// interval reuse both resolved frames instead of re-resolving.
|
|
99
|
+
this._resolvedCache = { lo: -1, hi: -1, frameLo: null, frameHi: null };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
loadAnimation(store) {
|
|
103
|
+
if (!store || store.length === 0) {
|
|
104
|
+
console.warn("No states received for animation.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
this.store = store;
|
|
108
|
+
this.totalTime = this.store.lastTime();
|
|
109
|
+
|
|
110
|
+
// Infer simulation timestep if not provided or invalid
|
|
111
|
+
if ((!this.simulationTimestep || isNaN(this.simulationTimestep)) && this.store.length > 1) {
|
|
112
|
+
const dt = this.store.timeAt(1) - this.store.timeAt(0);
|
|
113
|
+
if (dt > 0) {
|
|
114
|
+
console.log(`Inferred simulation timestep from states: ${dt}`);
|
|
115
|
+
this.simulationTimestep = dt;
|
|
116
|
+
} else {
|
|
117
|
+
console.warn("Could not infer valid timestep from states, defaulting to 1/60");
|
|
118
|
+
this.simulationTimestep = 1 / 60;
|
|
119
|
+
}
|
|
120
|
+
} else if (!this.simulationTimestep) {
|
|
121
|
+
console.warn("No timestep provided and not enough states to infer. Defaulting to 1/60");
|
|
122
|
+
this.simulationTimestep = 1 / 60;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.playbackControls = new PlaybackControls(this);
|
|
126
|
+
this.goToTime(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
onStatesAppended() {
|
|
130
|
+
if (this.store && this.store.length > 0) {
|
|
131
|
+
this.totalTime = this.store.lastTime();
|
|
132
|
+
}
|
|
133
|
+
if (this.playbackControls) {
|
|
134
|
+
this.playbackControls.forceRedraw();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
play() {
|
|
139
|
+
if (!this.isPlaying) {
|
|
140
|
+
this.isPlaying = true;
|
|
141
|
+
this.lastUpdateTime = performance.now();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
pause() {
|
|
146
|
+
this.isPlaying = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
setSpeed(speed) {
|
|
150
|
+
this.playbackSpeed = speed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setRecordingFormat(format) {
|
|
154
|
+
this.recordingFormat = format;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
forceRedrawStaticElements() {
|
|
158
|
+
this.playbackControls.forceRedraw();
|
|
159
|
+
this.app.bodyStateWindow.forceRedraw();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Binary search over store.timeAt(i) (time-ordered, but not necessarily
|
|
163
|
+
// uniformly spaced -- adaptive-step simulations break the old
|
|
164
|
+
// targetTime/simulationTimestep shortcut) for the index whose time is
|
|
165
|
+
// nearest targetTime.
|
|
166
|
+
getStateIndexForTime(targetTime) {
|
|
167
|
+
const store = this.store;
|
|
168
|
+
let lo = 0;
|
|
169
|
+
let hi = store.length - 1;
|
|
170
|
+
if (targetTime <= store.timeAt(lo)) return lo;
|
|
171
|
+
if (targetTime >= store.timeAt(hi)) return hi;
|
|
172
|
+
while (lo < hi - 1) {
|
|
173
|
+
const mid = (lo + hi) >> 1;
|
|
174
|
+
if (store.timeAt(mid) <= targetTime) {
|
|
175
|
+
lo = mid;
|
|
176
|
+
} else {
|
|
177
|
+
hi = mid;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// lo and hi now straddle targetTime; pick whichever is closer.
|
|
181
|
+
return targetTime - store.timeAt(lo) <= store.timeAt(hi) - targetTime ? lo : hi;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Binary search for the bracketing pair (lo, hi = lo+1 clamped) around
|
|
185
|
+
// targetTime, plus alpha in [0, 1] for interpolating between them. Unlike
|
|
186
|
+
// getStateIndexForTime this never rounds to the nearer frame -- it always
|
|
187
|
+
// returns the pair straddling targetTime (or the boundary pair if
|
|
188
|
+
// targetTime is outside the timeline).
|
|
189
|
+
getBracketingIndices(targetTime) {
|
|
190
|
+
const store = this.store;
|
|
191
|
+
const last = store.length - 1;
|
|
192
|
+
let lo = 0;
|
|
193
|
+
let hi = last;
|
|
194
|
+
if (targetTime <= store.timeAt(lo)) {
|
|
195
|
+
return { lo, hi: Math.min(lo + 1, last), alpha: 0 };
|
|
196
|
+
}
|
|
197
|
+
if (targetTime >= store.timeAt(hi)) {
|
|
198
|
+
return { lo: Math.max(hi - 1, 0), hi, alpha: 1 };
|
|
199
|
+
}
|
|
200
|
+
while (lo < hi - 1) {
|
|
201
|
+
const mid = (lo + hi) >> 1;
|
|
202
|
+
if (store.timeAt(mid) <= targetTime) {
|
|
203
|
+
lo = mid;
|
|
204
|
+
} else {
|
|
205
|
+
hi = mid;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const tLo = store.timeAt(lo);
|
|
209
|
+
const tHi = store.timeAt(hi);
|
|
210
|
+
const span = tHi - tLo;
|
|
211
|
+
const alpha = span > 0 ? (targetTime - tLo) / span : 0; // guard duplicate timestamps
|
|
212
|
+
return { lo, hi, alpha };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
seekToIndex(index) {
|
|
216
|
+
this.currentStateIndex = index;
|
|
217
|
+
this.currentTime = this.store.timeAt(index);
|
|
218
|
+
this.updateScene();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
stepForward() {
|
|
222
|
+
if (this.isPlaying) return; // Ignore if playing
|
|
223
|
+
const newIndex = (this.currentStateIndex + 1) % this.store.length;
|
|
224
|
+
this.seekToIndex(newIndex);
|
|
225
|
+
this.forceRedrawStaticElements();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
stepBackward() {
|
|
229
|
+
if (this.isPlaying) return; // Ignore if playing
|
|
230
|
+
const newIndex =
|
|
231
|
+
(this.currentStateIndex - 1 + this.store.length) % this.store.length;
|
|
232
|
+
this.seekToIndex(newIndex);
|
|
233
|
+
this.forceRedrawStaticElements();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
goToTime(time) {
|
|
237
|
+
time = Math.min(Math.max(time, 0), this.totalTime); // Clamp out-of-bounds time
|
|
238
|
+
if (this.app.uiState?.smoothInterpolation) {
|
|
239
|
+
// Keep the exact scrubbed time (not snapped to the nearest frame) so
|
|
240
|
+
// the interpolated path in updateScene() can render in-between poses;
|
|
241
|
+
// currentStateIndex still tracks the nearest frame for index-snapped
|
|
242
|
+
// consumers (BodyStateWindow, ScalarPlotter, ErrorMetrics, trails).
|
|
243
|
+
this.currentTime = time;
|
|
244
|
+
this.currentStateIndex = this.getStateIndexForTime(time);
|
|
245
|
+
this.updateScene();
|
|
246
|
+
} else {
|
|
247
|
+
const index = this.getStateIndexForTime(time);
|
|
248
|
+
this.seekToIndex(index);
|
|
249
|
+
}
|
|
250
|
+
if (!this.isPlaying) {
|
|
251
|
+
this.forceRedrawStaticElements();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async startRecording() {
|
|
256
|
+
if (this.isRecording || this.isLoadingRecorder) return;
|
|
257
|
+
|
|
258
|
+
if (this.recordingFormat === "png") {
|
|
259
|
+
// Only the PNG-sequence path needs the lazily-loaded tar.js/download.js
|
|
260
|
+
// (see loadRecordingLibs.js) -- webm/mp4 recording is all native
|
|
261
|
+
// MediaRecorder APIs, nothing to load.
|
|
262
|
+
this.isLoadingRecorder = true;
|
|
263
|
+
try {
|
|
264
|
+
await loadRecordingLibs();
|
|
265
|
+
} finally {
|
|
266
|
+
this.isLoadingRecorder = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (this.isRecording) return;
|
|
270
|
+
|
|
271
|
+
// Reset animation to start; the recording always captures exactly one
|
|
272
|
+
// full loop from time 0 (see captureFrame's auto-stop below).
|
|
273
|
+
this.seekToIndex(0);
|
|
274
|
+
|
|
275
|
+
if (this.recordingFormat === "png") {
|
|
276
|
+
this._pngTar = new Tar();
|
|
277
|
+
this._pngFrameCount = 0;
|
|
278
|
+
this._pngPending = [];
|
|
279
|
+
} else {
|
|
280
|
+
if (!this.#startVideoRecording()) return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
this.isRecording = true;
|
|
284
|
+
this.startTime = performance.now();
|
|
285
|
+
if (!this.isPlaying) {
|
|
286
|
+
this.play();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Sets up canvas.captureStream() + MediaRecorder for the webm/mp4 format.
|
|
291
|
+
// Returns false (and leaves no half-initialized state behind) if the
|
|
292
|
+
// browser lacks the needed APIs.
|
|
293
|
+
#startVideoRecording() {
|
|
294
|
+
const canvas = this.app.scene.renderer.domElement;
|
|
295
|
+
if (typeof canvas.captureStream !== "function") {
|
|
296
|
+
console.error("This browser does not support canvas.captureStream(); cannot record video.");
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const mimeType =
|
|
301
|
+
this.recordingFormat === "mp4"
|
|
302
|
+
? pickSupportedMimeType(MP4_MIME_CANDIDATES)
|
|
303
|
+
: pickSupportedMimeType(WEBM_MIME_CANDIDATES);
|
|
304
|
+
if (!mimeType || typeof MediaRecorder === "undefined") {
|
|
305
|
+
console.error("This browser does not support MediaRecorder; cannot record video.");
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// captureStream(0) puts the track in "manual" mode: it only produces a
|
|
310
|
+
// new frame when track.requestFrame() is called, which captureFrame()
|
|
311
|
+
// below does once per actually-rendered frame -- deterministic
|
|
312
|
+
// frame-for-frame capture instead of a realtime capture racing the
|
|
313
|
+
// simulation's own timestep. Fall back to a realtime 60fps stream if
|
|
314
|
+
// requestFrame isn't supported (older browsers).
|
|
315
|
+
const stream = canvas.captureStream(0);
|
|
316
|
+
const [track] = stream.getVideoTracks();
|
|
317
|
+
if (!track || typeof track.requestFrame !== "function") {
|
|
318
|
+
this._captureTrack = null;
|
|
319
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
320
|
+
this._mediaRecorder = new MediaRecorder(canvas.captureStream(60), { mimeType });
|
|
321
|
+
} else {
|
|
322
|
+
this._captureTrack = track;
|
|
323
|
+
this._mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this._recordingMimeType = mimeType;
|
|
327
|
+
this._recordedChunks = [];
|
|
328
|
+
this._mediaRecorder.ondataavailable = (event) => {
|
|
329
|
+
if (event.data && event.data.size > 0) {
|
|
330
|
+
this._recordedChunks.push(event.data);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
this._recordingStopped = new Promise((resolve) => {
|
|
334
|
+
this._mediaRecorder.onstop = resolve;
|
|
335
|
+
});
|
|
336
|
+
this._mediaRecorder.start();
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
getTotalTime() {
|
|
341
|
+
return this.totalTime;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
getCurrentTime() {
|
|
345
|
+
return this.currentTime;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
getCurrentStateIndex() {
|
|
349
|
+
return this.currentStateIndex;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
getCurrentState() {
|
|
353
|
+
return this.store.getFrame(this.currentStateIndex);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
stopRecording() {
|
|
357
|
+
if (!this.isRecording) return;
|
|
358
|
+
this.isRecording = false;
|
|
359
|
+
this.startTime = null;
|
|
360
|
+
|
|
361
|
+
if (this._pngTar) {
|
|
362
|
+
this.#stopPngRecording();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
this.#stopVideoRecording();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async #stopPngRecording() {
|
|
370
|
+
const tar = this._pngTar;
|
|
371
|
+
this._pngTar = null;
|
|
372
|
+
this._pngFrameCount = 0;
|
|
373
|
+
// Wait for every in-flight toBlob()/arrayBuffer() encode to finish
|
|
374
|
+
// appending to the tar before saving it, so the last frame(s)
|
|
375
|
+
// captured before the auto-stop aren't silently dropped.
|
|
376
|
+
const pending = this._pngPending;
|
|
377
|
+
this._pngPending = [];
|
|
378
|
+
await Promise.all(pending);
|
|
379
|
+
|
|
380
|
+
const blob = tar.save();
|
|
381
|
+
download(blob, "simview-recording.tar", "application/x-tar");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async #stopVideoRecording() {
|
|
385
|
+
const recorder = this._mediaRecorder;
|
|
386
|
+
if (!recorder) return;
|
|
387
|
+
const stopped = this._recordingStopped;
|
|
388
|
+
if (recorder.state !== "inactive") {
|
|
389
|
+
recorder.stop();
|
|
390
|
+
}
|
|
391
|
+
await stopped;
|
|
392
|
+
|
|
393
|
+
const chunks = this._recordedChunks || [];
|
|
394
|
+
const mimeType = this._recordingMimeType || "video/webm";
|
|
395
|
+
const blob = new Blob(chunks, { type: mimeType });
|
|
396
|
+
downloadBlob(blob, `simview-recording.${extensionForMimeType(mimeType)}`);
|
|
397
|
+
|
|
398
|
+
if (this._captureTrack) {
|
|
399
|
+
this._captureTrack.stop();
|
|
400
|
+
}
|
|
401
|
+
this._mediaRecorder = null;
|
|
402
|
+
this._captureTrack = null;
|
|
403
|
+
this._recordedChunks = null;
|
|
404
|
+
this._recordingMimeType = null;
|
|
405
|
+
this._recordingStopped = null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
animate(now) {
|
|
409
|
+
if (!this.isPlaying || !this.store) return;
|
|
410
|
+
if (this.lastUpdateTime === null) {
|
|
411
|
+
this.lastUpdateTime = now;
|
|
412
|
+
}
|
|
413
|
+
const dt = (now - this.lastUpdateTime) / 1000;
|
|
414
|
+
this.lastUpdateTime = now;
|
|
415
|
+
this.currentTime += dt * this.playbackSpeed;
|
|
416
|
+
if (this.totalTime > 0) {
|
|
417
|
+
this.currentTime = this.currentTime % this.totalTime;
|
|
418
|
+
} else {
|
|
419
|
+
this.currentTime = 0; // Single-state scene: totalTime % would be NaN
|
|
420
|
+
}
|
|
421
|
+
const newStateIndex = this.getStateIndexForTime(this.currentTime);
|
|
422
|
+
const indexChanged = newStateIndex !== this.currentStateIndex;
|
|
423
|
+
this.currentStateIndex = newStateIndex;
|
|
424
|
+
|
|
425
|
+
if (this.app.uiState?.smoothInterpolation) {
|
|
426
|
+
// Interpolated path: render every tick regardless of whether the
|
|
427
|
+
// nearest index changed, so bodies move smoothly between states
|
|
428
|
+
// instead of snapping. Index-snapped consumers (playbackControls'
|
|
429
|
+
// frame counter, bodyStateWindow readouts) still only refresh
|
|
430
|
+
// when the nearest frame actually changes -- their own throttles
|
|
431
|
+
// also apply, matching today's cadence.
|
|
432
|
+
this.updateScene();
|
|
433
|
+
if (indexChanged) {
|
|
434
|
+
if (this.playbackControls) {
|
|
435
|
+
this.playbackControls.animate(now);
|
|
436
|
+
}
|
|
437
|
+
if (this.app.bodyStateWindow) {
|
|
438
|
+
this.app.bodyStateWindow.animate(now);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
} else if (indexChanged) {
|
|
442
|
+
this.updateScene();
|
|
443
|
+
if (this.playbackControls) {
|
|
444
|
+
this.playbackControls.animate(now);
|
|
445
|
+
}
|
|
446
|
+
if (this.app.bodyStateWindow) {
|
|
447
|
+
this.app.bodyStateWindow.animate(now);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
captureFrame(now) {
|
|
453
|
+
if (!this.isRecording) return;
|
|
454
|
+
const elapsed = now - this.startTime;
|
|
455
|
+
const duration = this.totalTime * 1000; // Convert to milliseconds
|
|
456
|
+
|
|
457
|
+
if (this._pngTar) {
|
|
458
|
+
this.#capturePngFrame();
|
|
459
|
+
} else if (this._captureTrack) {
|
|
460
|
+
// Manual-mode capture stream (see #startVideoRecording): pull
|
|
461
|
+
// exactly one new frame per actually-rendered frame, so the
|
|
462
|
+
// output is frame-for-frame deterministic rather than racing a
|
|
463
|
+
// realtime clock.
|
|
464
|
+
this._captureTrack.requestFrame();
|
|
465
|
+
}
|
|
466
|
+
// Realtime (non-manual) MediaRecorder streams need no per-frame call
|
|
467
|
+
// here -- the browser samples the canvas on its own timer.
|
|
468
|
+
|
|
469
|
+
// Stop recording if we've completed one loop
|
|
470
|
+
if (elapsed >= duration) {
|
|
471
|
+
this.playbackControls.recordButtonClick();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Encodes the current canvas as a PNG blob and appends it to the in-progress
|
|
476
|
+
// tar (see startRecording/stopRecording) -- this is all CCapture's old "png"
|
|
477
|
+
// format did, just without CCapture itself. Frame filenames are zero-padded
|
|
478
|
+
// so a naive lexicographic sort (e.g. `tar tf` or an archive browser)
|
|
479
|
+
// reconstructs playback order, matching CCapture's own PNG-sequence naming.
|
|
480
|
+
#capturePngFrame() {
|
|
481
|
+
const canvas = this.app.scene.renderer.domElement;
|
|
482
|
+
const tar = this._pngTar;
|
|
483
|
+
const index = this._pngFrameCount++;
|
|
484
|
+
const encoded = new Promise((resolve) => {
|
|
485
|
+
canvas.toBlob((blob) => {
|
|
486
|
+
if (!blob || !tar) {
|
|
487
|
+
resolve();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
blob
|
|
491
|
+
.arrayBuffer()
|
|
492
|
+
.then((buf) => {
|
|
493
|
+
const name = String(index).padStart(7, "0") + ".png";
|
|
494
|
+
tar.append(name, new Uint8Array(buf));
|
|
495
|
+
})
|
|
496
|
+
.finally(resolve);
|
|
497
|
+
}, "image/png");
|
|
498
|
+
});
|
|
499
|
+
this._pngPending.push(encoded);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Single-frame PNG screenshot (see ui/PlaybackControls.js's camera button
|
|
503
|
+
// and the "S" keyboard shortcut). Renders the scene once immediately
|
|
504
|
+
// before capturing -- the renderer is created with preserveDrawingBuffer
|
|
505
|
+
// (see config.js RENDERER_CONFIG), so the canvas already holds the last
|
|
506
|
+
// rendered frame's pixels, but re-rendering synchronously first guards
|
|
507
|
+
// against capturing a stale frame if this fires between animation ticks
|
|
508
|
+
// (e.g. right after a scrub, before the next rAF has redrawn). Downloads
|
|
509
|
+
// as `simview_t<currentTime>s.png` via a temporary <a download>, same
|
|
510
|
+
// pattern as downloadBlob() above.
|
|
511
|
+
captureScreenshot() {
|
|
512
|
+
const { scene, renderer } = this.app.scene;
|
|
513
|
+
const camera = this.app.scene.camera;
|
|
514
|
+
renderer.render(scene, camera);
|
|
515
|
+
const canvas = renderer.domElement;
|
|
516
|
+
canvas.toBlob((blob) => {
|
|
517
|
+
if (!blob) {
|
|
518
|
+
console.error("Screenshot capture failed: canvas.toBlob returned null.");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const time = this.currentTime.toFixed(3);
|
|
522
|
+
downloadBlob(blob, `simview_t${time}s.png`);
|
|
523
|
+
}, "image/png");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
updateScene() {
|
|
527
|
+
if (!this.app.bodies || this.currentStateIndex >= this.store.length) return;
|
|
528
|
+
if (this.app.uiState?.smoothInterpolation && this.store.length > 1) {
|
|
529
|
+
this.updateSceneInterpolated();
|
|
530
|
+
} else {
|
|
531
|
+
this.updateSceneSnapped();
|
|
532
|
+
}
|
|
533
|
+
if (this.app.scalarPlotter) {
|
|
534
|
+
this.app.scalarPlotter.setEndIndex(this.currentStateIndex);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Exactly today's behavior: render the nearest recorded frame, no
|
|
539
|
+
// interpolation. Used whenever smooth interpolation is turned off.
|
|
540
|
+
updateSceneSnapped() {
|
|
541
|
+
const state = this.store.getFrame(this.currentStateIndex);
|
|
542
|
+
// Resolve parent-relative (rigid/articulated) transforms and expand
|
|
543
|
+
// grouped names into ordinary absolute-world transforms, then update
|
|
544
|
+
// each body exactly as before.
|
|
545
|
+
const resolved = resolveStateBodies(
|
|
546
|
+
this.app.bodyMeta,
|
|
547
|
+
this.app.bodyTopoOrder,
|
|
548
|
+
this.app.batchManager.simBatches,
|
|
549
|
+
state.bodies
|
|
550
|
+
);
|
|
551
|
+
resolved.forEach((resolvedBodyState, name) => {
|
|
552
|
+
const body = this.app.bodies.get(name);
|
|
553
|
+
if (body) {
|
|
554
|
+
// Update the body state - this handles both batched and non-batched formats
|
|
555
|
+
body.updateState(resolvedBodyState);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Resolves the two frames bracketing this.currentTime (memoized by (lo, hi)
|
|
561
|
+
// so consecutive ticks/scrubs within the same interval don't re-resolve),
|
|
562
|
+
// then linearly interpolates position/vector attributes and slerps
|
|
563
|
+
// orientation between them before handing each body its blended state.
|
|
564
|
+
updateSceneInterpolated() {
|
|
565
|
+
const { lo, hi, alpha } = this.getBracketingIndices(this.currentTime);
|
|
566
|
+
|
|
567
|
+
let { frameLo, frameHi } = this._resolvedCache;
|
|
568
|
+
if (this._resolvedCache.lo !== lo || this._resolvedCache.hi !== hi) {
|
|
569
|
+
const simBatches = this.app.batchManager.simBatches;
|
|
570
|
+
frameLo = resolveStateBodies(
|
|
571
|
+
this.app.bodyMeta,
|
|
572
|
+
this.app.bodyTopoOrder,
|
|
573
|
+
simBatches,
|
|
574
|
+
this.store.getFrame(lo).bodies
|
|
575
|
+
);
|
|
576
|
+
frameHi =
|
|
577
|
+
hi === lo
|
|
578
|
+
? frameLo
|
|
579
|
+
: resolveStateBodies(
|
|
580
|
+
this.app.bodyMeta,
|
|
581
|
+
this.app.bodyTopoOrder,
|
|
582
|
+
simBatches,
|
|
583
|
+
this.store.getFrame(hi).bodies
|
|
584
|
+
);
|
|
585
|
+
this._resolvedCache = { lo, hi, frameLo, frameHi };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Union of body names resolved in either bracketing frame -- a body
|
|
589
|
+
// only present in one (e.g. just appeared/disappeared this frame)
|
|
590
|
+
// falls back to a nearest-frame snap for that body instead of being
|
|
591
|
+
// dropped.
|
|
592
|
+
const names = new Set([...frameLo.keys(), ...frameHi.keys()]);
|
|
593
|
+
names.forEach((name) => {
|
|
594
|
+
const body = this.app.bodies.get(name);
|
|
595
|
+
if (!body) return;
|
|
596
|
+
|
|
597
|
+
const a = frameLo.get(name);
|
|
598
|
+
const b = frameHi.get(name);
|
|
599
|
+
if (!a || !b) {
|
|
600
|
+
// Present in only one bracketing frame: nothing sensible to
|
|
601
|
+
// interpolate against, snap to whichever exists.
|
|
602
|
+
body.updateState(a || b);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
body.updateState(this.interpolateBodyState(a, b, alpha));
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Blends two resolved bodyState entries (same shape resolveStateBodies
|
|
611
|
+
// produces) into one interpolated bodyState: bodyTransform position lerp +
|
|
612
|
+
// quaternion slerp (shortest path), optional vector attributes lerp'd,
|
|
613
|
+
// everything else (contacts, and any other ragged field) snaps to
|
|
614
|
+
// whichever frame alpha is closer to.
|
|
615
|
+
interpolateBodyState(a, b, alpha) {
|
|
616
|
+
const out = { ...(alpha < 0.5 ? a : b) };
|
|
617
|
+
|
|
618
|
+
if (a.bodyTransform && b.bodyTransform) {
|
|
619
|
+
const rowsA = Array.isArray(a.bodyTransform[0]) ? a.bodyTransform : [a.bodyTransform];
|
|
620
|
+
const rowsB = Array.isArray(b.bodyTransform[0]) ? b.bodyTransform : [b.bodyTransform];
|
|
621
|
+
out.bodyTransform = interpolateTransformRows(rowsA, rowsB, alpha);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
INTERPOLATED_VECTOR_ATTRS.forEach((attr) => {
|
|
625
|
+
if (!a[attr] || !b[attr]) return;
|
|
626
|
+
const rowsA = Array.isArray(a[attr][0]) ? a[attr] : [a[attr]];
|
|
627
|
+
const rowsB = Array.isArray(b[attr][0]) ? b[attr] : [b[attr]];
|
|
628
|
+
out[attr] = lerpVectorRows(rowsA, rowsB, alpha);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
return out;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
dispose() {
|
|
635
|
+
if (this.playbackControls) {
|
|
636
|
+
this.playbackControls.dispose();
|
|
637
|
+
this.playbackControls = null;
|
|
638
|
+
}
|
|
639
|
+
if (this.isRecording) {
|
|
640
|
+
this.isRecording = false;
|
|
641
|
+
if (this._mediaRecorder && this._mediaRecorder.state !== "inactive") {
|
|
642
|
+
this._mediaRecorder.stop();
|
|
643
|
+
}
|
|
644
|
+
if (this._captureTrack) {
|
|
645
|
+
this._captureTrack.stop();
|
|
646
|
+
}
|
|
647
|
+
this._mediaRecorder = null;
|
|
648
|
+
this._captureTrack = null;
|
|
649
|
+
this._recordedChunks = null;
|
|
650
|
+
this._pngTar = null;
|
|
651
|
+
this._pngPending = [];
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|