@octane-xplat/camera 0.13.0 → 0.14.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/README.md +63 -3
- package/dist/linux/CameraView.web.tsrx.js +337 -0
- package/dist/linux/host-movie.web.js +100 -0
- package/dist/linux/index.js +5 -0
- package/dist/linux/layout-child.web.js +29 -0
- package/dist/linux/movie-metadata.web.js +65 -0
- package/dist/linux/octane-client-build.json +8 -0
- package/dist/linux/session-backend.linux.js +479 -0
- package/dist/linux/session-core.js +368 -0
- package/dist/linux/session.js +29 -0
- package/dist/linux/synthetic-session.js +280 -0
- package/dist/linux/types.js +19 -0
- package/dist/native/CameraView.android.js +25 -1
- package/dist/native/CameraView.ios.js +24 -1
- package/dist/native/CameraView.windows.js +173 -0
- package/dist/native/camera-permission.windows.js +43 -0
- package/dist/native/camera-session.windows.js +78 -0
- package/dist/native/cx.windows.js +9 -0
- package/dist/native/index.js +4 -1
- package/dist/native/layout-child.windows.js +53 -0
- package/dist/native/octane-client-build.json +1 -1
- package/dist/native/session-backend.android.js +234 -0
- package/dist/native/session-backend.ios.js +682 -0
- package/dist/native/session-backend.js +55 -0
- package/dist/native/session-backend.windows.js +905 -0
- package/dist/native/session-core.js +368 -0
- package/dist/native/session.js +29 -0
- package/dist/native/synthetic-session.js +280 -0
- package/dist/native/types.js +19 -0
- package/dist/web/CameraView.web.tsrx.js +27 -7
- package/dist/web/host-movie.web.js +100 -0
- package/dist/web/index.js +4 -1
- package/dist/web/movie-metadata.web.js +65 -0
- package/dist/web/movie-storage.web.js +119 -0
- package/dist/web/octane-client-build.json +1 -1
- package/dist/web/session-backend.web.js +490 -0
- package/dist/web/session-core.js +368 -0
- package/dist/web/session.js +29 -0
- package/dist/web/synthetic-session.js +280 -0
- package/dist/web/types.js +19 -0
- package/package.json +22 -4
- package/platforms/android/AndroidManifest.xml +2 -0
- package/platforms/android/include.gradle +1 -0
- package/platforms/android/java/com/octanexplat/camera/CameraPreviewView.java +4 -0
- package/platforms/android/java/com/octanexplat/camera/CameraSessionBridge.java +363 -0
- package/platforms/ios/Info.plist +2 -0
- package/platforms/macos/src/XplatCameraHost.swift +513 -0
- package/src/CameraView.android.tsrx +21 -1
- package/src/CameraView.ios.tsrx +28 -2
- package/src/CameraView.macos.tsrx +93 -0
- package/src/CameraView.web.tsrx +20 -2
- package/src/CameraView.windows.tsrx +124 -0
- package/src/camera-permission.windows.ts +76 -0
- package/src/camera-session.macos.ts +75 -0
- package/src/camera-session.windows.ts +123 -0
- package/src/cx.macos.ts +5 -0
- package/src/cx.windows.ts +6 -0
- package/src/host-movie.web.ts +180 -0
- package/src/index.ts +31 -0
- package/src/index.web.ts +31 -0
- package/src/layout-child.windows.ts +79 -0
- package/src/movie-metadata.web.ts +84 -0
- package/src/movie-storage.web.ts +164 -0
- package/src/props.ts +7 -1
- package/src/session-backend.android.ts +397 -0
- package/src/session-backend.ios.ts +1092 -0
- package/src/session-backend.linux.ts +869 -0
- package/src/session-backend.macos.ts +624 -0
- package/src/session-backend.ts +57 -0
- package/src/session-backend.web.ts +874 -0
- package/src/session-backend.windows.ts +1517 -0
- package/src/session-core.ts +659 -0
- package/src/session.ts +37 -0
- package/src/synthetic-session.ts +367 -0
- package/src/types.ts +329 -0
- package/types/index.d.ts +43 -0
- package/types/props.d.ts +7 -1
- package/types/session-core.d.ts +129 -0
- package/types/synthetic-session.d.ts +48 -0
- package/types/types.d.ts +263 -0
- package/src/CameraView.windows.ts +0 -5
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { CameraCaptureError } from "./types.js";
|
|
2
|
+
import { cameraHost, commitHostMovie, openHostMovie, removeHostReservation, reserveHostMovie } from "./host-movie.web.js";
|
|
3
|
+
import { commitMovie, openStoredMovie, removeReservation, reserveMovie } from "./movie-storage.web.js";
|
|
4
|
+
import { inspectMovie } from "./movie-metadata.web.js";
|
|
5
|
+
//#region src/session-backend.web.ts
|
|
6
|
+
var mimeCandidates = [
|
|
7
|
+
"video/webm;codecs=vp8,opus",
|
|
8
|
+
"video/webm;codecs=vp9,opus",
|
|
9
|
+
"video/mp4;codecs=avc1.424028,mp4a.40.2",
|
|
10
|
+
"video/mp4"
|
|
11
|
+
];
|
|
12
|
+
var owner;
|
|
13
|
+
var error = (kind, operation, message, cause) => new CameraCaptureError({
|
|
14
|
+
kind,
|
|
15
|
+
operation,
|
|
16
|
+
message,
|
|
17
|
+
cause
|
|
18
|
+
});
|
|
19
|
+
var policyAllows = (kind) => {
|
|
20
|
+
return (document.permissionsPolicy ?? document.featurePolicy)?.allowsFeature(kind) !== false;
|
|
21
|
+
};
|
|
22
|
+
function createSessionBackend(config) {
|
|
23
|
+
let currentConfig = { ...config };
|
|
24
|
+
let observer;
|
|
25
|
+
let host;
|
|
26
|
+
let listener;
|
|
27
|
+
let stream;
|
|
28
|
+
let ready = false;
|
|
29
|
+
let released = false;
|
|
30
|
+
let generation = 0;
|
|
31
|
+
let persistent = false;
|
|
32
|
+
let recorder;
|
|
33
|
+
let sink;
|
|
34
|
+
let startedAt = 0;
|
|
35
|
+
let stoppedAt = 0;
|
|
36
|
+
let stopTimer;
|
|
37
|
+
let watchdog;
|
|
38
|
+
let recordingAudio = false;
|
|
39
|
+
let captureFailure;
|
|
40
|
+
let hostState = "unknown";
|
|
41
|
+
const hostProbe = cameraHost().then((host) => {
|
|
42
|
+
hostState = host ? "available" : "absent";
|
|
43
|
+
return host;
|
|
44
|
+
});
|
|
45
|
+
const lease = {};
|
|
46
|
+
const known = {
|
|
47
|
+
camera: "unknown",
|
|
48
|
+
microphone: "unknown"
|
|
49
|
+
};
|
|
50
|
+
const browserStorage = () => typeof indexedDB !== "undefined" && !!navigator.storage?.persisted;
|
|
51
|
+
const supported = () => typeof navigator !== "undefined" && globalThis.isSecureContext === true && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined" && !!mimeType() && (hostState === "available" || browserStorage());
|
|
52
|
+
const mimeType = () => typeof MediaRecorder === "undefined" ? "" : mimeCandidates.find((mime) => MediaRecorder.isTypeSupported(mime)) ?? "";
|
|
53
|
+
const markReady = (value) => {
|
|
54
|
+
ready = value;
|
|
55
|
+
observer?.previewReady(value);
|
|
56
|
+
};
|
|
57
|
+
const dropStream = () => {
|
|
58
|
+
if (host) host.srcObject = null;
|
|
59
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
60
|
+
stream = void 0;
|
|
61
|
+
markReady(false);
|
|
62
|
+
if (owner === lease) owner = void 0;
|
|
63
|
+
};
|
|
64
|
+
const onVisibility = () => {
|
|
65
|
+
if (document.visibilityState === "hidden") {
|
|
66
|
+
markReady(false);
|
|
67
|
+
observer?.interruption("began", "background");
|
|
68
|
+
} else {
|
|
69
|
+
observer?.interruption("ended", "background");
|
|
70
|
+
markReady(!!host && stream?.getVideoTracks()[0]?.readyState === "live");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const onPageHide = () => {
|
|
74
|
+
markReady(false);
|
|
75
|
+
observer?.interruption("began", "background");
|
|
76
|
+
};
|
|
77
|
+
const observeTracks = (target) => {
|
|
78
|
+
for (const track of target.getTracks()) {
|
|
79
|
+
track.addEventListener("ended", () => {
|
|
80
|
+
if (track.kind === "audio" && (!sink || !recordingAudio)) return;
|
|
81
|
+
if (stream !== target) return;
|
|
82
|
+
markReady(false);
|
|
83
|
+
observer?.availability(false);
|
|
84
|
+
observer?.interruption("began", "unknown");
|
|
85
|
+
});
|
|
86
|
+
track.addEventListener("mute", () => {
|
|
87
|
+
if (track.kind === "audio" && (!sink || !recordingAudio)) return;
|
|
88
|
+
if (stream === target) {
|
|
89
|
+
markReady(false);
|
|
90
|
+
observer?.interruption("began", "unknown");
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
track.addEventListener("unmute", () => {
|
|
94
|
+
if (stream === target && document.visibilityState !== "hidden") {
|
|
95
|
+
observer?.interruption("ended", "unknown");
|
|
96
|
+
observer?.availability(true);
|
|
97
|
+
markReady(!!host);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
const constraints = () => {
|
|
103
|
+
const camera = currentConfig.camera;
|
|
104
|
+
return {
|
|
105
|
+
width: { ideal: 1280 },
|
|
106
|
+
height: { ideal: 720 },
|
|
107
|
+
frameRate: { ideal: 30 },
|
|
108
|
+
...typeof camera === "object" ? { deviceId: { exact: camera.deviceId } } : camera === "front" || camera === "back" ? { facingMode: { exact: camera === "front" ? "user" : "environment" } } : {}
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
let acquisition;
|
|
112
|
+
const acquireCameraOnce = async () => {
|
|
113
|
+
if (stream?.getVideoTracks()[0]?.readyState === "live") return;
|
|
114
|
+
if (owner && owner !== lease) throw error("busy", "preview", "Another camera session still owns the source");
|
|
115
|
+
owner = lease;
|
|
116
|
+
const epoch = generation;
|
|
117
|
+
try {
|
|
118
|
+
const acquired = await navigator.mediaDevices.getUserMedia({
|
|
119
|
+
video: constraints(),
|
|
120
|
+
audio: false
|
|
121
|
+
});
|
|
122
|
+
if (released || epoch !== generation) {
|
|
123
|
+
acquired.getTracks().forEach((track) => track.stop());
|
|
124
|
+
if (!stream && owner === lease) owner = void 0;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
for (const track of stream?.getAudioTracks() ?? []) acquired.addTrack(track);
|
|
128
|
+
stream = acquired;
|
|
129
|
+
known.camera = "granted";
|
|
130
|
+
observeTracks(acquired);
|
|
131
|
+
observer?.availability(true);
|
|
132
|
+
} catch (cause) {
|
|
133
|
+
if (owner === lease) owner = void 0;
|
|
134
|
+
throw cause;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const acquireCamera = () => {
|
|
138
|
+
acquisition ??= acquireCameraOnce().finally(() => {
|
|
139
|
+
acquisition = void 0;
|
|
140
|
+
});
|
|
141
|
+
return acquisition;
|
|
142
|
+
};
|
|
143
|
+
const showPreview = async () => {
|
|
144
|
+
if (!host || released) return;
|
|
145
|
+
const element = host;
|
|
146
|
+
const epoch = generation;
|
|
147
|
+
if (await checkPermission("camera") !== "granted") {
|
|
148
|
+
listener?.onError?.(error("permissionBlocked", "preview", "Request camera access from a user action before activating this preview"));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await acquireCamera();
|
|
152
|
+
if (host !== element || !stream || released || epoch !== generation) return;
|
|
153
|
+
element.srcObject = stream;
|
|
154
|
+
element.muted = true;
|
|
155
|
+
const facing = stream.getVideoTracks()[0]?.getSettings().facingMode;
|
|
156
|
+
element.style.transform = facing === "user" ? "scaleX(-1)" : "";
|
|
157
|
+
await element.play();
|
|
158
|
+
if (host !== element || released || epoch !== generation) return;
|
|
159
|
+
markReady(document.visibilityState !== "hidden");
|
|
160
|
+
listener?.onReady?.();
|
|
161
|
+
};
|
|
162
|
+
const hostMediaKind = (kind) => kind === "microphone" ? "microphone" : "camera";
|
|
163
|
+
async function checkPermission(kind) {
|
|
164
|
+
if (globalThis.isSecureContext !== true || !navigator.mediaDevices?.getUserMedia) return "unavailable";
|
|
165
|
+
if (!policyAllows(kind)) return "blocked";
|
|
166
|
+
const desktop = await hostProbe;
|
|
167
|
+
if (desktop) try {
|
|
168
|
+
const { status } = await desktop.call("camera", "permissionStatus", { kind: hostMediaKind(kind) });
|
|
169
|
+
if (status === "granted" || status === "notDetermined") return status;
|
|
170
|
+
if (status === "restricted") return "restricted";
|
|
171
|
+
if (status === "denied" || status === "blocked") return "denied";
|
|
172
|
+
return "unknown";
|
|
173
|
+
} catch {
|
|
174
|
+
return "unknown";
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const status = await navigator.permissions.query({ name: kind });
|
|
178
|
+
return status.state === "granted" ? "granted" : status.state === "denied" ? "blocked" : known[kind] === "blocked" ? "blocked" : "notDetermined";
|
|
179
|
+
} catch {
|
|
180
|
+
if (known[kind] !== "granted") return known[kind];
|
|
181
|
+
return (kind === "camera" ? stream?.getVideoTracks() : stream?.getAudioTracks())?.some((track) => track.readyState === "live") ? "granted" : "unknown";
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const settle = (value, expected = sink) => {
|
|
185
|
+
const target = sink;
|
|
186
|
+
if (!target || target !== expected) return;
|
|
187
|
+
sink = void 0;
|
|
188
|
+
recorder = void 0;
|
|
189
|
+
captureFailure = void 0;
|
|
190
|
+
clearTimeout(stopTimer);
|
|
191
|
+
clearTimeout(watchdog);
|
|
192
|
+
if (!host) dropStream();
|
|
193
|
+
target.settled(value);
|
|
194
|
+
};
|
|
195
|
+
const stop = () => {
|
|
196
|
+
if (!recorder || !sink) return;
|
|
197
|
+
stoppedAt ||= performance.now();
|
|
198
|
+
clearTimeout(watchdog);
|
|
199
|
+
const target = sink;
|
|
200
|
+
const fail = captureFailure;
|
|
201
|
+
watchdog = setTimeout(() => {
|
|
202
|
+
if (sink !== target) return;
|
|
203
|
+
dropStream();
|
|
204
|
+
fail?.(error("finalizationFailed", "stop", "Recorder finalization callback timed out"));
|
|
205
|
+
}, 3e4);
|
|
206
|
+
if (recorder.state === "inactive") return;
|
|
207
|
+
try {
|
|
208
|
+
recorder.stop();
|
|
209
|
+
} catch (cause) {
|
|
210
|
+
fail?.(error("captureFailed", "stop", "The recorder could not stop", cause));
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
platform: "web",
|
|
215
|
+
get supported() {
|
|
216
|
+
return supported();
|
|
217
|
+
},
|
|
218
|
+
checkPermission,
|
|
219
|
+
requestPermission: async (kind) => {
|
|
220
|
+
const status = await checkPermission(kind);
|
|
221
|
+
if (status === "blocked" || status === "unavailable") return status;
|
|
222
|
+
if (navigator.userActivation && !navigator.userActivation.isActive && status !== "granted") return "blocked";
|
|
223
|
+
persistent = await navigator.storage?.persisted?.().catch(() => false) ?? false;
|
|
224
|
+
if (!persistent && (!navigator.userActivation || navigator.userActivation.isActive)) persistent = await navigator.storage?.persist?.().catch(() => false) ?? false;
|
|
225
|
+
const desktop = await hostProbe;
|
|
226
|
+
if (desktop) {
|
|
227
|
+
const { status: hostStatus } = await desktop.call("camera", "requestPermission", { kind: hostMediaKind(kind) });
|
|
228
|
+
if (hostStatus === "undeclared") throw error("configurationMissing", "requestPermission", `Missing ${kind === "camera" ? "NSCameraUsageDescription" : "NSMicrophoneUsageDescription"} in the host app's Info.plist`);
|
|
229
|
+
if (hostStatus !== "granted") {
|
|
230
|
+
known[kind] = hostStatus === "denied" || hostStatus === "blocked" ? "denied" : hostStatus === "restricted" ? "restricted" : "unknown";
|
|
231
|
+
return known[kind];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
if (kind === "camera") await acquireCamera();
|
|
236
|
+
else if (!stream?.getAudioTracks().some((track) => track.readyState === "live")) {
|
|
237
|
+
const epoch = generation;
|
|
238
|
+
const audio = await navigator.mediaDevices.getUserMedia({
|
|
239
|
+
audio: true,
|
|
240
|
+
video: false
|
|
241
|
+
});
|
|
242
|
+
if (released || epoch !== generation) {
|
|
243
|
+
audio.getTracks().forEach((track) => track.stop());
|
|
244
|
+
return "unavailable";
|
|
245
|
+
}
|
|
246
|
+
if (!stream) stream = new MediaStream();
|
|
247
|
+
for (const track of audio.getAudioTracks()) stream.addTrack(track);
|
|
248
|
+
observeTracks(stream);
|
|
249
|
+
}
|
|
250
|
+
known[kind] = "granted";
|
|
251
|
+
if (host) await showPreview();
|
|
252
|
+
return "granted";
|
|
253
|
+
} catch (cause) {
|
|
254
|
+
known[kind] = cause instanceof DOMException && (cause.name === "NotAllowedError" || cause.name === "SecurityError") ? "blocked" : "unavailable";
|
|
255
|
+
return known[kind];
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
getCapabilities: async () => {
|
|
259
|
+
const desktop = await hostProbe;
|
|
260
|
+
persistent = await navigator.storage?.persisted?.().catch(() => false) ?? false;
|
|
261
|
+
const devices = await navigator.mediaDevices?.enumerateDevices?.().catch(() => []) ?? [];
|
|
262
|
+
const settings = stream?.getVideoTracks()[0]?.getSettings();
|
|
263
|
+
const mime = mimeType();
|
|
264
|
+
const storageReady = persistent || !!desktop;
|
|
265
|
+
return {
|
|
266
|
+
supported: supported(),
|
|
267
|
+
available: ready && storageReady,
|
|
268
|
+
reason: !supported() ? "Secure media capture, a supported WebM/MP4 codec, and durable storage are required" : !storageReady ? "Persistent origin storage must be granted before recording" : !ready ? "An active permitted preview is required" : void 0,
|
|
269
|
+
cameras: devices.filter((device) => device.kind === "videoinput").map((device) => ({
|
|
270
|
+
id: device.deviceId,
|
|
271
|
+
label: device.label
|
|
272
|
+
})),
|
|
273
|
+
profiles: settings?.width && settings.height && settings.frameRate ? [{
|
|
274
|
+
profile: "standard",
|
|
275
|
+
width: settings.width,
|
|
276
|
+
height: settings.height,
|
|
277
|
+
frameRate: settings.frameRate
|
|
278
|
+
}] : [],
|
|
279
|
+
audio: devices.some((device) => device.kind === "audioinput"),
|
|
280
|
+
orientations: [],
|
|
281
|
+
durationLimit: "bestEffort",
|
|
282
|
+
elapsedTime: "estimated",
|
|
283
|
+
output: {
|
|
284
|
+
mimeType: mime,
|
|
285
|
+
container: !mime ? "" : mime.startsWith("video/mp4") ? "mp4" : "webm",
|
|
286
|
+
storage: desktop ? "appPrivateFile" : "originLocal",
|
|
287
|
+
destinationFileUrl: !!desktop
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
},
|
|
291
|
+
previewReady: () => ready && !!host && !released,
|
|
292
|
+
validateStart: (options) => {
|
|
293
|
+
if (hostState === "absent") {
|
|
294
|
+
if (!persistent) throw error("destinationUnavailable", "startRecording", "Persistent origin storage has not been granted; check capabilities after a user permission action");
|
|
295
|
+
if (options.destinationFileUrl) throw error("unsupportedConfiguration", "startRecording", "Browser output does not accept native file URLs");
|
|
296
|
+
}
|
|
297
|
+
if (options.orientation && options.orientation !== "unspecified") throw error("unsupportedConfiguration", "startRecording", "This browser cannot lock a recording orientation");
|
|
298
|
+
},
|
|
299
|
+
attachPreview: (target, callbacks) => {
|
|
300
|
+
if (released || sink) throw error("invalidState", "attachPreview", "The camera is disposed or still finalizing");
|
|
301
|
+
host = target;
|
|
302
|
+
listener = callbacks;
|
|
303
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
304
|
+
globalThis.addEventListener("pagehide", onPageHide);
|
|
305
|
+
showPreview().catch((cause) => callbacks?.onError?.(error("unavailable", "preview", "Could not activate camera preview", cause)));
|
|
306
|
+
return () => {
|
|
307
|
+
if (host) host.srcObject = null;
|
|
308
|
+
host = void 0;
|
|
309
|
+
listener = void 0;
|
|
310
|
+
generation += 1;
|
|
311
|
+
markReady(false);
|
|
312
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
313
|
+
globalThis.removeEventListener("pagehide", onPageHide);
|
|
314
|
+
if (!sink) dropStream();
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
configure: (next) => {
|
|
318
|
+
const sourceChanged = JSON.stringify(next.camera) !== JSON.stringify(currentConfig.camera) || next.profile !== currentConfig.profile;
|
|
319
|
+
currentConfig = { ...next };
|
|
320
|
+
if (sourceChanged) {
|
|
321
|
+
generation += 1;
|
|
322
|
+
dropStream();
|
|
323
|
+
if (host) showPreview().catch((cause) => listener?.onError?.(error("unavailable", "configure", "Could not change camera", cause)));
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
startCapture: (request, target) => {
|
|
327
|
+
sink = target;
|
|
328
|
+
recordingAudio = request.audio;
|
|
329
|
+
startedAt = 0;
|
|
330
|
+
stoppedAt = 0;
|
|
331
|
+
const source = stream;
|
|
332
|
+
const video = source?.getVideoTracks()[0];
|
|
333
|
+
const audio = source?.getAudioTracks().filter((track) => track.readyState === "live") ?? [];
|
|
334
|
+
if (!video || request.audio && !audio.length) {
|
|
335
|
+
settle({
|
|
336
|
+
kind: "failed",
|
|
337
|
+
stage: "preparation",
|
|
338
|
+
issue: error(request.audio ? "permissionDenied" : "unavailable", "startRecording", request.audio ? "Requested microphone access is missing" : "Video source is unavailable")
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const recordingStream = new MediaStream([video, ...request.audio ? audio : []]);
|
|
343
|
+
let desktop = null;
|
|
344
|
+
let reservation;
|
|
345
|
+
let stage = "preparation";
|
|
346
|
+
const chunks = [];
|
|
347
|
+
let captureError;
|
|
348
|
+
let limitReached = false;
|
|
349
|
+
let failing = false;
|
|
350
|
+
let didStart = false;
|
|
351
|
+
captureFailure = fail;
|
|
352
|
+
(async () => {
|
|
353
|
+
try {
|
|
354
|
+
desktop = await hostProbe;
|
|
355
|
+
if (request.destinationPath && !desktop) throw error("unsupportedConfiguration", "startRecording", "Web recordings do not accept native file destinations");
|
|
356
|
+
if (desktop) reservation = (await reserveHostMovie(desktop, {
|
|
357
|
+
destinationFileUrl: request.destinationPath ? `file://${request.destinationPath.split("/").map(encodeURIComponent).join("/")}` : void 0,
|
|
358
|
+
container: mimeType().startsWith("video/mp4") ? "mp4" : "webm"
|
|
359
|
+
})).fileUrl;
|
|
360
|
+
else {
|
|
361
|
+
if (!await navigator.storage?.persisted?.()) throw error("destinationUnavailable", "startRecording", "Persistent storage is unavailable");
|
|
362
|
+
reservation = await reserveMovie();
|
|
363
|
+
}
|
|
364
|
+
if (!source || video.readyState !== "live" || document.visibilityState === "hidden") throw error("unavailable", "startRecording", "Camera source became inactive before recording");
|
|
365
|
+
const activeRecorder = new MediaRecorder(recordingStream, { mimeType: mimeType() });
|
|
366
|
+
recorder = activeRecorder;
|
|
367
|
+
activeRecorder.ondataavailable = (event) => {
|
|
368
|
+
if (sink !== target || failing) return;
|
|
369
|
+
if (event.data.size) chunks.push(event.data);
|
|
370
|
+
};
|
|
371
|
+
activeRecorder.onerror = (event) => {
|
|
372
|
+
if (sink !== target || failing) return;
|
|
373
|
+
captureError = event;
|
|
374
|
+
stop();
|
|
375
|
+
};
|
|
376
|
+
activeRecorder.onstart = () => {
|
|
377
|
+
if (sink !== target || failing) return;
|
|
378
|
+
if (didStart) return;
|
|
379
|
+
didStart = true;
|
|
380
|
+
clearTimeout(watchdog);
|
|
381
|
+
startedAt = performance.now();
|
|
382
|
+
stage = "capture";
|
|
383
|
+
const settings = video.getSettings();
|
|
384
|
+
const effective = {
|
|
385
|
+
cameraId: settings.deviceId ?? "",
|
|
386
|
+
audio: request.audio,
|
|
387
|
+
width: settings.width ?? 0,
|
|
388
|
+
height: settings.height ?? 0,
|
|
389
|
+
frameRate: settings.frameRate ?? 0,
|
|
390
|
+
orientation: "unspecified",
|
|
391
|
+
mimeType: activeRecorder.mimeType,
|
|
392
|
+
container: activeRecorder.mimeType.startsWith("video/mp4") ? "mp4" : "webm"
|
|
393
|
+
};
|
|
394
|
+
if (request.maximumDurationMs) {
|
|
395
|
+
const limit = request.maximumDurationMs;
|
|
396
|
+
const checkLimit = () => {
|
|
397
|
+
if (sink !== target || failing) return;
|
|
398
|
+
const remaining = limit - (performance.now() - startedAt);
|
|
399
|
+
if (remaining > 0) stopTimer = setTimeout(checkLimit, Math.min(remaining, 2147483647));
|
|
400
|
+
else {
|
|
401
|
+
limitReached = true;
|
|
402
|
+
stop();
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
stopTimer = setTimeout(checkLimit, Math.min(limit, 2147483647));
|
|
406
|
+
}
|
|
407
|
+
target.started(effective);
|
|
408
|
+
};
|
|
409
|
+
activeRecorder.onstop = () => {
|
|
410
|
+
if (sink !== target || failing) return;
|
|
411
|
+
target.finishing(captureError ? "captureError" : limitReached ? "maximumDuration" : void 0);
|
|
412
|
+
stoppedAt ||= performance.now();
|
|
413
|
+
clearTimeout(stopTimer);
|
|
414
|
+
clearTimeout(watchdog);
|
|
415
|
+
(async () => {
|
|
416
|
+
try {
|
|
417
|
+
stage = "finalization";
|
|
418
|
+
if (!startedAt) throw error("noMedia", "finalize", "No recording start was observed");
|
|
419
|
+
const bytes = new Blob(chunks, { type: activeRecorder.mimeType });
|
|
420
|
+
const metadata = await inspectMovie(bytes, request.audio);
|
|
421
|
+
if (sink !== target || failing) return;
|
|
422
|
+
stage = "storage";
|
|
423
|
+
const output = desktop ? await commitHostMovie(desktop, reservation, bytes) : {
|
|
424
|
+
kind: "browserStorage",
|
|
425
|
+
resourceId: reservation,
|
|
426
|
+
retention: "persistent"
|
|
427
|
+
};
|
|
428
|
+
const clip = {
|
|
429
|
+
...metadata,
|
|
430
|
+
output
|
|
431
|
+
};
|
|
432
|
+
if (!desktop) await commitMovie(reservation, bytes, clip);
|
|
433
|
+
settle({
|
|
434
|
+
kind: "clip",
|
|
435
|
+
...clip,
|
|
436
|
+
limitReached,
|
|
437
|
+
warning: captureError ? error("captureFailed", "capture", "Recorder reported an error; finalized media was verified", captureError) : void 0
|
|
438
|
+
}, target);
|
|
439
|
+
} catch (cause) {
|
|
440
|
+
await fail(cause);
|
|
441
|
+
}
|
|
442
|
+
})();
|
|
443
|
+
};
|
|
444
|
+
activeRecorder.start(1e3);
|
|
445
|
+
watchdog = setTimeout(() => {
|
|
446
|
+
if (!startedAt && sink === target) {
|
|
447
|
+
stop();
|
|
448
|
+
fail(error("captureFailed", "startRecording", "Recorder start callback timed out"));
|
|
449
|
+
}
|
|
450
|
+
}, 15e3);
|
|
451
|
+
} catch (cause) {
|
|
452
|
+
await fail(cause);
|
|
453
|
+
}
|
|
454
|
+
})();
|
|
455
|
+
async function fail(cause) {
|
|
456
|
+
if (sink !== target || failing) return;
|
|
457
|
+
failing = true;
|
|
458
|
+
if (reservation) await (desktop ? removeHostReservation(desktop, reservation) : removeReservation(reservation).catch(() => {}));
|
|
459
|
+
settle({
|
|
460
|
+
kind: "failed",
|
|
461
|
+
stage,
|
|
462
|
+
issue: cause instanceof CameraCaptureError ? cause : error(stage === "storage" ? "storageFailed" : stage === "preparation" || stage === "capture" ? "captureFailed" : "finalizationFailed", stage, "Movie recording could not complete", cause)
|
|
463
|
+
}, target);
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
stopCapture: stop,
|
|
467
|
+
elapsedMs: () => startedAt ? Math.max(0, (stoppedAt || performance.now()) - startedAt) : 0,
|
|
468
|
+
openOutput: async (output) => {
|
|
469
|
+
if (output.kind === "nativeFile") {
|
|
470
|
+
const desktop = await hostProbe;
|
|
471
|
+
if (!desktop) throw error("unavailable", "openOutput", "File-backed output requires the desktop host that recorded it");
|
|
472
|
+
return openHostMovie(desktop, output);
|
|
473
|
+
}
|
|
474
|
+
return openStoredMovie(output);
|
|
475
|
+
},
|
|
476
|
+
release: async () => {
|
|
477
|
+
released = true;
|
|
478
|
+
generation += 1;
|
|
479
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
480
|
+
globalThis.removeEventListener("pagehide", onPageHide);
|
|
481
|
+
dropStream();
|
|
482
|
+
host = void 0;
|
|
483
|
+
},
|
|
484
|
+
setObserver: (value) => {
|
|
485
|
+
observer = value;
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
export { createSessionBackend };
|