Skip to content

Commit c0c986b

Browse files
authored
broadcast: mirrored prop (#592)
* broadcast: mirrored prop * address comments * update * remove unused import
1 parent 48d7b82 commit c0c986b

File tree

3 files changed

+141
-22
lines changed

3 files changed

+141
-22
lines changed

packages/core-web/src/broadcast.ts

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { isPictureInPictureSupported } from "./media/controls";
1313
import { getRTCPeerConnectionConstructor } from "./webrtc/shared";
1414
import {
1515
attachMediaStreamToPeerConnection,
16+
createMirroredVideoTrack,
1617
createNewWHIP,
1718
getDisplayMedia,
1819
getDisplayMediaExists,
@@ -146,6 +147,14 @@ export type InitialBroadcastProps = {
146147
* Set to true to disable ICE gathering. This is useful for testing purposes.
147148
*/
148149
noIceGathering?: boolean;
150+
151+
/**
152+
* Whether the video stream should be mirrored (horizontally flipped).
153+
*
154+
* Set to true to broadcast a mirrored view.
155+
* Defaults to `false`.
156+
*/
157+
mirrored?: boolean;
149158
};
150159

151160
export type BroadcastAriaText = {
@@ -326,6 +335,7 @@ export const createBroadcastStore = ({
326335
ingestUrl: ingestUrl ?? null,
327336
video: initialProps?.video ?? true,
328337
noIceGathering: initialProps?.noIceGathering ?? false,
338+
mirrored: initialProps?.mirrored ?? false,
329339
},
330340

331341
__device: device,
@@ -638,7 +648,11 @@ export const addBroadcastEventListeners = (
638648
}
639649

640650
// add effects
641-
const removeEffectsFromStore = addEffectsToStore(element, store, mediaStore);
651+
const { destroy: destroyEffects } = addEffectsToStore(
652+
element,
653+
store,
654+
mediaStore,
655+
);
642656

643657
const removeHydrationListener = store.persist.onFinishHydration(
644658
({ mediaDeviceIds, audio, video }) => {
@@ -656,7 +670,7 @@ export const addBroadcastEventListeners = (
656670

657671
mediaDevices?.removeEventListener?.("devicechange", onDeviceChange);
658672

659-
removeEffectsFromStore?.();
673+
destroyEffects?.();
660674

661675
element?.removeAttribute?.(MEDIA_BROADCAST_INITIALIZED_ATTRIBUTE);
662676
},
@@ -818,6 +832,7 @@ const addEffectsToStore = (
818832
requestedVideoDeviceId: state.__controls.requestedVideoInputDeviceId,
819833
initialAudioConfig: state.__initialProps.audio,
820834
initialVideoConfig: state.__initialProps.video,
835+
mirrored: state.__initialProps.mirrored,
821836
previousMediaStream: state.mediaStream,
822837
}),
823838
async ({
@@ -830,6 +845,7 @@ const addEffectsToStore = (
830845
previousMediaStream,
831846
initialAudioConfig,
832847
initialVideoConfig,
848+
mirrored,
833849
}) => {
834850
try {
835851
if (!mounted || !hydrated) {
@@ -871,7 +887,6 @@ const addEffectsToStore = (
871887
? {
872888
...(audioConstraints ? audioConstraints : {}),
873889
deviceId: {
874-
// we pass ideal here, so we don't get overconstrained errors
875890
ideal: requestedAudioDeviceId,
876891
},
877892
}
@@ -887,13 +902,14 @@ const addEffectsToStore = (
887902
? {
888903
...(videoConstraints ? videoConstraints : {}),
889904
deviceId: {
890-
// we pass ideal here, so we don't get overconstrained errors
891905
ideal: requestedVideoDeviceId,
892906
},
907+
...(mirrored ? { facingMode: "user" } : {}),
893908
}
894909
: video
895910
? {
896911
...(videoConstraints ? videoConstraints : {}),
912+
...(mirrored ? { facingMode: "user" } : {}),
897913
}
898914
: false,
899915
}));
@@ -934,11 +950,38 @@ const addEffectsToStore = (
934950
allAudioTracks?.[0] ??
935951
previousMediaStream?.getAudioTracks?.()?.[0] ??
936952
null;
937-
const mergedVideoTrack =
953+
954+
let mergedVideoTrack =
938955
allVideoTracks?.[0] ??
939956
previousMediaStream?.getVideoTracks?.()?.[0] ??
940957
null;
941958

959+
if (
960+
mergedVideoTrack &&
961+
mirrored &&
962+
requestedVideoDeviceId !== "screen"
963+
) {
964+
try {
965+
const videoSettings = mergedVideoTrack.getSettings();
966+
const isFrontFacing =
967+
videoSettings.facingMode === "user" ||
968+
!videoSettings.facingMode;
969+
970+
if (isFrontFacing) {
971+
element.classList.add("livepeer-mirrored-video");
972+
mergedVideoTrack = createMirroredVideoTrack(mergedVideoTrack);
973+
} else {
974+
element.classList.remove("livepeer-mirrored-video");
975+
}
976+
} catch (err) {
977+
warn(
978+
`Failed to apply video mirroring: ${(err as Error).message}`,
979+
);
980+
}
981+
} else {
982+
element.classList.remove("livepeer-mirrored-video");
983+
}
984+
942985
if (mergedAudioTrack) mergedMediaStream.addTrack(mergedAudioTrack);
943986
if (mergedVideoTrack) mergedMediaStream.addTrack(mergedVideoTrack);
944987

@@ -1123,20 +1166,22 @@ const addEffectsToStore = (
11231166
},
11241167
);
11251168

1126-
return () => {
1127-
destroyAudioVideoEnabled?.();
1128-
destroyErrorCount?.();
1129-
destroyMapDeviceListToFriendly?.();
1130-
destroyMediaStream?.();
1131-
destroyMediaSyncError?.();
1132-
destroyMediaSyncMounted?.();
1133-
destroyPeerConnectionAndMediaStream?.();
1134-
destroyPictureInPictureSupportedMonitor?.();
1135-
destroyRequestUserMedia?.();
1136-
destroyUpdateDeviceList?.();
1137-
destroyWhip?.();
1138-
1139-
cleanupWhip?.();
1140-
cleanupMediaStream?.();
1169+
return {
1170+
destroy: () => {
1171+
destroyAudioVideoEnabled?.();
1172+
destroyErrorCount?.();
1173+
destroyMapDeviceListToFriendly?.();
1174+
destroyMediaStream?.();
1175+
destroyMediaSyncError?.();
1176+
destroyMediaSyncMounted?.();
1177+
destroyPeerConnectionAndMediaStream?.();
1178+
destroyPictureInPictureSupportedMonitor?.();
1179+
destroyRequestUserMedia?.();
1180+
destroyUpdateDeviceList?.();
1181+
destroyWhip?.();
1182+
1183+
cleanupWhip?.();
1184+
cleanupMediaStream?.();
1185+
},
11411186
};
11421187
};

packages/core-web/src/webrtc/whip.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
negotiateConnectionWithClientOffer,
77
} from "./shared";
88

9+
const STANDARD_FPS = 30;
10+
911
export const VIDEO_WEBRTC_INITIALIZED_ATTRIBUTE =
1012
"data-livepeer-video-whip-initialized";
1113

@@ -265,3 +267,75 @@ export const getDisplayMedia = (options?: DisplayMediaStreamOptions) => {
265267

266268
return navigator.mediaDevices.getDisplayMedia(options);
267269
};
270+
271+
/**
272+
* Creates a mirrored version of a video track using a canvas element.
273+
* This function ensures the stream sent to the server is mirrored horizontally.
274+
*/
275+
export const createMirroredVideoTrack = (
276+
originalTrack: MediaStreamTrack,
277+
): MediaStreamTrack => {
278+
if (originalTrack.kind !== "video") {
279+
warn("Cannot mirror non-video track");
280+
return originalTrack;
281+
}
282+
283+
try {
284+
const settings = originalTrack.getSettings();
285+
const width = settings.width || 640;
286+
const height = settings.height || 480;
287+
288+
const canvas = document.createElement("canvas");
289+
canvas.width = width;
290+
canvas.height = height;
291+
292+
const ctx = canvas.getContext("2d");
293+
if (!ctx) {
294+
warn("Could not get canvas context for mirroring video");
295+
return originalTrack;
296+
}
297+
298+
const video = document.createElement("video");
299+
video.srcObject = new MediaStream([originalTrack]);
300+
video.autoplay = true;
301+
video.muted = true;
302+
303+
video.onloadedmetadata = () => {
304+
video
305+
.play()
306+
.catch((e) =>
307+
warn(`Failed to play video in mirroring process: ${e.message}`),
308+
);
309+
};
310+
311+
const drawFrame = () => {
312+
if (video.readyState >= 2) {
313+
ctx.clearRect(0, 0, width, height);
314+
315+
ctx.save();
316+
ctx.scale(-1, 1);
317+
ctx.drawImage(video, -width, 0, width, height);
318+
ctx.restore();
319+
}
320+
321+
requestAnimationFrame(drawFrame);
322+
};
323+
324+
drawFrame();
325+
326+
const mirroredStream = canvas.captureStream(STANDARD_FPS);
327+
328+
const mirroredTrack = mirroredStream.getVideoTracks()[0];
329+
330+
originalTrack.addEventListener("ended", () => {
331+
mirroredTrack.stop();
332+
video.pause();
333+
video.srcObject = null;
334+
});
335+
336+
return mirroredTrack;
337+
} catch (err) {
338+
warn(`Error creating mirrored track: ${(err as Error).message}`);
339+
return originalTrack;
340+
}
341+
};

packages/core/src/version.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
const core = "@livepeer/core@3.2.5";
2-
const react = "@livepeer/react@4.2.5";
1+
const core = "@livepeer/core@3.2.8";
2+
const react = "@livepeer/react@4.2.10";
33

44
export const version = {
55
core,

0 commit comments

Comments
 (0)