@cinecrew/cinecrew-player 0.1.2 → 0.1.4

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.
@@ -121,11 +121,13 @@ export function useWebAc3AudioPlayback({
121
121
 
122
122
  const context = new AudioContextConstructor({ latencyHint: 'interactive' });
123
123
  const gain = context.createGain();
124
+ const recordingDestination = context.createMediaStreamDestination?.();
124
125
  gain.gain.value = enabledRef.current
125
126
  ? Math.max(0, Math.min(1, Number(volumeRef.current) / 100))
126
127
  : 0;
127
128
  gain.connect(context.destination);
128
- audioRef.current = { context, gain };
129
+ if (recordingDestination) gain.connect(recordingDestination);
130
+ audioRef.current = { context, gain, recordingDestination };
129
131
  return audioRef.current;
130
132
  }, []);
131
133
 
@@ -212,8 +214,12 @@ export function useWebAc3AudioPlayback({
212
214
  useEffect(() => () => {
213
215
  const { context } = audioRef.current;
214
216
  if (context && context.state !== 'closed') context.close().catch(() => {});
215
- audioRef.current = { context: null, gain: null };
217
+ audioRef.current = { context: null, gain: null, recordingDestination: null };
216
218
  }, []);
217
219
 
218
- return { activateAudio, deactivateAudio };
220
+ return {
221
+ activateAudio,
222
+ deactivateAudio,
223
+ getRecordingAudioStream: () => audioRef.current.recordingDestination?.stream || null,
224
+ };
219
225
  }
@@ -17,8 +17,13 @@ export function useWebVideoAspectRatio(videoAspectRatio, audioOnly) {
17
17
  } else if (aspect === 'CENTER') {
18
18
  setAspectFit('none');
19
19
  setAspectRatioVal(undefined);
20
- } else if (typeof aspect === 'string' && aspect.includes(':')) {
21
- const parts = aspect.split(':');
20
+ } else if (typeof aspect === 'string' && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(aspect)) {
21
+ const parts = aspect.split(':').map(Number);
22
+ if (!parts[0] || !parts[1]) {
23
+ setAspectFit('contain');
24
+ setAspectRatioVal(undefined);
25
+ return;
26
+ }
22
27
  setAspectFit('contain');
23
28
  setAspectRatioVal(`${parts[0]}/${parts[1]}`);
24
29
  } else {
@@ -36,7 +41,17 @@ export function useWebVideoAspectRatio(videoAspectRatio, audioOnly) {
36
41
  width: useCustomRatio ? 'auto' : '100%',
37
42
  height: useCustomRatio ? 'auto' : '100%',
38
43
  ...(useCustomRatio
39
- ? { maxWidth: '100%', maxHeight: '100%', aspectRatio: aspectRatioVal }
44
+ ? {
45
+ position: 'absolute',
46
+ left: '50%',
47
+ top: '50%',
48
+ right: 'auto',
49
+ bottom: 'auto',
50
+ transform: 'translate(-50%, -50%)',
51
+ maxWidth: '100%',
52
+ maxHeight: '100%',
53
+ aspectRatio: aspectRatioVal,
54
+ }
40
55
  : {}),
41
56
  objectFit: useCustomRatio ? 'contain' : aspectFit,
42
57
  backgroundColor: '#000',
@@ -0,0 +1,24 @@
1
+ /** Run the player's built-in action before notifying the consuming app. */
2
+ export function invokePlayerAction(coreAction, callback, payload, context) {
3
+ const result = typeof coreAction === 'function' ? coreAction(payload) : undefined;
4
+ if (typeof callback !== 'function') return result;
5
+
6
+ const notify = () => {
7
+ try {
8
+ callback(payload, context);
9
+ } catch (error) {
10
+ // A consumer notification must never roll back or block the player action.
11
+ console.error('CineCrewPlayer action callback failed:', error);
12
+ }
13
+ };
14
+
15
+ if (result && typeof result.then === 'function') {
16
+ return result.then((value) => {
17
+ notify();
18
+ return value;
19
+ });
20
+ }
21
+
22
+ notify();
23
+ return result;
24
+ }
@@ -0,0 +1,46 @@
1
+ /** Format a player position as an unambiguous, zero-padded HH:MM:SS value. */
2
+ export function formatProgressBarTime(seconds) {
3
+ const totalSeconds = Number.isFinite(Number(seconds)) ? Math.max(0, Math.floor(Number(seconds))) : 0;
4
+ const hours = Math.floor(totalSeconds / 3600);
5
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
6
+ const remainder = totalSeconds % 60;
7
+ return [hours, minutes, remainder].map((value) => String(value).padStart(2, '0')).join(':');
8
+ }
9
+
10
+ /**
11
+ * Emit one progress-bar callback per whole playback second, while allowing
12
+ * completed seeks/restarts to report immediately even within the same second.
13
+ */
14
+ export function emitProgressBarTime(seconds, callback, lastSecondRef, { force = false } = {}) {
15
+ if (typeof callback !== 'function') return false;
16
+ const normalizedSeconds = Number.isFinite(Number(seconds)) ? Math.max(0, Number(seconds)) : 0;
17
+ const wholeSecond = Math.floor(normalizedSeconds);
18
+ const report = (value) => {
19
+ try {
20
+ callback(formatProgressBarTime(value));
21
+ } catch (error) {
22
+ // Progress observers must not interrupt media playback or native updates.
23
+ console.error('CineCrewPlayer progress-bar callback failed:', error);
24
+ }
25
+ };
26
+
27
+ if (force) {
28
+ lastSecondRef.current = wholeSecond;
29
+ report(normalizedSeconds);
30
+ return true;
31
+ }
32
+
33
+ if (lastSecondRef.current === null || lastSecondRef.current === undefined) {
34
+ lastSecondRef.current = wholeSecond;
35
+ if (wholeSecond === 0) return false;
36
+ report(normalizedSeconds);
37
+ return true;
38
+ }
39
+ if (wholeSecond <= lastSecondRef.current) return false;
40
+
41
+ for (let elapsedSecond = lastSecondRef.current + 1; elapsedSecond <= wholeSecond; elapsedSecond += 1) {
42
+ report(elapsedSecond);
43
+ }
44
+ lastSecondRef.current = wholeSecond;
45
+ return true;
46
+ }
@@ -0,0 +1,147 @@
1
+ function parseRatio(value) {
2
+ const [width, height] = String(value || '').split(':').map(Number);
3
+ return width > 0 && height > 0 ? width / height : null;
4
+ }
5
+
6
+ function fitRect(width, height, ratio) {
7
+ if (!ratio || !width || !height) return { x: 0, y: 0, width, height };
8
+ const boundsRatio = width / height;
9
+ const frameWidth = boundsRatio > ratio ? height * ratio : width;
10
+ const frameHeight = frameWidth / ratio;
11
+ return { x: (width - frameWidth) / 2, y: (height - frameHeight) / 2, width: frameWidth, height: frameHeight };
12
+ }
13
+
14
+ function drawVideoFrame(context, canvas, video, aspectRatio) {
15
+ const width = canvas.width;
16
+ const height = canvas.height;
17
+ context.fillStyle = '#000';
18
+ context.fillRect(0, 0, width, height);
19
+
20
+ const ratio = parseRatio(aspectRatio);
21
+ const frame = fitRect(width, height, ratio);
22
+ const sourceWidth = video.videoWidth;
23
+ const sourceHeight = video.videoHeight;
24
+ if (!sourceWidth || !sourceHeight) return;
25
+
26
+ const mode = String(aspectRatio || 'FIT').toUpperCase();
27
+ if (mode === 'STRETCH') {
28
+ context.drawImage(video, frame.x, frame.y, frame.width, frame.height);
29
+ return;
30
+ }
31
+
32
+ const sourceRatio = sourceWidth / sourceHeight;
33
+ const frameRatio = frame.width / frame.height;
34
+ if (mode === 'FILL' || mode === 'FILL_SCREEN') {
35
+ if (sourceRatio > frameRatio) {
36
+ const croppedWidth = sourceHeight * frameRatio;
37
+ context.drawImage(video, (sourceWidth - croppedWidth) / 2, 0, croppedWidth, sourceHeight, frame.x, frame.y, frame.width, frame.height);
38
+ } else {
39
+ const croppedHeight = sourceWidth / frameRatio;
40
+ context.drawImage(video, 0, (sourceHeight - croppedHeight) / 2, sourceWidth, croppedHeight, frame.x, frame.y, frame.width, frame.height);
41
+ }
42
+ return;
43
+ }
44
+
45
+ const scale = Math.min(frame.width / sourceWidth, frame.height / sourceHeight);
46
+ const drawWidth = sourceWidth * scale;
47
+ const drawHeight = sourceHeight * scale;
48
+ context.drawImage(video, frame.x + (frame.width - drawWidth) / 2, frame.y + (frame.height - drawHeight) / 2, drawWidth, drawHeight);
49
+ }
50
+
51
+ /** Capture the visible media frame (not player chrome) and preserve its audio tracks. */
52
+ export function createVideoRecordingStream(video, playerElement, getAspectRatio, additionalAudioStream) {
53
+ const capture = video?.captureStream || video?.webkitCaptureStream;
54
+ if (!video || typeof capture !== 'function' || typeof MediaStream === 'undefined') {
55
+ throw new Error('This browser does not support recording this media element.');
56
+ }
57
+
58
+ let sourceStream;
59
+ try {
60
+ sourceStream = capture.call(video);
61
+ } catch (error) {
62
+ if (error?.name === 'SecurityError' || /cross.?origin|security/i.test(error?.message || '')) {
63
+ throw new Error('The browser cannot record this cross-origin video because its source does not grant capture access. Use a local or CORS-enabled source to record it.');
64
+ }
65
+ throw error;
66
+ }
67
+ const extraAudioTracks = additionalAudioStream?.getAudioTracks?.() || [];
68
+ const audioTracks = extraAudioTracks.length ? extraAudioTracks : sourceStream.getAudioTracks();
69
+ const bounds = playerElement?.getBoundingClientRect?.();
70
+ const outputWidth = Math.max(2, video.videoWidth || Math.round(bounds?.width || 640));
71
+ const playerRatio = bounds?.width && bounds?.height ? bounds.width / bounds.height : 16 / 9;
72
+ const outputHeight = Math.max(2, Math.round(outputWidth / playerRatio));
73
+ const canvas = document.createElement('canvas');
74
+ canvas.width = outputWidth;
75
+ canvas.height = outputHeight;
76
+ const context = canvas.getContext('2d');
77
+ if (!context || typeof canvas.captureStream !== 'function') {
78
+ sourceStream.getTracks().forEach((track) => track.stop());
79
+ throw new Error('Canvas recording is not supported in this browser.');
80
+ }
81
+
82
+ let originClean = false;
83
+ try {
84
+ drawVideoFrame(context, canvas, video, getAspectRatio());
85
+ context.getImageData(0, 0, 1, 1);
86
+ originClean = true;
87
+ } catch {
88
+ // Cross-origin media without CORS can still be played and captured from the
89
+ // media element, but cannot be re-rendered through a canvas.
90
+ }
91
+
92
+ if (!originClean) {
93
+ return {
94
+ stream: sourceStream,
95
+ aspectRatioApplied: false,
96
+ cleanup: () => sourceStream.getTracks().forEach((track) => track.stop()),
97
+ };
98
+ }
99
+
100
+ const canvasStream = canvas.captureStream(30);
101
+ const stream = new MediaStream([...canvasStream.getVideoTracks(), ...audioTracks]);
102
+ let frameId;
103
+ const draw = () => {
104
+ drawVideoFrame(context, canvas, video, getAspectRatio());
105
+ frameId = requestAnimationFrame(draw);
106
+ };
107
+ frameId = requestAnimationFrame(draw);
108
+ return {
109
+ stream,
110
+ aspectRatioApplied: true,
111
+ cleanup: () => {
112
+ cancelAnimationFrame(frameId);
113
+ canvasStream.getTracks().forEach((track) => track.stop());
114
+ sourceStream.getTracks().forEach((track) => track.stop());
115
+ },
116
+ };
117
+ }
118
+
119
+ export function getRecordingMimeType() {
120
+ if (typeof MediaRecorder === 'undefined') return '';
121
+ const candidates = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
122
+ return candidates.find((type) => MediaRecorder.isTypeSupported?.(type)) || '';
123
+ }
124
+
125
+ export function createRecordingDownloadLink(blob, title = 'cinecrew-recording') {
126
+ if (!blob?.size) return null;
127
+ const safeTitle = String(title || 'cinecrew-recording').replace(/[^a-z0-9-_]+/gi, '-').replace(/^-|-$/g, '') || 'cinecrew-recording';
128
+ const url = URL.createObjectURL(blob);
129
+ return {
130
+ url,
131
+ filename: `${safeTitle}-${new Date().toISOString().replace(/[:.]/g, '-')}.webm`,
132
+ };
133
+ }
134
+
135
+ export function downloadRecording(blob, title = 'cinecrew-recording') {
136
+ const artifact = createRecordingDownloadLink(blob, title);
137
+ if (!artifact) return false;
138
+ const link = document.createElement('a');
139
+ link.href = artifact.url;
140
+ link.download = artifact.filename;
141
+ link.style.display = 'none';
142
+ document.body.appendChild(link);
143
+ link.click();
144
+ link.remove();
145
+ window.setTimeout(() => URL.revokeObjectURL(artifact.url), 60_000);
146
+ return true;
147
+ }