UNPKG

@needle-tools/facefilter

Version:

Needle Engine FaceFilter

274 lines (246 loc) 10.9 kB
import { Context, disposeObjectResources, getIconElement, hasCommercialLicense, hasProLicense, isDevEnvironment, ObjectUtils, showBalloonError, showBalloonMessage, showBalloonWarning } from "@needle-tools/engine"; import { DoubleSide, MeshBasicMaterial, Object3D, PerspectiveCamera, Texture, TextureLoader, Vector3 } from "three"; declare type RecordingOptions = { context: Context; customLogo?: Texture | null; download_name?: string | null; } declare type FilterRecordingOptions = RecordingOptions & { } export class NeedleRecordingHelper { static debug = false; private static button: HTMLButtonElement | null = null; private static isRecording = false; private static readonly chunks: Blob[] = []; private static recorder: MediaRecorder | null = null; private static recordingFormat: string = ""; static createButton(options: FilterRecordingOptions): HTMLButtonElement { const ctx = options.context; // Watermark.add(ctx, options.customLogo || null); if (!this.button) { this.button = document.createElement("button"); this.button.innerText = "Record"; const startIcon = getIconElement("screen_record"); const stopIcon = getIconElement("stop_circle"); this.button.prepend(startIcon); let recordingStartTime = 0; let shouldRecord = false; this.button.addEventListener("click", () => { if (this.debug) showBalloonMessage("State: " + this.recorder?.state); // Stop recording if (this.chunks.length > 0 || this.isRecording || shouldRecord) { shouldRecord = false; this.button!.innerText = "Record"; this.button!.prepend(startIcon); this.stopRecording(options); }// Start recording else { shouldRecord = true; let isWaitingForStart = true; const waitDuration = 3000; const clickTime = Date.now(); stopIcon.style.color = ""; // This is called every few seconds to update the button text const update = () => { if (!shouldRecord) { return; } // Show a countdown before starting if (isWaitingForStart) { const duration = waitDuration - (Date.now() - clickTime); this.button!.innerText = "Start in " + Math.max(0, (duration / 1000)).toFixed(0) + "s"; this.button!.prepend(stopIcon); } // The recording has started, show how much time has passed else if (this.isRecording) { const duration = Date.now() - recordingStartTime; stopIcon.style.color = "#ff5555"; this.button!.innerText = "Recording Video " + (duration / 1000).toFixed(0) + "s"; this.button!.prepend(stopIcon); } setTimeout(update, 500); }; update(); // Wait for a short moment before actually starting the recording setTimeout(() => { isWaitingForStart = false; if (shouldRecord) { recordingStartTime = Date.now(); this.startRecording(ctx.renderer.domElement, options); } }, waitDuration); } }); } ctx.menu.appendChild(this.button); if (this.debug) { setTimeout(() => { this.startRecording(ctx.renderer.domElement, options); setTimeout(() => { this.stopRecording(options); }, 2000) }, 1000) } return this.button; } static startRecording(canvas: HTMLCanvasElement, opts: FilterRecordingOptions) { this.recordingFormat = "video/webm"; const availableFormats = [ "video/webm", "video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm;codecs=h264", "video/mp4", ] for (const format of availableFormats) { if (MediaRecorder.isTypeSupported(format)) { this.recordingFormat = format; break; } } const recorderOptions: MediaRecorderOptions = { mimeType: this.recordingFormat, } recorderOptions.videoBitsPerSecond = 2500000 * 4; // 4x higher than the default 2.5mbps const stream = canvas.captureStream(30); this.recorder = new MediaRecorder(stream, recorderOptions); this.recorder.ondataavailable = (e) => { if (this.debug) showBalloonMessage("Recording data " + e.data.type + " " + e.data.size + ", " + this.chunks.length); if (e.data?.size > 0) this.chunks.push(e.data); }; this.recorder.onerror = (e: any) => { this.isRecording = false; console.error(e.error.name + ": " + e.error.message); showBalloonError(e.error.name + ": " + e.error.message); } this.isRecording = true; Watermark.add(opts.context, opts.customLogo || null); this.recorder.start(100); } static stopRecording(opts?: RecordingOptions) { this.isRecording = false; Watermark.remove(); this.recorder?.requestData(); if (this.debug) showBalloonMessage("Recording stopped " + this.chunks.length); this.recorder!.onstop = () => { this.download(opts); }; this.recorder!.stop(); } private static async download(opts?: RecordingOptions) { if (this.chunks.length === 0) { return false; } const format = this.recordingFormat || "video/webm"; const blob = new Blob(this.chunks, { type: format }); this.chunks.length = 0; const ext = format.split("/")[1]; let downloadName = "needle-engine-facefilter"; if (opts?.download_name?.length) { if (hasProLicense()) downloadName = opts.download_name; else { console.warn("Needle Engine Pro is required to set a custom download name"); } } downloadName += "." + ext; console.debug("Downloading recording as " + downloadName); const url = URL.createObjectURL(blob); // Share doesnt work with a blob url // if("share" in navigator) { // await navigator.share({ // title: "Needle Engine Facefilter", // text: "Facefilter recording", // url: url, // }).catch((e) => { // console.warn(e); // return false; // }); // } // else { const a = document.createElement("a"); a.href = url; a.download = downloadName; a.click(); } setTimeout(() => { URL.revokeObjectURL(url); }, 10); return true; } } class Watermark { private static active: boolean = false; private static object: Object3D | null = null; private static texture: Texture | null = null; static async add(context: Context, logo: Texture | null) { this.active = true; if (!this.object) { const allowCustomLogo = hasProLicense(); if (!logo || !allowCustomLogo) { const url = "https://cdn.needle.tools/static/branding/logo_needle_white_no_padding.png"; const textureLoader = new TextureLoader(); this.texture = await textureLoader.loadAsync(url); if (logo) { const msg = "\n\nTo use a custom logo in your face filter please upgrade to Needle Engine Pro for custom branding: https://needle.tools/pricing\n\n"; console.warn(msg); } } else { this.texture = logo; this.texture.repeat.set(1, -1); // flip y } this.texture.colorSpace = context.renderer.outputColorSpace; const quad = ObjectUtils.createPrimitive("Quad", { texture: this.texture, material: new MeshBasicMaterial({ depthWrite: false, colorWrite: true, transparent: true, side: DoubleSide, }) }); quad.renderOrder = -1000; this.object = quad; } const object = this.object; const texture = this.texture; const cam = context.mainCamera; if (cam instanceof PerspectiveCamera && texture) { cam.add(object); let aspect = 1; if (texture.image?.width) { aspect = texture.image.width / texture.image.height; } object.scale.set(aspect, 1, 1); object.scale.multiplyScalar(cam.far * .05); const updatePosition = () => { if (!this.active) { window.removeEventListener("resize", updatePosition); window.removeEventListener("orientationchange", updatePosition); window.removeEventListener("fullscreenchange", updatePosition); } else { const corner = new Vector3(1, .9, 1).unproject(cam); cam.worldToLocal(corner); object.position.copy(corner); object.position.x -= object.scale.x * 0.5; // center object.position.x -= 4; // extra offset setTimeout(() => { updatePosition(); }, 2000); } } window.addEventListener("resize", updatePosition); window.addEventListener("orientationchange", updatePosition); window.addEventListener("fullscreenchange", updatePosition); updatePosition(); } } static remove() { this.active = false; this.object?.removeFromParent(); } }