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.
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};
}