tools/lotus-study/

look.js

// Color and film-grain treatment of the original video. No generated imagery.
export function createFilmLook(video,canvas){
  const gl=canvas.getContext('webgl',{alpha:false,antialias:false,depth:false,powerPreference:'low-power'});
  if(!gl)return null;
  const vertex=`attribute vec2 position; varying vec2 uv;
  void main(){uv=position*.5+.5;gl_Position=vec4(position,0.,1.);}`;
  const fragment=`precision highp float;
  varying vec2 uv;uniform sampler2D footage;uniform vec2 resolution;
  uniform float pop;uniform float grain;uniform float frame;
  float hash(vec3 p){p=fract(p*.1031);p+=dot(p,p.yzx+33.33);return fract((p.x+p.y)*p.z);}
  float luma(vec3 c){return dot(c,vec3(.2126,.7152,.0722));}
  vec3 sampleFootage(vec2 p){
    if(p.x<0.||p.x>1.||p.y<0.||p.y>1.)return vec3(0.);
    return texture2D(footage,p).rgb;
  }
  void main(){
    // Align the final bud: 5.64 px right at 1080 (previous offset minus 3 px), 2% up.
    vec2 imageUV=uv-vec2(5.64/1080.,.02);
    vec3 source=sampleFootage(imageUV);
    float light=luma(source);
    // Open the midtones, deepen the dark petals, and keep pale veins readable.
    vec3 color=pow(source,vec3(.88));
    float lum=luma(color);
    color=mix(vec3(lum),color,1.+pop*.32);
    color=(color-.38)*(1.+pop*.12)+.38;
    float flower=smoothstep(.018,.17,light);
    float shade=(1.-smoothstep(.10,.55,light))*flower;
    color+=vec3(.025,-.012,.075)*shade*pop;
    // Warm rose highlights and a gentle cel-like separation of midtone values.
    float highlight=smoothstep(.32,.78,light);
    color=mix(color,color*vec3(1.06,.98,.94),highlight*pop);
    float stepped=floor(lum*9.)/9.+.055;
    color*=mix(1.,(stepped+.06)/(lum+.06),pop*.18*flower);
    // A restrained bright-petal glow; the black background stays dark.
    vec2 pixel=1./resolution;
    vec3 glow=vec3(0.);
    for(int i=0;i<4;i++){
      float a=float(i)*1.5707963;
      vec3 neighbor=sampleFootage(imageUV+vec2(cos(a),sin(a))*pixel*3.);
      glow+=max(neighbor-.58,0.);
    }
    color+=glow*pop*.075;
    color=mix(source,clamp(color,0.,1.),pop);
    // Fresh fine and coarse grain per film frame, integrated with exposure.
    vec2 cell=floor(gl_FragCoord.xy/1.35);
    float fine=hash(vec3(cell,frame))-hash(vec3(cell+71.3,frame+19.));
    float coarse=hash(vec3(floor(gl_FragCoord.xy/2.7),frame+39.))-0.5;
    float texture=fine*.82+coarse*.34;
    float exposure=.18+.82*pow(clamp(luma(color),0.,1.),.35);
    color+=texture*grain*.19*exposure;
    // Very small chroma variation adds pigment noise without colored static.
    color+=vec3(fine,-coarse,coarse)*grain*.009*flower;
    gl_FragColor=vec4(clamp(color,0.,1.),1.);
  }`;
  function shader(type,source){const s=gl.createShader(type);gl.shaderSource(s,source);gl.compileShader(s);if(!gl.getShaderParameter(s,gl.COMPILE_STATUS))throw new Error(gl.getShaderInfoLog(s));return s;}
  const program=gl.createProgram();
  gl.attachShader(program,shader(gl.VERTEX_SHADER,vertex));gl.attachShader(program,shader(gl.FRAGMENT_SHADER,fragment));gl.linkProgram(program);
  if(!gl.getProgramParameter(program,gl.LINK_STATUS))throw new Error(gl.getProgramInfoLog(program));
  gl.useProgram(program);
  const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);
  const position=gl.getAttribLocation(program,'position');gl.enableVertexAttribArray(position);gl.vertexAttribPointer(position,2,gl.FLOAT,false,0,0);
  const texture=gl.createTexture();gl.bindTexture(gl.TEXTURE_2D,texture);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL,true);
  const uniforms=Object.fromEntries(['resolution','pop','grain','frame'].map(n=>[n,gl.getUniformLocation(program,n)]));
  let pop=.85,grain=.6,dirty=true,last=-1,active=true,previousTick=0,exportSize=0,afterDraw=null;
  function draw(){
    if(video.readyState<2||!active)return;
    gl.texImage2D(gl.TEXTURE_2D,0,gl.RGB,gl.RGB,gl.UNSIGNED_BYTE,video);
    gl.uniform2f(uniforms.resolution,canvas.width,canvas.height);gl.uniform1f(uniforms.pop,pop);gl.uniform1f(uniforms.grain,grain);gl.uniform1f(uniforms.frame,Math.floor(video.currentTime*24));
    gl.drawArrays(gl.TRIANGLES,0,6);afterDraw?.(canvas);canvas.parentElement.classList.add('graded');last=video.currentTime;dirty=false;
  }
  function resize(){const size=exportSize||Math.round(canvas.clientWidth*Math.min(devicePixelRatio,1.75));if(size&&canvas.width!==size){canvas.width=canvas.height=size;gl.viewport(0,0,size,size);dirty=true;}draw();}
  const observer=new ResizeObserver(resize);observer.observe(canvas);
  function tick(now){if(active&&now-previousTick>31&&!document.hidden&&(dirty||video.currentTime!==last)){draw();previousTick=now;}requestAnimationFrame(tick);}
  canvas.addEventListener('webglcontextlost',e=>{e.preventDefault();active=false;canvas.parentElement.classList.remove('graded');});
  video.addEventListener('seeked',draw);video.addEventListener('loadeddata',draw);
  requestAnimationFrame(tick);resize();
  return {set(next){pop=next.pop/100;grain=next.grain/100;dirty=true;draw();},exportSize(size){exportSize=size;resize();},onDraw(callback){afterDraw=callback;},draw};
}