diff --git a/easel/docs/live-broadcast.md b/easel/docs/live-broadcast.md new file mode 100644 index 0000000000..f323478b38 --- /dev/null +++ b/easel/docs/live-broadcast.md @@ -0,0 +1,32 @@ +# Live draft broadcasts + +`/api/easel-live` keeps one current draft frame per broadcast in Redis for one hour. It creates no painting, published piece, or permanent media record. A watch link is a read capability: anyone holding it can view the current draft until stopped or expired. Writes require an authenticated account with an AC handle and remain bound to the stable account subject. + +The client chooses a cryptographically random 128-bit ID encoded as 32 lowercase hexadecimal characters. It persists a strictly increasing positive integer `sequence` before sending each update or stop. Version describes the artifact revision; sequence orders requests, including stops and restarts. + +- `POST` JSON: `{id, sequence, kind, version, mime, data, status:"live"}`. `data` is canonical base64, at most 8 MiB decoded. Unknown fields are rejected. +- `GET ?id=…` returns `{id, route, kind, version, mime, sequence, status, updatedAt, expiresAt}`. `route` is the canonical `https://aesthetic.computer/watch/?id=…` address. No frame bytes or owner identity are included. +- `GET ?id=…&frame=sequence` returns raw bytes. A superseded sequence returns 409, stopped returns 410, and an expired or unknown ID returns 404. +- `DELETE` JSON: `{id, sequence}`. Owner-only; removes frame bytes immediately and retains a stopped metadata tombstone for one hour. A newer owner sequence may restart that ID. + +Metadata and frames use `Cache-Control: no-store`. Viewers poll metadata every 500–1000 ms and fetch bytes only after a sequence change. Late joiners receive the current frame. There is no frame history, prompt, conversation, or workspace-file upload in this protocol. Piece source is an explicit supported draft output and must only execute within a sandboxed viewer. + +| Medium | MIME types | +|---|---| +| Picture | `image/png`, `image/jpeg`, `image/webp` | +| Sound | `audio/wav`, `audio/mpeg`, `audio/ogg` | +| Paper | `application/pdf`, `text/plain` before compilation | +| Game Boy | `application/x-gameboy-rom`, `text/plain` before compilation | +| Piece | `text/javascript`, `text/plain` | + +One Redis Lua transaction enforces ownership, increasing sequence, frame replacement, expiration, and owner limits. Each owner can have four active broadcasts, 120 successful writes/stops per minute, and 64 MiB of decoded updates per minute. Client coalescing targets at most 256 KiB/second on average; large frames must update less often. The 8 MiB maximum is a frame-size ceiling, not a promise of that size at two frames per second. Stop and expiration remove frame bytes; stopped metadata also expires. Anonymous reads do not extend retention. + +## Measured envelope — 2026-09-15 + +A bounded test executed the actual Lua script and Redis adapter from Lith against its configured Redis service, with 30 injected authenticated subjects and anonymous viewer reads. It sent 600 updates and 1,200 viewer reads using 64 KiB frames. Every frame was checked against its maker's expected bytes; total verified frame transfer was 39,321,600 bytes. + +The run completed in 4.15 seconds, including additional isolation, stop, and expiry checks. Combined update → metadata → frame latency was 92 ms at p50 and 202 ms at p95. It also verified rejected foreign writes, rejected stale sequences, the four-active-broadcast limit, physical frame removal on stop, and actual expiry. The expiry check shortened only one uniquely namespaced test key to one second. All test keys were removed afterward; production keys and expiration settings were untouched. + +This measures the backend Redis transport with generated fixture bytes. It excludes real Auth0 validation, public-network latency, viewer rendering, model inference, media generation, and sustained classroom operation. It does not establish support for 30 simultaneous 8 MiB frames at two updates per second. At 30 maximum-sized current frames, base64 payload alone would occupy about 320 MiB before Redis overhead. + +Focused regression checks: `node --test easel/test/live-broadcast-api.test.mjs`. These include a separate 30-maker in-memory simulation; its timings are not Redis or network capacity measurements. diff --git a/easel/test/live-broadcast-api.test.mjs b/easel/test/live-broadcast-api.test.mjs new file mode 100644 index 0000000000..181f8d4fa4 --- /dev/null +++ b/easel/test/live-broadcast-api.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createLiveHandler,redisLiveStore,WRITE_SCRIPT,MAX_FRAME_BYTES,LIVE_TTL} from '../../system/backend/easel-live.mjs'; +const id=n=>n.toString(16).padStart(32,'0'); +function fixture(){let clock=1000000;const rows=new Map(),rates=new Map();let reads=0,writes=0; + const store={async read(id,frame=false){reads++;const row=rows.get(id);if(!row||Number(row.expiresAt)<=clock){rows.delete(id);return null;}const result={...row};delete result.owner;if(!frame)delete result.data;return result;},async write(id,owner,doc,at){writes++;const previous=rows.get(id);if(previous&&previous.owner!==owner)return -1;if(previous&&doc.sequence<=previous.sequence)return -2;if(!previous&&doc.status==='stopped')return -5;const key=owner+Math.floor(at/60000),rate=rates.get(key)||{count:0,bytes:0};if(rate.count>=120||(doc.status==='live'&&rate.bytes+(doc.bytes||0)>67108864))return -4;if(doc.status==='live'&&!previous&&[...rows.values()].filter(r=>r.owner===owner&&r.status==='live'&&r.expiresAt>at).length>=4)return -3;rates.set(key,{count:rate.count+1,bytes:rate.bytes+(doc.bytes||0)});const row={...previous,...doc,owner,updatedAt:at,expiresAt:at+LIVE_TTL*1000};if(doc.status==='stopped')delete row.data;rows.set(id,row);return 1;}}; + const handler=createLiveHandler({authorize:async h=>h.authorization?{sub:h.authorization}:null,getHandleOrEmail:async sub=>sub==='nohandle'?'mail@example.test':'@maker',store,now:()=>clock}); + const request=(method,body,owner='owner',query)=>handler({httpMethod:method,headers:owner?{authorization:owner}:{},...(method==='GET'?{queryStringParameters:query}:{body:JSON.stringify(body)})}); + const doc=(n,sequence=1,data=Buffer.from('draft').toString('base64'))=>({id:id(n),sequence,kind:'picture',version:sequence,mime:'image/png',data,status:'live'}); + return {request,doc,rows,advance:ms=>{clock+=ms;},counts:()=>({reads,writes})}; +} +test('owner writes, anonymous late join, exact sequence reads, stale and foreign writes, stop and expiry',async()=>{ + const f=fixture();assert.equal((await f.request('POST',f.doc(1),null)).statusCode,401);assert.equal((await f.request('POST',f.doc(1),'nohandle')).statusCode,403); + let result=await f.request('POST',f.doc(1));assert.equal(result.statusCode,200);assert.equal(JSON.parse(result.body).route,`https://aesthetic.computer/watch/?id=${id(1)}`); + result=await f.request('GET',null,null,{id:id(1)});assert.equal(result.statusCode,200);assert.doesNotMatch(result.body,/draft|owner|data/);assert.equal(result.headers['Cache-Control'],'no-store'); + result=await f.request('GET',null,null,{id:id(1),frame:'1'});assert.equal(Buffer.from(result.body,'base64').toString(),'draft');assert.equal(result.isBase64Encoded,true); + assert.equal((await f.request('POST',f.doc(1,2),'stranger')).statusCode,403); + assert.equal((await f.request('POST',f.doc(1,2))).statusCode,200); + result=await f.request('POST',f.doc(1,1));assert.equal(result.statusCode,409);assert.equal(JSON.parse(result.body).sequence,2); + assert.equal((await f.request('GET',null,null,{id:id(1),frame:'1'})).statusCode,409); + assert.equal((await f.request('DELETE',{id:id(1),sequence:3},'stranger')).statusCode,403); + assert.equal((await f.request('DELETE',{id:id(1),sequence:3})).statusCode,200);assert.equal(f.rows.get(id(1)).data,undefined); + assert.equal((await f.request('GET',null,null,{id:id(1),frame:'3'})).statusCode,410); + assert.equal(JSON.parse((await f.request('GET',null,null,{id:id(1)})).body).status,'stopped'); + assert.equal((await f.request('POST',f.doc(1,2))).statusCode,409);assert.equal((await f.request('POST',f.doc(1,4))).statusCode,200); + f.advance(LIVE_TTL*1000+1);assert.equal((await f.request('GET',null,null,{id:id(1)})).statusCode,404);assert.equal(f.rows.size,0); +}); +test('strict schemas reject source/private fields, unsupported media and overlimit payloads; owner sessions and writes bounded',async()=>{ + const f=fixture();for(const patch of [{prompt:'secret'},{source:'hidden'},{mime:'text/html'},{data:'!!!!'},{sequence:0},{status:'published'},{id:'short'}])assert.equal((await f.request('POST',{...f.doc(1),...patch})).statusCode,400); + assert.equal((await f.request('POST',f.doc(1,1,Buffer.alloc(MAX_FRAME_BYTES+1).toString('base64')))).statusCode,413); + for(let i=1;i<=4;i++)assert.equal((await f.request('POST',f.doc(i))).statusCode,200); + assert.equal((await f.request('POST',f.doc(5))).statusCode,429); + for(let seq=2;seq<=117;seq++)assert.equal((await f.request('POST',f.doc(1,seq))).statusCode,200); + assert.equal((await f.request('POST',f.doc(1,118))).statusCode,429); +}); +test('draft Paper and Game Boy source can be watched before compilation',async()=>{ + const f=fixture();for(const [n,kind] of [[1,'paper'],[2,'gameboy']]){const result=await f.request('POST',{...f.doc(n),kind,mime:'text/plain'});assert.equal(result.statusCode,200);assert.equal(JSON.parse(result.body).kind,kind);} +}); +test('Redis adapter performs one atomic script with hashed owner keys and reads metadata without frame bytes',async()=>{ + let call;const redis={eval:async(script,options)=>{call={script,options};return 1;},hmGet:async(key,fields)=>{assert.ok(!fields.includes('data'));return fields.map(k=>k==='kind'?'picture':null);}}; + const store=redisLiveStore(redis);await store.write(id(1),'private-auth-sub',{sequence:7,status:'live',kind:'picture',mime:'image/png',version:2,data:'eA==',bytes:1},1000); + assert.equal(call.script,WRITE_SCRIPT);assert.equal(call.options.keys.length,4);assert.ok(call.options.keys.every(k=>!k.includes('private-auth-sub')));assert.equal(call.options.arguments[1],'7');assert.equal(call.options.arguments[9],String(LIVE_TTL));await store.read(id(1)); +}); +test('30 independent makers and late viewers: 20 updates each, 64 KiB frames, no cross-session leakage',async t=>{ + const f=fixture(),started=performance.now();let transferred=0; + for(let seq=1;seq<=20;seq++){ + await Promise.all(Array.from({length:30},async(_,i)=>{const n=i+1,bytes=Buffer.alloc(64*1024,n),doc=f.doc(n,seq,bytes.toString('base64')); + assert.equal((await f.request('POST',doc,`owner-${n}`)).statusCode,200); + const meta=JSON.parse((await f.request('GET',null,null,{id:id(n)})).body);assert.equal(meta.sequence,seq); + const frame=await f.request('GET',null,null,{id:id(n),frame:String(seq)});assert.equal(frame.statusCode,200);const decoded=Buffer.from(frame.body,'base64');assert.deepEqual(decoded,bytes);transferred+=decoded.length; + }));f.advance(500); + } + assert.equal(f.rows.size,30);assert.deepEqual(f.counts(),{reads:1800,writes:600}); + t.diagnostic(`In-memory store simulation, not Redis/network capacity: 30 makers, 600 writes + 1200 viewer reads, ${transferred} frame bytes verified in ${Math.round(performance.now()-started)}ms; simulated 2fps ×10s, current-frame-only storage.`); +}); diff --git a/easel/test/watch-client.test.mjs b/easel/test/watch-client.test.mjs new file mode 100644 index 0000000000..bcdd3155ad --- /dev/null +++ b/easel/test/watch-client.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test';import assert from 'node:assert/strict'; +import {LiveWatch,validateMetadata,MAX_FRAME_BYTES} from '../../system/public/watch/client.mjs'; +const id='a'.repeat(32),meta={id,kind:'picture',version:1,mime:'image/png',sequence:1,status:'live'}; +function fixture(fetch){const timers=new Map(),frames=[],statuses=[],errors=[],ends=[];let timer=0;const watch=new LiveWatch({id,fetch,onFrame:async(metadata,bytes)=>frames.push({metadata,bytes}),onStatus:(...args)=>statuses.push(args),onError:error=>errors.push(error),onEnd:reason=>ends.push(reason),schedule:(fn,delay)=>{timers.set(++timer,{fn,delay});return timer;},cancel:id=>timers.delete(id)});watch.start();return{watch,timers,frames,statuses,errors,ends};} +const metadata=(value=meta)=>({ok:true,status:200,json:async()=>value});const frame=()=>({ok:true,status:200,headers:{get:()=>null},arrayBuffer:async()=>new Uint8Array([1,2,3]).buffer}); +test('one frame request per new sequence, stable nodes on metadata-only polling',async()=>{let count=0;const f=fixture(async(url,options)=>{assert.equal(options.credentials,'omit');count++;return url.includes('&frame=')?frame():metadata();});await f.watch.poll();await f.watch.poll();assert.equal(count,3);assert.equal(f.frames.length,1);assert.equal(f.watch.sequence,1);f.watch.stop();}); +test('superseded frame keeps prior version and retries metadata',async()=>{const f=fixture(async url=>url.includes('&frame=')?{status:409}:metadata());await f.watch.poll();assert.equal(f.frames.length,0);assert.equal(f.errors.length,0);assert.ok([...f.timers.values()].some(t=>t.delay===250));f.watch.stop();}); +test('polling is sequential and visibility abort prevents late frame display',async()=>{let resolve,count=0;const f=fixture(async()=>{count++;return new Promise(r=>resolve=r);});const running=f.watch.poll();await f.watch.poll();assert.equal(count,1);f.watch.setVisible(false);resolve(metadata());await running;assert.equal(f.frames.length,0);assert.equal(f.timers.size,0);f.watch.setVisible(true);assert.equal(f.timers.size,1);f.watch.stop();}); +test('stopped and expired links stop polling and clear content through onEnd',async()=>{for(const code of [404,410]){const f=fixture(async()=>({status:code}));await f.watch.poll();assert.deepEqual(f.ends,[code===410?'stopped':'expired']);assert.equal(f.watch.running,false);assert.equal(f.timers.size,0);}}); +test('connection errors back off without discarding successful frame',async()=>{let online=true;const f=fixture(async url=>{if(!online)throw new Error('offline');return url.includes('&frame=')?frame():metadata();});await f.watch.poll();online=false;await f.watch.poll();await f.watch.poll();assert.equal(f.frames.length,1);assert.equal(f.watch.sequence,1);assert.equal(f.errors.length,2);assert.ok([...f.timers.values()].some(t=>t.delay===1500));f.watch.stop();}); +test('invalid metadata and oversized frames never reach rendering',async()=>{assert.throws(()=>validateMetadata({...meta,kind:'html'},id));assert.throws(()=>new LiveWatch({id:'../secret'}));const f=fixture(async url=>url.includes('&frame=')?{...frame(),headers:{get:()=>String(MAX_FRAME_BYTES+1)}}:metadata());await f.watch.poll();assert.equal(f.frames.length,0);assert.match(f.errors[0],/too large/);f.watch.stop();}); diff --git a/system/backend/easel-live.mjs b/system/backend/easel-live.mjs new file mode 100644 index 0000000000..6005c5c2a2 --- /dev/null +++ b/system/backend/easel-live.mjs @@ -0,0 +1,77 @@ +import {createHash} from 'node:crypto'; +export const MAX_FRAME_BYTES=8*1024*1024; +export const LIVE_TTL=3600; +const ID=/^[a-f0-9]{32}$/; +const TYPES={picture:['image/png','image/jpeg','image/webp'],sound:['audio/wav','audio/mpeg','audio/ogg'],paper:['application/pdf','text/plain'],gameboy:['application/x-gameboy-rom','text/plain'],piece:['text/javascript','text/plain']}; +const FIELDS=['kind','version','mime','sequence','status','updatedAt','expiresAt']; +const headers={'Access-Control-Allow-Origin':'*','Access-Control-Allow-Methods':'GET, POST, DELETE, OPTIONS','Access-Control-Allow-Headers':'Content-Type, Authorization','Cache-Control':'no-store','X-Content-Type-Options':'nosniff'}; +const reply=(statusCode,value)=>({statusCode,headers:{...headers,'Content-Type':'application/json'},body:JSON.stringify(value)}); + +// One Redis transaction owns identity, ordering, rate limits, and frame lifetime. +export const WRITE_SCRIPT=` +local owner=redis.call('HGET',KEYS[1],'owner') +if owner and owner~=ARGV[1] then return -1 end +local seq=tonumber(redis.call('HGET',KEYS[1],'sequence') or '0') +if tonumber(ARGV[2])<=seq then return -2 end +if ARGV[3]=='stopped' and not owner then return -5 end +local requests=tonumber(redis.call('GET',KEYS[3]) or '0') +if requests>=120 then return -4 end +if ARGV[3]=='live' then + redis.call('ZREMRANGEBYSCORE',KEYS[2],'-inf',ARGV[8]) + if not redis.call('ZSCORE',KEYS[2],KEYS[1]) and redis.call('ZCARD',KEYS[2])>=4 then return -3 end + local bytes=tonumber(redis.call('GET',KEYS[4]) or '0') + if requests>=120 or bytes+tonumber(ARGV[11])>67108864 then return -4 end + redis.call('INCRBY',KEYS[4],ARGV[11]);redis.call('EXPIRE',KEYS[4],60) + redis.call('ZADD',KEYS[2],ARGV[9],KEYS[1]);redis.call('EXPIRE',KEYS[2],ARGV[10]) +else redis.call('ZREM',KEYS[2],KEYS[1]) end +redis.call('INCR',KEYS[3]);redis.call('EXPIRE',KEYS[3],60) +redis.call('HSET',KEYS[1],'owner',ARGV[1],'sequence',ARGV[2],'status',ARGV[3],'updatedAt',ARGV[8],'expiresAt',ARGV[9]) +if ARGV[3]=='live' then + redis.call('HSET',KEYS[1],'kind',ARGV[4],'version',ARGV[5],'mime',ARGV[6],'data',ARGV[7]) +else redis.call('HDEL',KEYS[1],'data') end +redis.call('EXPIRE',KEYS[1],ARGV[10]) +return 1`; +export function redisLiveStore(redis,{prefix='easel-live:'}={}) { + return { + async read(id,frame=false){const fields=frame?[...FIELDS,'data']:FIELDS;const values=await redis.hmGet(prefix+id,fields);if(values[0]===null)return null;return Object.fromEntries(fields.map((key,i)=>[key,values[i]]));}, + async write(id,owner,document,now){const ownerKey=createHash('sha256').update(owner).digest('hex');const minute=Math.floor(now/60000);return Number(await redis.eval(WRITE_SCRIPT,{keys:[prefix+id,prefix+'owner:'+ownerKey,prefix+'rate:'+ownerKey+':'+minute,prefix+'bytes:'+ownerKey+':'+minute],arguments:[owner,String(document.sequence),document.status,document.kind||'',String(document.version||''),document.mime||'',document.data||'',String(now),String(now+LIVE_TTL*1000),String(LIVE_TTL),String(document.bytes||0)]}));} + }; +} +function metadata(id,row){return {id,route:`https://aesthetic.computer/watch/?id=${id}`,kind:row.kind,version:Number(row.version),mime:row.mime,sequence:Number(row.sequence),status:row.status,updatedAt:new Date(Number(row.updatedAt)).toISOString(),expiresAt:new Date(Number(row.expiresAt)).toISOString()};} +export function createLiveHandler({authorize,getHandleOrEmail,store,now=Date.now}={}) { + return async event=>{ + const method=event.httpMethod; + if(method==='OPTIONS')return {statusCode:204,headers,body:''}; + if(!['GET','POST','DELETE'].includes(method))return reply(405,{error:'Use GET, POST or DELETE'}); + try { + if(method==='GET'){ + const {id,frame}=event.queryStringParameters||{}; + if(!ID.test(id||'')||(frame!==undefined&&!/^[1-9][0-9]{0,14}$/.test(frame)))return reply(400,{error:'Invalid draft ID or frame sequence'}); + const row=await store.read(id,frame!==undefined);if(!row)return reply(404,{error:'Draft expired or not found'}); + if(frame===undefined)return reply(200,metadata(id,row)); + if(row.status!=='live'||!row.data)return reply(410,{error:'Broadcast stopped'}); + if(Number(frame)!==Number(row.sequence))return reply(409,{error:'Frame superseded; refresh metadata'}); + return {statusCode:200,headers:{...headers,'Content-Type':row.mime,'Content-Security-Policy':"sandbox; default-src 'none'",'Content-Disposition':'inline'},body:row.data,isBase64Encoded:true}; + } + if(!event.headers?.authorization && !event.headers?.Authorization)return reply(401,{error:'Authentication required'}); + const maxBody=method==='DELETE'?1024:Math.ceil(MAX_FRAME_BYTES/3)*4+2048; + if(event.isBase64Encoded||typeof event.body!=='string'||Buffer.byteLength(event.body)>maxBody)return reply(413,{error:'Draft exceeds 8 MiB'}); + let doc;try{doc=JSON.parse(event.body);}catch{return reply(400,{error:'Invalid JSON'});} + if(!doc||Array.isArray(doc)||!ID.test(doc.id||'')||!Number.isSafeInteger(doc.sequence)||doc.sequence<1)return reply(400,{error:'ID and positive sequence required'}); + const allowed=method==='DELETE'?['id','sequence']:['id','sequence','kind','version','mime','data','status']; + if(Object.keys(doc).some(k=>!allowed.includes(k)))return reply(400,{error:'Unknown draft fields'}); + if(method==='POST') { + if(!TYPES[doc.kind]?.includes(doc.mime)||!Number.isSafeInteger(doc.version)||doc.version<1||doc.status!=='live'||typeof doc.data!=='string'||!doc.data.length||doc.data.length%4!==0||!/^[A-Za-z0-9+/]*={0,2}$/.test(doc.data))return reply(400,{error:'Invalid media, version, status or base64'}); + const bytes=Buffer.from(doc.data,'base64');if(bytes.length>MAX_FRAME_BYTES)return reply(413,{error:'Draft exceeds 8 MiB'}); + if(bytes.toString('base64')!==doc.data)return reply(400,{error:'Invalid base64'});doc.bytes=bytes.length; + }else doc.status='stopped'; + let user;try{user=await authorize(event.headers||{});}catch{return reply(503,{error:'Authentication unavailable'});} + if(!user?.sub)return reply(401,{error:'Authentication required'}); + const handle=await getHandleOrEmail(user.sub);if(typeof handle!=='string'||!handle.startsWith('@'))return reply(403,{error:'An AC handle is required to broadcast'}); + const result=await store.write(doc.id,user.sub,doc,now()); + if(result===-2) {const row=await store.read(doc.id);return reply(409,{error:'Stale sequence',sequence:Number(row?.sequence||0)});} + if(result!==1)return reply(result===-1?403:result===-2?409:result===-5?404:429,{error:result===-1?'Draft belongs to another account':result===-2?'Stale sequence':result===-5?'Draft not found':'Draft limit reached; coalesce updates and retry'}); + const row=await store.read(doc.id);return reply(200,metadata(doc.id,row)); + }catch{return reply(503,{error:'Draft storage unavailable'});} + }; +} diff --git a/system/netlify/functions/easel-live.mjs b/system/netlify/functions/easel-live.mjs new file mode 100644 index 0000000000..72ef1b90a2 --- /dev/null +++ b/system/netlify/functions/easel-live.mjs @@ -0,0 +1,12 @@ +import {createClient} from 'redis'; +import {authorize,getHandleOrEmail} from '../../backend/authorization.mjs'; +import {createLiveHandler,redisLiveStore} from '../../backend/easel-live.mjs'; +let client,connecting; +async function redis(){ + if(!process.env.REDIS_CONNECTION_STRING)throw new Error("Redis not configured"); + if(client?.isReady)return client; + if(!connecting)connecting=(async()=>{client=createClient({url:process.env.REDIS_CONNECTION_STRING,socket:{connectTimeout:3000,reconnectStrategy:false}});client.on('error',()=>{});await client.connect();return client;})().finally(()=>{connecting=null;}); + return connecting; +} +const store={read:async(...args)=>redisLiveStore(await redis()).read(...args),write:async(...args)=>redisLiveStore(await redis()).write(...args)}; +export const handler=createLiveHandler({authorize,getHandleOrEmail,store}); diff --git a/system/public/watch/client.mjs b/system/public/watch/client.mjs new file mode 100644 index 0000000000..5bd562a060 --- /dev/null +++ b/system/public/watch/client.mjs @@ -0,0 +1,61 @@ +export const MAX_FRAME_BYTES = 16 * 1024 * 1024; +export function validateMetadata(value, id) { + if (!value || value.id !== id || !['picture','sound','paper','gameboy','piece'].includes(value.kind) + || !Number.isSafeInteger(value.sequence) || value.sequence < 1 + || !Number.isSafeInteger(value.version) || value.version < 1 + || !['live','stopped'].includes(value.status) || typeof value.mime !== 'string') throw new Error('Invalid preview metadata'); + return value; +} +async function readFrame(response) { + if (Number(response.headers?.get('content-length')) > MAX_FRAME_BYTES) throw new Error('Preview is too large'); + if (!response.body?.getReader) { const bytes=await response.arrayBuffer(); if(bytes.byteLength>MAX_FRAME_BYTES)throw new Error('Preview is too large');return bytes; } + const reader=response.body.getReader(), chunks=[];let length=0; + try { while(true){const{done,value}=await reader.read();if(done)break;length+=value.length;if(length>MAX_FRAME_BYTES){await reader.cancel();throw new Error('Preview is too large');}chunks.push(value);} } + finally {reader.releaseLock();} + const bytes=new Uint8Array(length);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return bytes.buffer; +} +export class LiveWatch { + constructor({id,fetch=globalThis.fetch.bind(globalThis),onFrame=async()=>{},onStatus=()=>{},onEnd=()=>{},onError=()=>{},schedule=(fn,delay)=>globalThis.setTimeout(fn,delay),cancel=timer=>globalThis.clearTimeout(timer)}={}) { + if(!/^[a-f0-9]{32}$/.test(id||''))throw new Error('This watch link is incomplete'); + Object.assign(this,{id,fetch,onFrame,onStatus,onEnd,onError,schedule,cancel}); + this.visible=true;this.running=false;this.sequence=0;this.retry=750;this.epoch=0; + } + start(){if(this.running)return;this.running=true;this.queue(0);} + stop(){this.running=false;this.epoch++;this.cancel(this.timer);this.controller?.abort();} + setVisible(visible){this.visible=visible;this.epoch++;this.cancel(this.timer);this.controller?.abort();if(visible&&this.running)this.queue(0);} + queue(delay){this.cancel(this.timer);if(this.running&&this.visible)this.timer=this.schedule(()=>this.poll(),delay);} + async poll(){ + if(!this.running||!this.visible||this.inFlight)return; + this.inFlight=true;const epoch=this.epoch,controller=new AbortController();this.controller=controller; + const timeout=this.schedule(()=>controller.abort(),15000); + let delay=750; + try{ + const options={credentials:'omit',cache:'no-store',referrerPolicy:'no-referrer',signal:controller.signal}; + const response=await this.fetch(`/api/easel-live?id=${this.id}`,options); + if(response.status===404||response.status===410){this.stop();this.onEnd(response.status===410?'stopped':'expired');return;} + if(!response.ok)throw new Error('Could not connect to this preview'); + const metadata=validateMetadata(await response.json(),this.id); + if(epoch!==this.epoch||!this.running)return; + if(metadata.status==='stopped'){this.stop();this.onEnd('stopped');return;} + this.onStatus(metadata,this.sequence); + if(metadata.sequence>this.sequence){ + const frame=await this.fetch(`/api/easel-live?id=${this.id}&frame=${metadata.sequence}`,options); + if(frame.status===409){delay=250;return;} + if(frame.status===404||frame.status===410){this.stop();this.onEnd(frame.status===410?'stopped':'expired');return;} + if(!frame.ok)throw new Error('Could not load the new version'); + const bytes=await readFrame(frame); + if(epoch!==this.epoch||!this.running)return; + await this.onFrame(metadata,bytes,controller.signal); + if(epoch!==this.epoch||!this.running)return; + this.sequence=metadata.sequence; + this.onStatus(metadata,this.sequence); + } + this.retry=750; + }catch(error){ + if(epoch===this.epoch&&this.running&&this.visible){this.onError(error.name==='AbortError'?'Connection timed out; reconnecting':error.message);delay=this.retry;this.retry=Math.min(10000,this.retry*2);} + }finally{ + this.cancel(timeout);this.inFlight=false;if(this.controller===controller)this.controller=null; + if(this.running&&this.visible)this.queue(epoch===this.epoch?delay:0); + } + } +} diff --git a/system/public/watch/gameboy.html b/system/public/watch/gameboy.html new file mode 100644 index 0000000000..8e782232c2 --- /dev/null +++ b/system/public/watch/gameboy.html @@ -0,0 +1,8 @@ + + +Game Boy preview + +

Loading Game Boy…

+
+ + diff --git a/system/public/watch/gameboy.js b/system/public/watch/gameboy.js new file mode 100644 index 0000000000..f1c5bb4ee6 --- /dev/null +++ b/system/public/watch/gameboy.js @@ -0,0 +1,39 @@ +(() => { + const canvas=document.getElementById('screen'),status=document.getElementById('game-status'),pause=document.getElementById('pause'); + const emulator=window.WasmBoy?.WasmBoy,held={};let loaded=false,playing=false; + const update=()=>{if(loaded)emulator.setJoypadState({...held});}; + const release=()=>{for(const key of Object.keys(held))held[key]=false;update();}; + const keys={ArrowUp:'UP',ArrowDown:'DOWN',ArrowLeft:'LEFT',ArrowRight:'RIGHT',x:'A',X:'A',z:'B',Z:'B',Enter:'START',Shift:'SELECT'}; + for(const name of ['keydown','keyup'])canvas.addEventListener(name,event=>{const key=keys[event.key];if(!key)return;event.preventDefault();held[key]=name==='keydown';update();}); + canvas.addEventListener('blur',release); + for(const button of document.querySelectorAll('[data-key]')){ + button.addEventListener('pointerdown',event=>{event.preventDefault();button.setPointerCapture(event.pointerId);held[button.dataset.key]=true;update();}); + for(const name of ['pointerup','pointercancel','lostpointercapture'])button.addEventListener(name,()=>{held[button.dataset.key]=false;update();}); + button.addEventListener('keydown',event=>{if(event.key===' '||event.key==='Enter'){event.preventDefault();held[button.dataset.key]=true;update();}}); + button.addEventListener('keyup',event=>{if(event.key===' '||event.key==='Enter'){event.preventDefault();held[button.dataset.key]=false;update();}}); + button.addEventListener('blur',release); + } + pause.addEventListener('click',async()=>{try{if(playing){await emulator.pause();playing=false;pause.textContent='Play';}else{await emulator.play();playing=true;pause.textContent='Pause';canvas.focus();}}catch{status.textContent='Could not resume Game Boy';}}); + const receive=async event=>{ + if(event.source!==parent||event.data?.type!=='easel-gameboy-rom')return; + window.removeEventListener('message',receive); + const bytes=event.data.bytes; + if(!(bytes instanceof ArrayBuffer)||bytes.byteLength<32768||bytes.byteLength>8*1024*1024){status.textContent='This ROM cannot be previewed.';return;} + if(!emulator){status.textContent='Game Boy emulator could not load.';return;} + let timer,failed=false; + try{ + let firstFrame;const first=new Promise((resolve,reject)=>{firstFrame=resolve;timer=setTimeout(()=>reject(new Error('The ROM has not produced a frame.')),10000);});first.catch(()=>{}); + const initialize=async()=>{ + await emulator.config({headless:false,isAudioEnabled:false,enableBootROMIfAvailable:false,updateGraphicsCallback:pixels=>{if(pixels?.length===160*144*4)firstFrame();}},canvas); + if(failed)return;await emulator.loadROM(new Uint8Array(bytes));if(failed)return;emulator.disableDefaultJoypad();loaded=true;await emulator.play();playing=true;if(failed){await emulator.pause();return;}await first; + }; + await Promise.race([initialize(),first]); + status.textContent='Arrows · X/Z · Enter/Shift — sound off';pause.hidden=false; + }catch(error){failed=true;status.textContent=error.message||'Game Boy could not start.';await emulator.pause().catch(()=>{});} + finally{clearTimeout(timer);} + }; + window.addEventListener('message',receive); + document.addEventListener('visibilitychange',()=>{if(document.hidden&&loaded){release();emulator.pause();playing=false;pause.textContent='Play';}}); + window.addEventListener('pagehide',()=>{if(loaded)emulator.pause();}); + parent.postMessage({type:'easel-gameboy-ready'},'*'); +})(); diff --git a/system/public/watch/index.html b/system/public/watch/index.html new file mode 100644 index 0000000000..17a5ca3c45 --- /dev/null +++ b/system/public/watch/index.html @@ -0,0 +1,8 @@ + + + + +Easel preview +
Easel previewConnecting…
+

Waiting for the first version.

+ diff --git a/system/public/watch/watch.css b/system/public/watch/watch.css new file mode 100644 index 0000000000..8e1f8c83b0 --- /dev/null +++ b/system/public/watch/watch.css @@ -0,0 +1 @@ +:root{color-scheme:dark;font-family:system-ui,sans-serif;background:#463264;color:#fff}*{box-sizing:border-box}body{margin:0;min-height:100dvh;display:flex;flex-direction:column}header{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 16px;min-height:48px}header strong{font-size:16px;font-weight:600}#status{font-size:13px;color:#e2d5ef;text-align:right}#artifact{flex:1;min-height:calc(100dvh - 48px);display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px;padding:12px}#artifact>img{display:block;width:100%;height:calc(100dvh - 80px);max-width:100%;max-height:calc(100dvh - 80px);object-fit:contain;image-rendering:pixelated}#artifact iframe{border:0;width:100%;height:calc(100dvh - 110px);background:#20172e}#artifact pre{align-self:stretch;white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.5 ui-monospace,monospace;padding:16px;background:#20172e;max-height:calc(100dvh - 120px);overflow:auto}audio{width:min(560px,100%)}canvas.waveform{width:min(720px,100%);height:180px;image-rendering:pixelated}a{color:#fff;text-underline-offset:3px}p{line-height:1.5;text-align:center}button{font:inherit;background:#fff;color:#21142c;border:0;border-radius:4px;padding:10px 16px;cursor:pointer}button:focus-visible,a:focus-visible,canvas:focus-visible{outline:3px solid #ff92ef;outline-offset:3px}.game{margin:0;background:#21142c;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;padding:10px}.game canvas{width:min(100%,480px);max-height:65dvh;aspect-ratio:160/144;object-fit:contain;image-rendering:pixelated}.game-controls{display:flex;gap:6px;flex-wrap:wrap;justify-content:center}.game-controls button{touch-action:none;min-width:44px;user-select:none}.game p{margin:0;font-size:13px}.source-label{margin:0;color:#e2d5ef} diff --git a/system/public/watch/watch.mjs b/system/public/watch/watch.mjs new file mode 100644 index 0000000000..66e0e1d632 --- /dev/null +++ b/system/public/watch/watch.mjs @@ -0,0 +1,43 @@ +import {LiveWatch} from './client.mjs'; +const artifact=document.getElementById('artifact'),label=document.getElementById('label'),status=document.getElementById('status'); +let activeURL=null,player=null,activeCleanup=()=>{}; +function clear(){activeCleanup();activeCleanup=()=>{};player?.pause();player=null;if(activeURL)URL.revokeObjectURL(activeURL);activeURL=null;artifact.replaceChildren();} +function waveform(bytes){ + const data=new DataView(bytes);if(bytes.byteLength<44||new TextDecoder().decode(bytes.slice(0,4))!=='RIFF')return null; + let channels=0,bits=0,format=0,offset=12,samples=null; + while(offset+8<=data.byteLength){const size=data.getUint32(offset+4,true),start=offset+8;if(start+size>data.byteLength)return null;const id=new TextDecoder().decode(bytes.slice(offset,offset+4));if(id==='fmt '&&size>=16){format=data.getUint16(start,true);channels=data.getUint16(start+2,true);bits=data.getUint16(start+14,true);}if(id==='data'){samples={start,size};break;}offset=start+size+(size%2);} + if(!samples||format!==1||bits!==16||channels<1||channels>8)return null; + const canvas=document.createElement('canvas');canvas.className='waveform';canvas.width=720;canvas.height=180;canvas.setAttribute('role','img');canvas.setAttribute('aria-label','Audio waveform');const ctx=canvas.getContext('2d'),frames=Math.floor(samples.size/(channels*2));ctx.fillStyle='#ff92ef'; + for(let x=0;x<720;x++){let peak=0;for(let i=Math.floor(x*frames/720);i{}; + const makeURL=()=>nextURL=URL.createObjectURL(new Blob([bytes],{type:mime})); + try{ + if(metadata.kind==='picture'&&['image/png','image/jpeg','image/webp','image/gif'].includes(mime)){ + const img=new Image();img.alt=`Picture, version ${metadata.version}`;img.src=makeURL();await img.decode();fragment.append(img); + }else if(metadata.kind==='sound'&&['audio/wav','audio/x-wav','audio/mpeg','audio/ogg'].includes(mime)){ + const wave=waveform(bytes);if(wave)fragment.append(wave);nextPlayer=document.createElement('audio');nextPlayer.controls=true;nextPlayer.preload='metadata';nextPlayer.src=makeURL();fragment.append(nextPlayer); + }else if(metadata.kind==='paper'&&mime==='application/pdf'){ + const url=makeURL(),frame=document.createElement('iframe');frame.title=`Paper, version ${metadata.version}`;frame.sandbox='';frame.src=url;const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open PDF';fragment.append(link,frame); + }else if(metadata.kind==='gameboy'&&['application/octet-stream','application/x-gameboy-rom','application/x-gameboy'].includes(mime)){ + const frame=document.createElement('iframe');frame.title=`Playable Game Boy, version ${metadata.version}`;frame.sandbox='allow-scripts allow-same-origin';frame.src='./gameboy.html'; + // Send ROM bytes only to our exact child window; the capability stays here. + const send=event=>{if(event.source===frame.contentWindow&&event.data?.type==='easel-gameboy-ready'){window.removeEventListener('message',send);frame.contentWindow.postMessage({type:'easel-gameboy-rom',bytes},'*');}}; + window.addEventListener('message',send);nextCleanup=()=>window.removeEventListener('message',send);signal.addEventListener('abort',()=>window.removeEventListener('message',send),{once:true});frame.addEventListener('load',()=>{if(signal.aborted)window.removeEventListener('message',send);});fragment.append(frame); + }else if(['text/plain','text/javascript','application/javascript','text/x-tex','application/x-tex'].includes(mime)){ + const caption=document.createElement('p');caption.className='source-label';caption.textContent=metadata.kind==='piece'?'Piece source — execution is not available in this viewer.':'Source draft — build it to show the artifact.';const pre=document.createElement('pre');pre.textContent=new TextDecoder().decode(bytes);fragment.append(caption,pre); + }else throw new Error('This preview format is not supported'); + if(signal.aborted){nextCleanup();if(nextURL)URL.revokeObjectURL(nextURL);return;} + clear();activeCleanup=nextCleanup;activeURL=nextURL;player=nextPlayer;artifact.append(fragment);label.textContent=`${metadata.kind==='gameboy'?'Game Boy':metadata.kind[0].toUpperCase()+metadata.kind.slice(1)} · v${metadata.version}`; + }catch(error){nextCleanup();if(nextURL)URL.revokeObjectURL(nextURL);throw error;} +} +let watch; +try{ + const id=new URLSearchParams(location.search).get('id'); + watch=new LiveWatch({id,onFrame:display,onStatus:(metadata,sequence)=>{status.textContent=metadata.sequence>sequence?'Updating…':'Live draft';},onEnd:reason=>{clear();label.textContent='Easel preview';status.textContent=reason==='stopped'?'Sharing stopped':'Preview expired';const p=document.createElement('p');p.textContent='This draft is no longer being shared.';artifact.append(p);},onError:message=>status.textContent=message}); + watch.setVisible(!document.hidden);watch.start(); + document.addEventListener('visibilitychange',()=>{watch.setVisible(!document.hidden);if(document.hidden)player?.pause();}); + window.addEventListener('pagehide',()=>{watch.stop();clear();}); +}catch(error){status.textContent=error.message;artifact.replaceChildren();}