@design.estate/wcctools 6.1.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/changelog.md +20 -0
  2. package/dist_shell/bundle.js +405 -368
  3. package/dist_shell/bundle.js.map +1 -1
  4. package/dist_shell/bundle.js.third-party-notices.json +52 -0
  5. package/dist_shell/bundle.js.third-party-notices.txt +107 -1
  6. package/dist_shell/index.html +1 -1
  7. package/dist_ts/00_commitinfo_data.js +1 -1
  8. package/dist_ts/classes.bundlestatus.d.ts +36 -0
  9. package/dist_ts/classes.bundlestatus.js +110 -0
  10. package/dist_ts/classes.devapi.d.ts +3 -0
  11. package/dist_ts/classes.devapi.js +8 -1
  12. package/dist_ts/classes.devserver.d.ts +45 -10
  13. package/dist_ts/classes.devserver.js +123 -49
  14. package/dist_ts/cli.js +5 -2
  15. package/dist_ts/index.d.ts +0 -2
  16. package/dist_ts/index.js +1 -3
  17. package/dist_ts_interfaces/requests.d.ts +35 -0
  18. package/dist_ts_shell/bundlestatus.d.ts +15 -0
  19. package/dist_ts_shell/bundlestatus.js +64 -0
  20. package/dist_ts_shell/elements/wcc-contextmenu.d.ts +3 -0
  21. package/dist_ts_shell/elements/wcc-contextmenu.js +18 -3
  22. package/dist_ts_shell/elements/wcc-preview-frame.d.ts +6 -0
  23. package/dist_ts_shell/elements/wcc-preview-frame.js +50 -2
  24. package/dist_ts_shell/elements/wcc-shell.d.ts +15 -0
  25. package/dist_ts_shell/elements/wcc-shell.js +45 -3
  26. package/dist_ts_shell/plugins.d.ts +2 -1
  27. package/dist_ts_shell/plugins.js +3 -2
  28. package/dist_ts_web/00_commitinfo_data.js +1 -1
  29. package/package.json +5 -2
  30. package/readme.md +6 -2
  31. package/ts/00_commitinfo_data.ts +1 -1
  32. package/ts/classes.bundlestatus.ts +123 -0
  33. package/ts/classes.devapi.ts +9 -0
  34. package/ts/classes.devserver.ts +127 -47
  35. package/ts/cli.ts +4 -1
  36. package/ts/index.ts +0 -2
  37. package/ts_interfaces/bridge.ts +95 -0
  38. package/ts_interfaces/catalog.ts +37 -0
  39. package/ts_interfaces/index.ts +4 -0
  40. package/ts_interfaces/plugins.ts +3 -0
  41. package/ts_interfaces/requests.ts +65 -0
  42. package/ts_interfaces/standard.ts +103 -0
  43. package/ts_shared/index.ts +1 -0
  44. package/ts_shared/plugins.ts +3 -0
  45. package/ts_shared/previewroute.ts +58 -0
  46. package/ts_shell/bundlestatus.ts +68 -0
  47. package/ts_shell/elements/wcc-contextmenu.ts +306 -0
  48. package/ts_shell/elements/wcc-preview-frame.ts +167 -0
  49. package/ts_shell/elements/wcc-properties.ts +998 -0
  50. package/ts_shell/elements/wcc-record-button.ts +108 -0
  51. package/ts_shell/elements/wcc-recording-panel.ts +1005 -0
  52. package/ts_shell/elements/wcc-shell.ts +485 -0
  53. package/ts_shell/elements/wcc-sidebar.ts +1419 -0
  54. package/ts_shell/index.html +21 -0
  55. package/ts_shell/index.ts +4 -0
  56. package/ts_shell/plugins.ts +12 -0
  57. package/ts_shell/previewconnection.ts +103 -0
  58. package/ts_shell/services/recorder.service.ts +451 -0
  59. package/ts_shell/types/dom-mediacapture-stub/index.d.ts +12 -0
  60. package/ts_shell/types/dom-mediacapture-stub/package.json +6 -0
  61. package/ts_shell/types/dom-webcodecs-stub/index.d.ts +2 -0
  62. package/ts_shell/types/dom-webcodecs-stub/package.json +6 -0
  63. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -0,0 +1,21 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>wcctools</title>
7
+ <link rel="icon" href="data:," />
8
+ <link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
9
+ <link rel="stylesheet" href="https://assetbroker.lossless.one/fonts/fonts.css">
10
+ <style>
11
+ body {
12
+ margin: 0px;
13
+ background: #0a0a0a;
14
+ }
15
+ </style>
16
+ <script type="module" src="/wcctools/bundle.js"></script>
17
+ </head>
18
+ <body>
19
+ <wcc-shell preview-path="/wcctools-preview" api-path="/wcctools/typedrequest"></wcc-shell>
20
+ </body>
21
+ </html>
@@ -0,0 +1,4 @@
1
+ /**
2
+ * The wcctools shell bundle: the catalog UI that `wcctools dev` serves on `/wcctools/`.
3
+ */
4
+ export { WccShell } from './elements/wcc-shell.js';
@@ -0,0 +1,12 @@
1
+ import * as typedrequest from '@api.global/typedrequest';
2
+ import * as deesDomtools from '@design.estate/dees-domtools';
3
+
4
+ import * as interfaces from '../ts_interfaces/index.js';
5
+ import * as shared from '../ts_shared/index.js';
6
+
7
+ export {
8
+ typedrequest,
9
+ deesDomtools,
10
+ interfaces,
11
+ shared,
12
+ };
@@ -0,0 +1,103 @@
1
+ import * as plugins from './plugins.js';
2
+
3
+ export interface IWccPreviewConnectionHandlers {
4
+ /** A preview document installed its bridge; called again after every reload of the frame. */
5
+ onReady: (bridgeArg: plugins.interfaces.IWccPreviewBridge) => void;
6
+ /** The connected preview document is going away (reload or navigation). */
7
+ onDisconnect: () => void;
8
+ onEscape: () => void;
9
+ onScroll: (scrollYArg: number) => void;
10
+ }
11
+
12
+ /**
13
+ * The shell's connection to the preview document in its preview iframe. Each preview document posts
14
+ * a ready message once its bridge is installed; the connection then binds that document's
15
+ * bridge and listeners, and releases them when the document is hidden, so nothing of a
16
+ * replaced document stays referenced.
17
+ */
18
+ export class WccPreviewConnection {
19
+ private bridgeValue: plugins.interfaces.IWccPreviewBridge | null = null;
20
+ private documentDisposers: Array<() => void> = [];
21
+ private started = false;
22
+
23
+ constructor(
24
+ private readonly getIframe: () => HTMLIFrameElement | null,
25
+ private readonly handlers: IWccPreviewConnectionHandlers,
26
+ ) {}
27
+
28
+ public get bridge(): plugins.interfaces.IWccPreviewBridge | null {
29
+ return this.bridgeValue;
30
+ }
31
+
32
+ public start() {
33
+ if (this.started) {
34
+ return;
35
+ }
36
+ this.started = true;
37
+ window.addEventListener('message', this.handleMessage);
38
+ }
39
+
40
+ public dispose() {
41
+ window.removeEventListener('message', this.handleMessage);
42
+ this.started = false;
43
+ this.releaseDocument();
44
+ }
45
+
46
+ public scrollPreviewTo(scrollYArg: number) {
47
+ if (this.bridgeValue) {
48
+ this.getIframe()?.contentWindow?.scrollTo(0, scrollYArg);
49
+ }
50
+ }
51
+
52
+ private handleMessage = (eventArg: MessageEvent) => {
53
+ const previewWindow = this.getIframe()?.contentWindow;
54
+ if (
55
+ !previewWindow
56
+ || eventArg.source !== previewWindow
57
+ || eventArg.origin !== window.location.origin
58
+ || !plugins.shared.isPreviewReadyMessage(eventArg.data)
59
+ ) {
60
+ return;
61
+ }
62
+ // A document that reloaded again since it posted installs its own bridge and posts again
63
+ const bridge = previewWindow.wccPreview;
64
+ if (!bridge) {
65
+ return;
66
+ }
67
+ this.releaseDocument();
68
+ this.bindDocument(previewWindow, bridge);
69
+ this.handlers.onReady(bridge);
70
+ };
71
+
72
+ private bindDocument(previewWindowArg: Window, bridgeArg: plugins.interfaces.IWccPreviewBridge) {
73
+ this.bridgeValue = bridgeArg;
74
+ const onKeydown = (eventArg: KeyboardEvent) => {
75
+ if (eventArg.key === 'Escape') {
76
+ this.handlers.onEscape();
77
+ }
78
+ };
79
+ const onScroll = () => {
80
+ this.handlers.onScroll(previewWindowArg.scrollY);
81
+ };
82
+ const onPagehide = () => {
83
+ this.releaseDocument();
84
+ this.handlers.onDisconnect();
85
+ };
86
+ previewWindowArg.addEventListener('keydown', onKeydown);
87
+ previewWindowArg.addEventListener('scroll', onScroll, { passive: true });
88
+ previewWindowArg.addEventListener('pagehide', onPagehide);
89
+ this.documentDisposers = [
90
+ () => previewWindowArg.removeEventListener('keydown', onKeydown),
91
+ () => previewWindowArg.removeEventListener('scroll', onScroll),
92
+ () => previewWindowArg.removeEventListener('pagehide', onPagehide),
93
+ ];
94
+ }
95
+
96
+ private releaseDocument() {
97
+ for (const dispose of this.documentDisposers) {
98
+ dispose();
99
+ }
100
+ this.documentDisposers = [];
101
+ this.bridgeValue = null;
102
+ }
103
+ }
@@ -0,0 +1,451 @@
1
+ /**
2
+ * RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic.
3
+ * Recording always uses MediaRecorder → WebM (the reliable browser path).
4
+ * MP4 output is produced by converting WebM → MP4 via mediabunny at export time.
5
+ */
6
+
7
+ export type TOutputFormat = 'webm' | 'mp4';
8
+
9
+ export interface IRecorderEvents {
10
+ onDurationUpdate?: (duration: number) => void;
11
+ onRecordingComplete?: (blob: Blob) => void;
12
+ onAudioLevelUpdate?: (level: number) => void;
13
+ onError?: (error: Error) => void;
14
+ onStreamEnded?: () => void;
15
+ }
16
+
17
+ export interface IRecordingOptions {
18
+ mode: 'viewport' | 'screen';
19
+ audioDeviceId?: string;
20
+ viewportElement?: HTMLElement;
21
+ outputFormat?: TOutputFormat;
22
+ }
23
+
24
+ export class RecorderService {
25
+ // Recording state
26
+ private mediaRecorder: MediaRecorder | null = null;
27
+ private recordedChunks: Blob[] = [];
28
+ private durationInterval: number | null = null;
29
+ private _duration: number = 0;
30
+ private _recordedBlob: Blob | null = null;
31
+ private _isRecording: boolean = false;
32
+ private _outputFormat: TOutputFormat = 'webm';
33
+
34
+ // Audio monitoring state
35
+ private audioContext: AudioContext | null = null;
36
+ private audioAnalyser: AnalyserNode | null = null;
37
+ private audioMonitoringInterval: number | null = null;
38
+ private monitoringStream: MediaStream | null = null;
39
+
40
+ // Current recording stream
41
+ private currentStream: MediaStream | null = null;
42
+
43
+ // Event callbacks
44
+ private events: IRecorderEvents = {};
45
+
46
+ constructor(events?: IRecorderEvents) {
47
+ if (events) {
48
+ this.events = events;
49
+ }
50
+ }
51
+
52
+ // Public getters
53
+ get isRecording(): boolean {
54
+ return this._isRecording;
55
+ }
56
+
57
+ get duration(): number {
58
+ return this._duration;
59
+ }
60
+
61
+ get recordedBlob(): Blob | null {
62
+ return this._recordedBlob;
63
+ }
64
+
65
+ get outputFormat(): TOutputFormat {
66
+ return this._outputFormat;
67
+ }
68
+
69
+ // Update event callbacks
70
+ setEvents(events: IRecorderEvents): void {
71
+ this.events = { ...this.events, ...events };
72
+ }
73
+
74
+ // ==================== Microphone Management ====================
75
+
76
+ async loadMicrophones(requestPermission: boolean = false): Promise<MediaDeviceInfo[]> {
77
+ try {
78
+ if (requestPermission) {
79
+ // Request permission by getting a temporary stream
80
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
81
+ stream.getTracks().forEach(track => track.stop());
82
+ }
83
+
84
+ const devices = await navigator.mediaDevices.enumerateDevices();
85
+ return devices.filter(d => d.kind === 'audioinput');
86
+ } catch (error) {
87
+ console.error('Error loading microphones:', error);
88
+ return [];
89
+ }
90
+ }
91
+
92
+ async startAudioMonitoring(deviceId: string): Promise<void> {
93
+ this.stopAudioMonitoring();
94
+
95
+ if (!deviceId) return;
96
+
97
+ try {
98
+ const stream = await navigator.mediaDevices.getUserMedia({
99
+ audio: { deviceId: { exact: deviceId } }
100
+ });
101
+
102
+ this.monitoringStream = stream;
103
+ this.audioContext = new AudioContext();
104
+ const source = this.audioContext.createMediaStreamSource(stream);
105
+ this.audioAnalyser = this.audioContext.createAnalyser();
106
+ this.audioAnalyser.fftSize = 256;
107
+ source.connect(this.audioAnalyser);
108
+
109
+ const dataArray = new Uint8Array(this.audioAnalyser.frequencyBinCount);
110
+
111
+ this.audioMonitoringInterval = window.setInterval(() => {
112
+ if (this.audioAnalyser) {
113
+ this.audioAnalyser.getByteFrequencyData(dataArray);
114
+ const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
115
+ const level = Math.min(100, (average / 128) * 100);
116
+ this.events.onAudioLevelUpdate?.(level);
117
+ }
118
+ }, 50);
119
+ } catch (error) {
120
+ console.error('Error starting audio monitoring:', error);
121
+ this.events.onAudioLevelUpdate?.(0);
122
+ }
123
+ }
124
+
125
+ stopAudioMonitoring(): void {
126
+ if (this.audioMonitoringInterval) {
127
+ clearInterval(this.audioMonitoringInterval);
128
+ this.audioMonitoringInterval = null;
129
+ }
130
+ if (this.audioContext) {
131
+ this.audioContext.close();
132
+ this.audioContext = null;
133
+ }
134
+ if (this.monitoringStream) {
135
+ this.monitoringStream.getTracks().forEach(track => track.stop());
136
+ this.monitoringStream = null;
137
+ }
138
+ this.audioAnalyser = null;
139
+ }
140
+
141
+ // ==================== Recording Control ====================
142
+
143
+ async startRecording(options: IRecordingOptions): Promise<void> {
144
+ try {
145
+ this._outputFormat = options.outputFormat || 'webm';
146
+
147
+ // Stop audio monitoring before recording
148
+ this.stopAudioMonitoring();
149
+
150
+ // Get video stream based on mode
151
+ const displayMediaOptions: DisplayMediaStreamOptions = {
152
+ video: {
153
+ displaySurface: options.mode === 'viewport' ? 'browser' : 'monitor',
154
+ frameRate: { ideal: 60 },
155
+ } as MediaTrackConstraints,
156
+ audio: false
157
+ };
158
+
159
+ // Add preferCurrentTab hint for viewport mode
160
+ if (options.mode === 'viewport') {
161
+ (displayMediaOptions as any).preferCurrentTab = true;
162
+ }
163
+
164
+ const videoStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
165
+
166
+ // If viewport mode, try to crop to viewport element using Element Capture API
167
+ if (options.mode === 'viewport' && options.viewportElement) {
168
+ try {
169
+ if ('CropTarget' in window) {
170
+ const cropTarget = await (window as any).CropTarget.fromElement(options.viewportElement);
171
+ const [videoTrack] = videoStream.getVideoTracks();
172
+ await (videoTrack as any).cropTo(cropTarget);
173
+ }
174
+ } catch (e) {
175
+ console.warn('Element Capture not supported, recording full tab:', e);
176
+ }
177
+ }
178
+
179
+ // Combine video with audio if enabled
180
+ let combinedStream = videoStream;
181
+ if (options.audioDeviceId) {
182
+ try {
183
+ const audioStream = await navigator.mediaDevices.getUserMedia({
184
+ audio: { deviceId: { exact: options.audioDeviceId } }
185
+ });
186
+ combinedStream = new MediaStream([
187
+ ...videoStream.getVideoTracks(),
188
+ ...audioStream.getAudioTracks()
189
+ ]);
190
+ } catch (audioError) {
191
+ console.warn('Could not add audio:', audioError);
192
+ }
193
+ }
194
+
195
+ // Store stream for cleanup
196
+ this.currentStream = combinedStream;
197
+
198
+ // Handle stream ending (user clicks "Stop sharing")
199
+ videoStream.getVideoTracks()[0].onended = () => {
200
+ if (this._isRecording) {
201
+ this.stopRecording();
202
+ this.events.onStreamEnded?.();
203
+ }
204
+ };
205
+
206
+ // Always record as WebM — conversion to MP4 happens at export time
207
+ const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
208
+ ? 'video/webm;codecs=vp9'
209
+ : 'video/webm';
210
+
211
+ this.mediaRecorder = new MediaRecorder(combinedStream, {
212
+ mimeType,
213
+ videoBitsPerSecond: 8_000_000, // 8 Mbps for smooth, high-quality capture
214
+ });
215
+ this.recordedChunks = [];
216
+
217
+ this.mediaRecorder.ondataavailable = (e) => {
218
+ if (e.data.size > 0) {
219
+ this.recordedChunks.push(e.data);
220
+ }
221
+ };
222
+
223
+ this.mediaRecorder.onstop = () => this.handleRecordingComplete();
224
+
225
+ this.mediaRecorder.start(1000); // Capture in 1-second chunks
226
+
227
+ // Start duration timer
228
+ this._duration = 0;
229
+ this.durationInterval = window.setInterval(() => {
230
+ this._duration++;
231
+ this.events.onDurationUpdate?.(this._duration);
232
+ }, 1000);
233
+
234
+ this._isRecording = true;
235
+ } catch (error) {
236
+ console.error('Error starting recording:', error);
237
+ this._isRecording = false;
238
+ this.events.onError?.(error as Error);
239
+ throw error;
240
+ }
241
+ }
242
+
243
+ stopRecording(): void {
244
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
245
+ this.mediaRecorder.stop();
246
+ }
247
+
248
+ if (this.durationInterval) {
249
+ clearInterval(this.durationInterval);
250
+ this.durationInterval = null;
251
+ }
252
+ }
253
+
254
+ private async handleRecordingComplete(): Promise<void> {
255
+ const blob = new Blob(this.recordedChunks, { type: 'video/webm' });
256
+ this._recordedBlob = blob;
257
+
258
+ // Stop all tracks
259
+ if (this.currentStream) {
260
+ this.currentStream.getTracks().forEach(track => track.stop());
261
+ this.currentStream = null;
262
+ }
263
+
264
+ this._isRecording = false;
265
+ this.events.onRecordingComplete?.(this._recordedBlob);
266
+ }
267
+
268
+ // ==================== Conversion & Export ====================
269
+
270
+ /**
271
+ * Converts a WebM blob to MP4 using mediabunny's Conversion API.
272
+ * Uses WebCodecs for hardware-accelerated H.264 encoding.
273
+ */
274
+ async convertToMp4(webmBlob: Blob): Promise<Blob> {
275
+ const {
276
+ Input, Output, Conversion, BlobSource, BufferTarget, Mp4OutputFormat, WEBM, QUALITY_HIGH,
277
+ } = await import('mediabunny');
278
+
279
+ const input = new Input({
280
+ source: new BlobSource(webmBlob),
281
+ formats: [WEBM],
282
+ });
283
+
284
+ const target = new BufferTarget();
285
+ const output = new Output({
286
+ format: new Mp4OutputFormat({ fastStart: 'in-memory' }),
287
+ target,
288
+ });
289
+
290
+ const conversion = await Conversion.init({
291
+ input,
292
+ output,
293
+ // Force transcoding from VP9 → H.264 and Opus → AAC
294
+ video: {
295
+ codec: 'avc',
296
+ bitrate: QUALITY_HIGH,
297
+ fit: 'contain',
298
+ },
299
+ audio: {
300
+ codec: 'aac',
301
+ bitrate: QUALITY_HIGH,
302
+ },
303
+ });
304
+ await conversion.execute();
305
+
306
+ const buffer = target.buffer;
307
+ if (!buffer || buffer.byteLength === 0) {
308
+ throw new Error('MP4 conversion produced empty output');
309
+ }
310
+
311
+ return new Blob([buffer], { type: 'video/mp4' });
312
+ }
313
+
314
+ async exportTrimmedVideo(
315
+ videoElement: HTMLVideoElement,
316
+ trimStart: number,
317
+ trimEnd: number
318
+ ): Promise<Blob> {
319
+ return new Promise((resolve, reject) => {
320
+ // Create a canvas for capturing frames
321
+ const canvas = document.createElement('canvas');
322
+ canvas.width = videoElement.videoWidth || 1280;
323
+ canvas.height = videoElement.videoHeight || 720;
324
+ const ctx = canvas.getContext('2d');
325
+
326
+ if (!ctx) {
327
+ reject(new Error('Could not get canvas context'));
328
+ return;
329
+ }
330
+
331
+ // Create canvas stream for video
332
+ const canvasStream = canvas.captureStream(30);
333
+
334
+ // Try to capture audio from video element
335
+ let combinedStream: MediaStream;
336
+
337
+ try {
338
+ // Create audio context to capture video's audio
339
+ const audioCtx = new AudioContext();
340
+ const source = audioCtx.createMediaElementSource(videoElement);
341
+ const destination = audioCtx.createMediaStreamDestination();
342
+ source.connect(destination);
343
+ source.connect(audioCtx.destination); // Also play through speakers
344
+
345
+ // Combine video (from canvas) and audio (from video element)
346
+ combinedStream = new MediaStream([
347
+ ...canvasStream.getVideoTracks(),
348
+ ...destination.stream.getAudioTracks()
349
+ ]);
350
+
351
+ // Store audioCtx for cleanup
352
+ const cleanup = () => {
353
+ audioCtx.close();
354
+ };
355
+
356
+ this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, cleanup, resolve, reject);
357
+ } catch (audioError) {
358
+ console.warn('Could not capture audio, recording video only:', audioError);
359
+ combinedStream = canvasStream;
360
+ this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, () => {}, resolve, reject);
361
+ }
362
+ });
363
+ }
364
+
365
+ private recordTrimmedStream(
366
+ video: HTMLVideoElement,
367
+ canvas: HTMLCanvasElement,
368
+ ctx: CanvasRenderingContext2D,
369
+ stream: MediaStream,
370
+ trimStart: number,
371
+ trimEnd: number,
372
+ cleanup: () => void,
373
+ resolve: (blob: Blob) => void,
374
+ reject: (error: Error) => void
375
+ ): void {
376
+ const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
377
+ ? 'video/webm;codecs=vp9'
378
+ : 'video/webm';
379
+
380
+ const recorder = new MediaRecorder(stream, { mimeType });
381
+ const chunks: Blob[] = [];
382
+
383
+ recorder.ondataavailable = (e) => {
384
+ if (e.data.size > 0) {
385
+ chunks.push(e.data);
386
+ }
387
+ };
388
+
389
+ recorder.onstop = () => {
390
+ cleanup();
391
+ resolve(new Blob(chunks, { type: 'video/webm' }));
392
+ };
393
+
394
+ recorder.onerror = (e) => {
395
+ cleanup();
396
+ reject(new Error('Recording error: ' + e));
397
+ };
398
+
399
+ // Seek to trim start
400
+ video.currentTime = trimStart;
401
+
402
+ video.onseeked = () => {
403
+ // Start recording
404
+ recorder.start(100);
405
+
406
+ // Start playing
407
+ video.play();
408
+
409
+ // Draw frames to canvas
410
+ const drawFrame = () => {
411
+ if (video.currentTime >= trimEnd || video.paused || video.ended) {
412
+ video.pause();
413
+ video.onseeked = null;
414
+
415
+ // Give a small delay before stopping to ensure last frame is captured
416
+ setTimeout(() => {
417
+ if (recorder.state === 'recording') {
418
+ recorder.stop();
419
+ }
420
+ }, 100);
421
+ return;
422
+ }
423
+
424
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
425
+ requestAnimationFrame(drawFrame);
426
+ };
427
+
428
+ drawFrame();
429
+ };
430
+ }
431
+
432
+ // ==================== Cleanup ====================
433
+
434
+ reset(): void {
435
+ this._recordedBlob = null;
436
+ this.recordedChunks = [];
437
+ this._duration = 0;
438
+ this._isRecording = false;
439
+ }
440
+
441
+ dispose(): void {
442
+ this.stopRecording();
443
+ this.stopAudioMonitoring();
444
+ this.reset();
445
+
446
+ if (this.currentStream) {
447
+ this.currentStream.getTracks().forEach(track => track.stop());
448
+ this.currentStream = null;
449
+ }
450
+ }
451
+ }
@@ -0,0 +1,12 @@
1
+ // Minimal type stubs for MediaCapture Transform API types not yet in lib.dom.d.ts.
2
+ // These specialize MediaStreamTrack for audio/video so mediabunny's API is type-safe.
3
+
4
+ interface MediaStreamAudioTrack extends MediaStreamTrack {
5
+ readonly kind: 'audio';
6
+ clone(): MediaStreamAudioTrack;
7
+ }
8
+
9
+ interface MediaStreamVideoTrack extends MediaStreamTrack {
10
+ readonly kind: 'video';
11
+ clone(): MediaStreamVideoTrack;
12
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "@types/dom-mediacapture-transform",
3
+ "version": "0.0.0",
4
+ "description": "Minimal stub providing MediaStreamVideoTrack/MediaStreamAudioTrack for TS 6",
5
+ "types": "index.d.ts"
6
+ }
@@ -0,0 +1,2 @@
1
+ // Empty stub: TypeScript 6 includes WebCodecs types in lib.dom.d.ts natively.
2
+ // This prevents @types/dom-webcodecs from conflicting with the built-in types.
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "@types/dom-webcodecs",
3
+ "version": "0.0.0",
4
+ "description": "Empty stub — TypeScript 6 provides WebCodecs types natively in lib.dom.d.ts",
5
+ "types": "index.d.ts"
6
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@design.estate/wcctools',
6
- version: '6.1.0',
6
+ version: '6.2.0',
7
7
  description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
8
8
  }