webgl-max-headroom
Version:
WebGL-powered Max Headroom style background and video overlay web components with AI-driven person segmentation, retro cyberpunk effects, and real-time shader animations
44 lines (40 loc) • 9.73 kB
JavaScript
(function(l,r){typeof exports=="object"&&typeof module<"u"?module.exports=r():typeof define=="function"&&define.amd?define(r):(l=typeof globalThis<"u"?globalThis:l||self,l.MaxHeadroomVideoOverlay=r())})(this,function(){"use strict";class l{constructor(t,i,e={}){this.video=t,this.canvas=i,this.ctx=i.getContext("2d",{willReadFrequently:!0}),this.model=null,this.isRunning=!1,this.config={glitchFrequency:3,...e},this.glitchState={isGlitching:!1,glitchType:"none",glitchStartTime:0,glitchDuration:0,nextGlitchTime:0,lastGlitchType:"none",repeatCount:0,maxRepeats:0},this.tempCanvas=null,this.tempCtx=null,this.frameBuffer=[],this.frameBufferSize=12,this.replayFrameIndex=0,this.onStatusChange=null,this.setupTempCanvas()}setupTempCanvas(){this.tempCanvas=document.createElement("canvas"),this.tempCtx=this.tempCanvas.getContext("2d"),this.updateCanvasSize()}updateCanvasSize(){this.tempCanvas&&(this.tempCanvas.width=this.canvas.width,this.tempCanvas.height=this.canvas.height,this.frameBuffer=[])}updateConfig(t){this.config={...this.config,...t}}async loadModel(){this.updateStatus("Loading BodyPix model...");try{const[t,i]=await Promise.all([import("@tensorflow/tfjs"),import("@tensorflow-models/body-pix")]);await t.ready(),this.model=await i.load({architecture:"MobileNetV1",outputStride:16,multiplier:.75,quantBytes:2}),this.updateStatus("Model loaded")}catch(t){throw this.updateStatus("Failed to load model"),console.error("Error loading TensorFlow.js or BodyPix:",t),t}}updateStatus(t){this.onStatusChange&&this.onStatusChange(t)}start(){this.isRunning||(this.isRunning=!0,this.renderFrame())}stop(){this.isRunning=!1}destroy(){this.stop(),this.model=null,this.frameBuffer=[]}async renderFrame(){if(!(!this.isRunning||!this.model)){try{const t=Date.now();this.updateGlitchState(t);const i=await this.model.segmentPerson(this.video,{flipHorizontal:!0,internalResolution:"medium",segmentationThreshold:.7});this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height);const e=this.calculateVideoRect();let s=null;if(this.glitchState.isGlitching&&this.glitchState.glitchType==="skip"&&(s=this.getReplayFrame()),s)this.tempCtx.clearRect(0,0,this.tempCanvas.width,this.tempCanvas.height),this.tempCtx.drawImage(s,0,0);else{this.ctx.drawImage(this.video,e.offsetX,e.offsetY,e.width,e.height);const a=this.ctx.getImageData(0,0,this.canvas.width,this.canvas.height),n=this.applySegmentationMask(a,i,e);this.tempCtx.clearRect(0,0,this.tempCanvas.width,this.tempCanvas.height),this.tempCtx.putImageData(n,0,0),(!this.glitchState.isGlitching||this.glitchState.glitchType!=="skip")&&this.captureCurrentFrame(n)}this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height);const h=this.applyGlitchTransform(e,t);this.ctx.drawImage(this.tempCanvas,0,0),this.restoreGlitchTransform(h)}catch(t){console.error("Frame rendering error:",t)}this.isRunning&&requestAnimationFrame(()=>this.renderFrame())}}calculateVideoRect(){const t=this.video.videoWidth/this.video.videoHeight,i=this.canvas.width/this.canvas.height;let e,s,h,a;return t>i?(s=this.canvas.height,e=s*t,h=(this.canvas.width-e)/2,a=0):(e=this.canvas.width,s=e/t,h=0,a=(this.canvas.height-s)/2),{offsetX:h,offsetY:a,width:e,height:s}}applySegmentationMask(t,i,e){const s=t.data,h=i.data,a=i.width,n=i.height,d=e.width/a,m=e.height/n;for(let o=0;o<t.height;o++)for(let c=0;c<t.width;c++){const u=(o*t.width+c)*4;if(c>=e.offsetX&&c<e.offsetX+e.width&&o>=e.offsetY&&o<e.offsetY+e.height){const v=c-e.offsetX,y=o-e.offsetY,g=Math.floor(v/d),f=Math.floor(y/m);if(g>=0&&g<a&&f>=0&&f<n){const x=f*a+g;h[x]===0&&(s[u+3]=0)}}else s[u+3]=0}return t}captureCurrentFrame(t){const i=document.createElement("canvas");i.width=this.canvas.width,i.height=this.canvas.height,i.getContext("2d").putImageData(t,0,0),this.frameBuffer.push(i),this.frameBuffer.length>this.frameBufferSize&&this.frameBuffer.shift()}getReplayFrame(){if(this.frameBuffer.length===0)return null;const t=this.frameBuffer[this.replayFrameIndex];return this.replayFrameIndex++,this.replayFrameIndex>=this.frameBuffer.length&&(this.replayFrameIndex=Math.max(0,this.frameBuffer.length-6)),t}updateGlitchState(t){if(!this.glitchState.isGlitching&&t>=this.glitchState.nextGlitchTime){if(this.config.glitchFrequency>0)if(this.glitchState.isGlitching=!0,this.glitchState.glitchStartTime=t,this.glitchState.glitchDuration=80+Math.random()*200,this.glitchState.lastGlitchType!=="none"&&this.glitchState.repeatCount<this.glitchState.maxRepeats&&Math.random()<.7)this.glitchState.glitchType=this.glitchState.lastGlitchType,this.glitchState.repeatCount++;else{const e=["flipH","flipV","flipBoth","mirror","offset","skip"];this.glitchState.glitchType=e[Math.floor(Math.random()*e.length)],this.glitchState.lastGlitchType=this.glitchState.glitchType,this.glitchState.repeatCount=1,this.glitchState.glitchType==="skip"?(this.glitchState.maxRepeats=2+Math.floor(Math.random()*5),this.replayFrameIndex=Math.max(0,this.frameBuffer.length-8)):this.glitchState.maxRepeats=1+Math.floor(Math.random()*4)}if(this.config.glitchFrequency>0)if(this.glitchState.repeatCount<this.glitchState.maxRepeats)this.glitchState.nextGlitchTime=t+150+Math.random()*200;else{const s=5e3-(this.config.glitchFrequency-1)/9*4500;this.glitchState.nextGlitchTime=t+s+Math.random()*s*.5,this.glitchState.lastGlitchType="none",this.glitchState.repeatCount=0,this.glitchState.maxRepeats=0}else this.glitchState.nextGlitchTime=t+1e4}this.glitchState.isGlitching&&t>=this.glitchState.glitchStartTime+this.glitchState.glitchDuration&&(this.glitchState.isGlitching=!1,this.glitchState.glitchType="none")}applyGlitchTransform(t,i){if(!this.glitchState.isGlitching)return!1;const e=t.offsetX+t.width/2,s=t.offsetY+t.height/2;switch(this.ctx.save(),this.glitchState.glitchType){case"flipH":this.ctx.translate(e,s),this.ctx.scale(-1,1),this.ctx.translate(-e,-s);break;case"flipV":this.ctx.translate(e,s),this.ctx.scale(1,-1),this.ctx.translate(-e,-s);break;case"flipBoth":this.ctx.translate(e,s),this.ctx.scale(-1,-1),this.ctx.translate(-e,-s);break;case"mirror":this.ctx.translate(e,s),this.ctx.scale(-1,1),this.ctx.translate(-e+Math.sin(i*.01)*20,-s);break;case"offset":const h=(Math.random()-.5)*40,a=(Math.random()-.5)*20;this.ctx.translate(h,a);break;case"skip":const n=(Math.random()-.5)*6,d=(Math.random()-.5)*6;this.ctx.translate(n,d);break}return!0}restoreGlitchTransform(t){t&&this.ctx.restore()}setGlitchFrequency(t){this.config.glitchFrequency=t}}class r extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"}),this.video=null,this.canvas=null,this.renderer=null,this.glitchFrequency=3}static get observedAttributes(){return["glitch-frequency","width","height"]}attributeChangedCallback(t,i,e){if(i!==e)switch(t){case"glitch-frequency":this.glitchFrequency=parseInt(e||"3"),this.renderer&&this.renderer.updateConfig({glitchFrequency:this.glitchFrequency});break;case"width":case"height":this.resizeCanvas();break}}connectedCallback(){this.render(),this.setupCanvas(),this.initialize()}disconnectedCallback(){this.stop()}render(){this.shadowRoot.innerHTML=`
<style>
:host {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
video {
display: none;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.status {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #00ffff;
padding: 10px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
z-index: 100;
pointer-events: auto;
}
</style>
<video id="video" autoplay muted playsinline></video>
<canvas id="canvas"></canvas>
<div class="status" id="status">Loading...</div>
`}setupCanvas(){this.video=this.shadowRoot.getElementById("video"),this.canvas=this.shadowRoot.getElementById("canvas"),this.status=this.shadowRoot.getElementById("status"),this.resizeCanvas(),window.addEventListener("resize",()=>this.resizeCanvas())}resizeCanvas(){if(!this.canvas)return;const t=parseInt(this.getAttribute("width"))||window.innerWidth,i=parseInt(this.getAttribute("height"))||window.innerHeight;this.canvas.width=t,this.canvas.height=i,this.renderer&&this.renderer.updateCanvasSize()}async initialize(){try{this.updateStatus("Setting up camera..."),await this.setupCamera(),this.renderer=new l(this.video,this.canvas,{glitchFrequency:this.glitchFrequency}),this.renderer.onStatusChange=t=>this.updateStatus(t),await this.renderer.loadModel(),this.updateStatus("Ready"),this.renderer.start()}catch(t){this.updateStatus(`Error: ${t.message}`),console.error("Video overlay initialization error:",t)}}async setupCamera(){const t=await navigator.mediaDevices.getUserMedia({video:{width:{ideal:1920},height:{ideal:1080},facingMode:"user"},audio:!1});return this.video.srcObject=t,new Promise(i=>{this.video.onloadedmetadata=()=>{this.video.play(),i()}})}updateStatus(t){this.status&&(this.status.textContent=t),this.dispatchEvent(new CustomEvent("status-change",{detail:{status:t}}))}start(){this.renderer&&this.renderer.start()}stop(){this.renderer&&this.renderer.stop(),this.video&&this.video.srcObject&&this.video.srcObject.getTracks().forEach(i=>i.stop())}setGlitchFrequency(t){this.glitchFrequency=t,this.renderer&&this.renderer.setGlitchFrequency(t)}getCurrentStatus(){return this.status?this.status.textContent:""}}return customElements.define("max-headroom-video-overlay",r),r});