Back to Sketch Loop
SOURCE / PINNED RELEASE

Made of little things.

Sketch Loop

Release
14966bba4a2a
Author-recorded commit
e68361295d81…
License
LICENSE
Author’s source reference
nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-929bc2a88cb

Archive hash verified: 5e0385e3c08f944f. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/export.ts
import { GIFEncoder, quantize, applyPalette } from 'gifenc';
import { Sketch } from './sketch.js';

export type ExportOptions = { duration: number; fps: number; start: number; scale: number; loop: boolean };
export type ExportResult = { bytes: Uint8Array; width: number; height: number; frames: number; duration: number };
const yieldUI = () => new Promise<void>(resolve => setTimeout(resolve, 0));
export function frameDelays(duration: number, fps: number): number[] {
  const frames = Math.round(duration*fps);
  return Array.from({length:frames}, (_,i)=>(Math.round((i+1)*duration*100/frames)-Math.round(i*duration*100/frames))*10);
}
export async function renderGif(source: string, options: ExportOptions, signal: AbortSignal, progress: (fraction:number,phase:string)=>void): Promise<ExportResult> {
  const {duration,fps,start,scale,loop}=options;
  if (!Number.isFinite(duration)||duration<1||duration>30||!Number.isInteger(duration*10)) throw new Error('Choose a duration from 1 to 30 seconds in 0.1-second steps.');
  if (![10,15,20,25,30].includes(fps)||!Number.isFinite(start)||start<0||start>30||![0.5,1].includes(scale)) throw new Error('Choose valid export settings.');
  const sketch = new Sketch(source);
  let time=0, nextYield=performance.now();
  const check=()=>{if(signal.aborted)throw new DOMException('Export cancelled.','AbortError');};
  const advance=async(target:number)=>{
    while(time<=target+1e-8){
      check();sketch.step(); time+=1/sketch.fps;
      if(performance.now()-nextYield>12){progress(Math.min(0.15,time/Math.max(start,0.1)*0.15),'Preparing sketch');await yieldUI();nextYield=performance.now();}
    }
  };
  progress(0,'Preparing sketch'); await yieldUI(); await advance(start);
  const width=Math.max(1,Math.round(sketch.canvas.width*scale)),height=Math.max(1,Math.round(sketch.canvas.height*scale));
  if(width*height*duration*fps>160000000)throw new Error('This export is too large. Reduce duration, FPS, or choose half size.');
  const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;
  const ctx=canvas.getContext('2d',{willReadFrequently:true})!;
  ctx.imageSmoothingEnabled=false;
  const gif=GIFEncoder(),delays=frameDelays(duration,fps);
  for(let i=0;i<delays.length;i++){
    check(); await advance(start+i/fps);
    ctx.fillStyle='#000';ctx.fillRect(0,0,width,height);ctx.drawImage(sketch.canvas,0,0,width,height);
    const data=ctx.getImageData(0,0,width,height).data;
    const palette=quantize(data,256),indexed=applyPalette(data,palette);
    gif.writeFrame(indexed,width,height,{palette,delay:delays[i],repeat:loop?0:-1});
    if(gif.bytesView().length>24*1024*1024)throw new Error('GIF exceeds 24 MB. Try half size, fewer frames, or a shorter duration.');
    progress(0.15+(i+1)/delays.length*0.85,`Rendering frame ${i+1} of ${delays.length}`);
    await yieldUI(); nextYield=performance.now();
  }
  check();gif.finish();return {bytes:gif.bytes(),width,height,frames:delays.length,duration};
}