multimodal / videoinput /srcts /videoClipper.ts
jcheng5's picture
Initial checkin
21e6506
class VideoClipperElement extends HTMLElement {
video: HTMLVideoElement;
avSettingsMenu!: AVSettingsMenuElement;
buttonRecord!: HTMLButtonElement;
buttonStop!: HTMLButtonElement;
cameraStream?: MediaStream;
micStream?: MediaStream;
recorder?: MediaRecorder;
chunks: Blob[] = [];
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot!.innerHTML = `
<style>
:host {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
width: 100%;
height: min-content;
}
video {
grid-column: 1 / 2;
grid-row: 1 / 2;
width: 100%;
object-fit: cover;
background-color: var(--video-clip-bg, black);
aspect-ratio: 16 / 9;
border-radius: var(--video-clip-border-radius, var(--bs-border-radius-lg));
}
video.mirrored {
transform: scaleX(-1);
}
.panel-settings {
grid-column: 1 / 2;
grid-row: 1 / 2;
justify-self: end;
margin: 0.5em;
}
.panel-buttons {
grid-column: 1 / 2;
grid-row: 1 / 2;
justify-self: end;
align-self: end;
margin: 0.5em;
}
</style>
<video part="video" muted></video>
<div class="panel-settings">
<slot name="settings"></slot>
</div>
<div class="panel-buttons">
<slot name="recording-controls"></slot>
</div>
`;
this.video = this.shadowRoot!.querySelector("video")!;
}
connectedCallback() {
(async () => {
const slotSettings = this.shadowRoot!.querySelector(
"slot[name=settings]"
)! as HTMLSlotElement;
slotSettings.addEventListener("slotchange", async () => {
this.avSettingsMenu =
slotSettings.assignedElements()[0] as AVSettingsMenuElement;
await this.#initializeMediaInput();
if (this.buttonRecord) {
this.#setEnabledButton(this.buttonRecord);
}
});
const slotControls = this.shadowRoot!.querySelector(
"slot[name=recording-controls]"
)! as HTMLSlotElement;
slotControls.addEventListener("slotchange", () => {
const findButton = (selector: string): HTMLElement | null => {
for (const el of slotControls.assignedElements()) {
if (el.matches(selector)) {
return el as HTMLElement;
}
const sub = el.querySelector(selector);
if (sub) {
return sub as HTMLElement;
}
}
return null;
};
this.buttonRecord = findButton(".record-button")! as HTMLButtonElement;
this.buttonStop = findButton(".stop-button")! as HTMLButtonElement;
this.#setEnabledButton();
this.buttonRecord.addEventListener("click", () => {
this.#setEnabledButton(this.buttonStop);
this._beginRecord();
});
this.buttonStop.addEventListener("click", () => {
this._endRecord();
this.#setEnabledButton(this.buttonRecord);
});
});
})().catch((err) => {
console.error(err);
});
}
disconnectedCallback() {}
#setEnabledButton(btn?: HTMLButtonElement) {
this.buttonRecord.style.display =
btn === this.buttonRecord ? "inline-block" : "none";
this.buttonStop.style.display =
btn === this.buttonStop ? "inline-block" : "none";
}
async setMediaDevices(
cameraId: string | null,
micId: string | null
): Promise<{ cameraId: string; micId: string }> {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach((track) => track.stop());
}
this.cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: cameraId || undefined,
facingMode: "user",
aspectRatio: 16 / 9,
},
audio: {
deviceId: micId || undefined,
},
});
// TODO: I can't figure out how to tell if this is actually a selfie cam.
// Ideally we wouldn't mirror unless we are sure.
const isSelfieCam = true; // this.cameraStream.getVideoTracks()[0].getSettings().facingMode === "user";
this.video.classList.toggle("mirrored", isSelfieCam);
/* Prevent the height from jumping around when switching cameras */
const aspectRatio = this.cameraStream
.getVideoTracks()[0]
.getSettings().aspectRatio;
if (aspectRatio) {
this.video.style.aspectRatio = `${aspectRatio}`;
} else {
this.video.style.aspectRatio = "";
}
this.video.srcObject = this.cameraStream!;
this.video.play();
return {
cameraId: this.cameraStream.getVideoTracks()[0].getSettings().deviceId!,
micId: this.cameraStream.getAudioTracks()[0].getSettings().deviceId!,
};
}
async #initializeMediaInput() {
// Retrieve the user's previous camera and mic settings, if they ever
// explicitly chose one
const savedCamera = window.localStorage.getItem("multimodal-camera");
const savedMic = window.localStorage.getItem("multimodal-mic");
// Initialize the camera and mic with the saved settings. It's important to
// request camera/mic access _before_ we attempt to enumerate devices,
// because if the user has not granted camera/mic access, enumerateDevices()
// will not prompt the user for permission and will instead return empty
// devices.
//
// The return values are the actual camera and mic IDs that were used, which
// may be different from the saved values if those devices are no longer
// available.
const { cameraId, micId } = await this.setMediaDevices(
savedCamera,
savedMic
);
// Populate the camera and mic dropdowns with the available devices
const devices = await navigator.mediaDevices.enumerateDevices();
this.avSettingsMenu.setCameras(
devices.filter((dev) => dev.kind === "videoinput")
);
this.avSettingsMenu.setMics(
devices.filter((dev) => dev.kind === "audioinput")
);
// Update the dropdown UI to reflect the actual devices that were used
this.avSettingsMenu.cameraId = cameraId;
this.avSettingsMenu.micId = micId;
// Listen for changes to the camera and mic dropdowns
const handleDeviceChange = async (
deviceType: string,
deviceId: string | null
) => {
if (!deviceId) return;
window.localStorage.setItem(`multimodal-${deviceType}`, deviceId);
await this.setMediaDevices(
this.avSettingsMenu.cameraId,
this.avSettingsMenu.micId
);
};
this.avSettingsMenu.addEventListener("camera-change", (e) => {
handleDeviceChange("camera", this.avSettingsMenu.cameraId);
});
this.avSettingsMenu.addEventListener("mic-change", (e) => {
handleDeviceChange("mic", this.avSettingsMenu.micId);
});
}
_beginRecord() {
// Create a MediaRecorder object
this.recorder = new MediaRecorder(this.cameraStream!, {});
this.recorder.addEventListener("error", (e) => {
console.error("MediaRecorder error:", (e as ErrorEvent).error);
});
this.recorder.addEventListener("dataavailable", (e) => {
// console.log("chunk: ", e.data.size, e.data.type);
this.chunks.push(e.data);
});
this.recorder.addEventListener("start", () => {
// console.log("Recording started");
});
this.recorder.start();
}
_endRecord(emit: boolean = true) {
this.recorder!.stop();
if (!emit) {
this.chunks = [];
} else {
// Use setTimeout to give it a moment to finish processing the last chunk
setTimeout(() => {
// console.log("chunks: ", this.chunks.length);
const blob = new Blob(this.chunks, { type: this.chunks[0].type });
// emit blobevent
const event = new BlobEvent("data", {
data: blob,
});
try {
this.dispatchEvent(event);
} finally {
this.chunks = [];
}
}, 0);
}
}
}
customElements.define("video-clipper", VideoClipperElement);