@exercode/problem-utils 4.3.2 → 4.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("../_virtual/_rolldown/runtime.cjs");let t=require("node:crypto");t=e.__toESM(t,1);let n=require("node:zlib");n=e.__toESM(n,1);const r=Buffer.from(`89504e470d0a1a0a`,`hex`),i={0:1,2:3,3:1,4:2,6:4},a=65535;var o=class{#e=/* @__PURE__ */ new Map;add(e,n,r){let i=this.#e.get(e)??{path:``,frames:[],lastDigest:``,changeCount:0,keptChangeInterval:1,lastSeenAtMs:0,isTooLarge:!1};this.#e.set(e,i),i.path=n.path.replace(/\.png$/,`_recording.png`),i.lastSeenAtMs=r;let a=t.default.createHash(`sha256`).update(n.data).digest(`base64`);if(i.lastDigest===a)return;i.lastDigest=a;let o=i.changeCount++;i.isTooLarge||o%i.keptChangeInterval!==0||(i.frames.push({png:Buffer.from(n.data,`base64`),capturedAtMs:r}),this.#t(({frames:e})=>e.reduce((e,t)=>e+t.png.length,0)))}build(e){let t=/* @__PURE__ */ new Map,n=({frames:n,lastSeenAtMs:r})=>{let i=t.get(n)??c(n.map((t,i)=>({png:t.png,delayMs:(n[i+1]?.capturedAtMs??r+e)-t.capturedAtMs})));return t.set(n,i),i},r=e=>s(e)&&e.frames.length>1;return this.#t(e=>r(e)?n(e).length:0),[...this.#e.values()].filter(r).map(e=>({path:e.path,data:n(e).toString(`base64`),encoding:`base64`}))}#t(e){let t=[...this.#e.values()];for(;;){let n=new Map(t.map(t=>[t,e(t)]));if([...n.values()].reduce((e,t)=>e+t,0)<=2097152)return;let r=t.filter(e=>e.frames.length>0).toSorted((e,t)=>(n.get(t)??0)-(n.get(e)??0)),[i]=r.filter(e=>!s(e)).toSorted((e,t)=>e.changeCount-t.changeCount),a=r.find(e=>e.frames.length>2);if(i)i.frames=[];else if(a)a.frames=a.frames.filter((e,t)=>t%2==0),a.keptChangeInterval*=2;else{let[e]=r;e.frames=[],e.isTooLarge=!0}}}};function s(e){return e.changeCount>=3}function c(e){let t=e.map(e=>({...l(e.png),delayMs:e.delayMs})),o=t[0]?.header.subarray(8);if(!o)throw Error(`an animated PNG needs at least one frame`);let s=t.filter(e=>e.header.subarray(8).equals(o)).map(e=>({...e,width:e.header.readUInt32BE(0),height:e.header.readUInt32BE(4)})),[c]=s,d=Math.max(...s.map(e=>e.width)),f=Math.max(...s.map(e=>e.height)),p=Buffer.from(c.header);p.writeUInt32BE(d,0),p.writeUInt32BE(f,4);let m=Buffer.alloc(8);m.writeUInt32BE(s.length,0);let h=[r,u(`IHDR`,p),...c.metadataChunks,u(`acTL`,m)],g=c.width===d&&c.height===f;if(!g){let e=o[0]*(i[o[1]]??1),t=Buffer.alloc(f*(1+Math.ceil(d*e/8)));h.push(u(`IDAT`,n.default.deflateSync(t)))}let _=0;for(let[e,t]of s.entries()){let n=Buffer.alloc(26);n.writeUInt32BE(_++,0),n.writeUInt32BE(t.width,4),n.writeUInt32BE(t.height,8);let r=Math.max(1,Math.min(1e3,Math.floor(a*1e3/t.delayMs))),i=Math.round(t.delayMs*r/1e3);if(n.writeUInt16BE(Math.min(Math.max(i,1),a),20),n.writeUInt16BE(r,22),n.writeUInt8(1,24),h.push(u(`fcTL`,n)),e===0&&g)h.push(u(`IDAT`,t.imageData));else{let e=Buffer.alloc(4);e.writeUInt32BE(_++),h.push(u(`fdAT`,Buffer.concat([e,t.imageData])))}}return h.push(u(`IEND`,Buffer.alloc(0))),Buffer.concat(h)}function l(e){if(!e.subarray(0,r.length).equals(r))throw Error(`not a PNG image`);let t,n=[],i=[],a=r.length;for(;a+12<=e.length;){let r=e.readUInt32BE(a),o=e.toString(`latin1`,a+4,a+8),s=e.subarray(a+8,a+8+r),c=a+12+r;o===`IHDR`?t=s:o===`IDAT`?i.push(s):i.length===0&&o!==`IEND`&&n.push(e.subarray(a,c)),a=c}if(t?.length!==13||i.length===0)throw Error(`not a PNG image`);return{header:t,metadataChunks:n,imageData:Buffer.concat(i)}}function u(e,t){let r=Buffer.concat([Buffer.from(e,`latin1`),t]),i=Buffer.alloc(r.length+8);return i.writeUInt32BE(t.length,0),r.copy(i,4),i.writeUInt32BE(n.default.crc32(r),i.length-4),i}exports.GuiRecorder=o,exports.MAX_GUI_RECORDING_BYTES=2097152,exports.encodeAnimatedPng=c;
1
+ "use strict";const e=require("../_virtual/_rolldown/runtime.cjs");let t=require("node:crypto");t=e.__toESM(t,1);let n=require("node:zlib");n=e.__toESM(n,1);const r=Buffer.from(`89504e470d0a1a0a`,`hex`),i={0:1,2:3,3:1,4:2,6:4},a=65535;var o=class{#e=/* @__PURE__ */ new Map;add(e,n,r){let i=this.#e.get(e)??{path:``,frames:[],lastDigest:``,changeCount:0,keptChangeInterval:1,lastSeenAtMs:0,isTooLarge:!1};this.#e.set(e,i),i.path=n.path.replace(/\.png$/,`_recording.png`),i.lastSeenAtMs=r;let a=t.default.createHash(`sha256`).update(n.data).digest(`base64`);if(i.lastDigest===a)return;i.lastDigest=a;let o=i.changeCount++;i.isTooLarge||o%i.keptChangeInterval!==0||(i.frames.push({png:Buffer.from(n.data,`base64`),capturedAtMs:r}),this.#t(({frames:e})=>e.reduce((e,t)=>e+t.png.length,0)))}build(e){let t=/* @__PURE__ */ new Map,n=({frames:n,lastSeenAtMs:r})=>{let i=t.get(n)??l(n.map((t,i)=>({png:t.png,delayMs:(n[i+1]?.capturedAtMs??r+e)-t.capturedAtMs})));return t.set(n,i),i};return this.#t(e=>s(e)?n(e).length:0),[...this.#e.values()].filter(s).map(e=>({path:e.path,data:n(e).toString(`base64`),encoding:`base64`}))}#t(e){let t=[...this.#e.values()];for(;;){let n=new Map(t.map(t=>[t,e(t)]));if([...n.values()].reduce((e,t)=>e+t,0)<=2097152)return;let r=t.filter(e=>e.frames.length>0).toSorted((e,t)=>(n.get(t)??0)-(n.get(e)??0)),[i]=r.filter(e=>!c(e)).toSorted((e,t)=>e.changeCount-t.changeCount),a=r.find(e=>e.frames.length>2);if(i)i.frames=[];else if(a)a.frames=a.frames.filter((e,t)=>t%2==0),a.keptChangeInterval*=2;else{let[e]=r;e.frames=[],e.isTooLarge=!0}}}};function s(e){return c(e)&&e.frames.length>1}function c(e){return e.changeCount>=3}function l(e){let t=e.map(e=>({...u(e.png),delayMs:e.delayMs})),o=t[0]?.header.subarray(8);if(!o)throw Error(`an animated PNG needs at least one frame`);let s=t.filter(e=>e.header.subarray(8).equals(o)).map(e=>({...e,width:e.header.readUInt32BE(0),height:e.header.readUInt32BE(4)})),[c]=s,l=Math.max(...s.map(e=>e.width)),f=Math.max(...s.map(e=>e.height)),p=Buffer.from(c.header);p.writeUInt32BE(l,0),p.writeUInt32BE(f,4);let m=Buffer.alloc(8);m.writeUInt32BE(s.length,0);let h=[r,d(`IHDR`,p),...c.metadataChunks,d(`acTL`,m)],g=c.width===l&&c.height===f;if(!g){let e=o[0]*(i[o[1]]??1),t=Buffer.alloc(f*(1+Math.ceil(l*e/8)));h.push(d(`IDAT`,n.default.deflateSync(t)))}let _=0;for(let[e,t]of s.entries()){let n=Buffer.alloc(26);n.writeUInt32BE(_++,0),n.writeUInt32BE(t.width,4),n.writeUInt32BE(t.height,8);let r=Math.max(1,Math.min(1e3,Math.floor(a*1e3/t.delayMs))),i=Math.round(t.delayMs*r/1e3);if(n.writeUInt16BE(Math.min(Math.max(i,1),a),20),n.writeUInt16BE(r,22),n.writeUInt8(1,24),h.push(d(`fcTL`,n)),e===0&&g)h.push(d(`IDAT`,t.imageData));else{let e=Buffer.alloc(4);e.writeUInt32BE(_++),h.push(d(`fdAT`,Buffer.concat([e,t.imageData])))}}return h.push(d(`IEND`,Buffer.alloc(0))),Buffer.concat(h)}function u(e){if(!e.subarray(0,r.length).equals(r))throw Error(`not a PNG image`);let t,n=[],i=[],a=r.length;for(;a+12<=e.length;){let r=e.readUInt32BE(a),o=e.toString(`latin1`,a+4,a+8),s=e.subarray(a+8,a+8+r),c=a+12+r;o===`IHDR`?t=s:o===`IDAT`?i.push(s):i.length===0&&o!==`IEND`&&n.push(e.subarray(a,c)),a=c}if(t?.length!==13||i.length===0)throw Error(`not a PNG image`);return{header:t,metadataChunks:n,imageData:Buffer.concat(i)}}function d(e,t){let r=Buffer.concat([Buffer.from(e,`latin1`),t]),i=Buffer.alloc(r.length+8);return i.writeUInt32BE(t.length,0),r.copy(i,4),i.writeUInt32BE(n.default.crc32(r),i.length-4),i}exports.GuiRecorder=o,exports.MAX_GUI_RECORDING_BYTES=2097152,exports.encodeAnimatedPng=l;
2
2
  //# sourceMappingURL=guiRecording.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"guiRecording.cjs","names":["crypto","zlib","require_runtime","node_crypto","node_zlib","PNG_SIGNATURE","CHANNELS_BY_COLOR_TYPE","MAX_UINT16","GuiRecorder","captures","#windowIdToCaptures","windowId","screenshot","capturedAtMs","digest","changeIndex","#shrinkToFit","frames","sum","frame","framesToAnimatedPng","encode","animatedPng","encodeAnimatedPng","index","lastSeenAtMs","frameIntervalMs","isRecorded","isAnimated","allCaptures","sizes","measure","size","bySizeDescending","b","a","notAnimated","shrinkable","largest","parsedFrames","parsePng","pixelFormat","animationFrames","firstFrame","width","height","header","animationControl","chunks","createChunk","startsWithFirstFrame","bitsPerPixel","blankScanlines","sequenceNumber","frameControl","delayUnitsPerSecond","delay","sequence","png","metadataChunks","imageDataChunks","offset","dataLength","type","data","nextOffset","typeAndData","chunk"],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n const isRecorded = (captures: WindowCaptures): boolean => isAnimated(captures) && captures.frames.length > 1;\n\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"guiRecording.cjs","names":["crypto","zlib","require_runtime","node_crypto","node_zlib","PNG_SIGNATURE","CHANNELS_BY_COLOR_TYPE","MAX_UINT16","GuiRecorder","captures","#windowIdToCaptures","windowId","screenshot","capturedAtMs","digest","changeIndex","#shrinkToFit","frames","sum","frame","framesToAnimatedPng","encode","animatedPng","encodeAnimatedPng","index","lastSeenAtMs","frameIntervalMs","isRecorded","allCaptures","sizes","measure","size","bySizeDescending","b","a","notAnimated","isAnimated","shrinkable","largest","parsedFrames","parsePng","pixelFormat","animationFrames","firstFrame","width","height","header","animationControl","chunks","createChunk","startsWithFirstFrame","bitsPerPixel","blankScanlines","sequenceNumber","frameControl","delayUnitsPerSecond","delay","sequence","png","metadataChunks","imageDataChunks","offset","dataLength","type","data","nextOffset","typeAndData","chunk"],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isRecorded(captures: WindowCaptures): boolean {\n return isAnimated(captures) && captures.frames.length > 1;\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- import e from"node:crypto";import t from"node:zlib";const n=2097152,r=Buffer.from(`89504e470d0a1a0a`,`hex`),i={0:1,2:3,3:1,4:2,6:4},a=65535;var o=class{#e=/* @__PURE__ */ new Map;add(t,n,r){let i=this.#e.get(t)??{path:``,frames:[],lastDigest:``,changeCount:0,keptChangeInterval:1,lastSeenAtMs:0,isTooLarge:!1};this.#e.set(t,i),i.path=n.path.replace(/\.png$/,`_recording.png`),i.lastSeenAtMs=r;let a=e.createHash(`sha256`).update(n.data).digest(`base64`);if(i.lastDigest===a)return;i.lastDigest=a;let o=i.changeCount++;i.isTooLarge||o%i.keptChangeInterval!==0||(i.frames.push({png:Buffer.from(n.data,`base64`),capturedAtMs:r}),this.#t(({frames:e})=>e.reduce((e,t)=>e+t.png.length,0)))}build(e){let t=/* @__PURE__ */ new Map,n=({frames:n,lastSeenAtMs:r})=>{let i=t.get(n)??c(n.map((t,i)=>({png:t.png,delayMs:(n[i+1]?.capturedAtMs??r+e)-t.capturedAtMs})));return t.set(n,i),i},r=e=>s(e)&&e.frames.length>1;return this.#t(e=>r(e)?n(e).length:0),[...this.#e.values()].filter(r).map(e=>({path:e.path,data:n(e).toString(`base64`),encoding:`base64`}))}#t(e){let t=[...this.#e.values()];for(;;){let n=new Map(t.map(t=>[t,e(t)]));if([...n.values()].reduce((e,t)=>e+t,0)<=2097152)return;let r=t.filter(e=>e.frames.length>0).toSorted((e,t)=>(n.get(t)??0)-(n.get(e)??0)),[i]=r.filter(e=>!s(e)).toSorted((e,t)=>e.changeCount-t.changeCount),a=r.find(e=>e.frames.length>2);if(i)i.frames=[];else if(a)a.frames=a.frames.filter((e,t)=>t%2==0),a.keptChangeInterval*=2;else{let[e]=r;e.frames=[],e.isTooLarge=!0}}}};function s(e){return e.changeCount>=3}function c(e){let n=e.map(e=>({...l(e.png),delayMs:e.delayMs})),o=n[0]?.header.subarray(8);if(!o)throw Error(`an animated PNG needs at least one frame`);let s=n.filter(e=>e.header.subarray(8).equals(o)).map(e=>({...e,width:e.header.readUInt32BE(0),height:e.header.readUInt32BE(4)})),[c]=s,d=Math.max(...s.map(e=>e.width)),f=Math.max(...s.map(e=>e.height)),p=Buffer.from(c.header);p.writeUInt32BE(d,0),p.writeUInt32BE(f,4);let m=Buffer.alloc(8);m.writeUInt32BE(s.length,0);let h=[r,u(`IHDR`,p),...c.metadataChunks,u(`acTL`,m)],g=c.width===d&&c.height===f;if(!g){let e=o[0]*(i[o[1]]??1),n=Buffer.alloc(f*(1+Math.ceil(d*e/8)));h.push(u(`IDAT`,t.deflateSync(n)))}let _=0;for(let[e,t]of s.entries()){let n=Buffer.alloc(26);n.writeUInt32BE(_++,0),n.writeUInt32BE(t.width,4),n.writeUInt32BE(t.height,8);let r=Math.max(1,Math.min(1e3,Math.floor(a*1e3/t.delayMs))),i=Math.round(t.delayMs*r/1e3);if(n.writeUInt16BE(Math.min(Math.max(i,1),a),20),n.writeUInt16BE(r,22),n.writeUInt8(1,24),h.push(u(`fcTL`,n)),e===0&&g)h.push(u(`IDAT`,t.imageData));else{let e=Buffer.alloc(4);e.writeUInt32BE(_++),h.push(u(`fdAT`,Buffer.concat([e,t.imageData])))}}return h.push(u(`IEND`,Buffer.alloc(0))),Buffer.concat(h)}function l(e){if(!e.subarray(0,r.length).equals(r))throw Error(`not a PNG image`);let t,n=[],i=[],a=r.length;for(;a+12<=e.length;){let r=e.readUInt32BE(a),o=e.toString(`latin1`,a+4,a+8),s=e.subarray(a+8,a+8+r),c=a+12+r;o===`IHDR`?t=s:o===`IDAT`?i.push(s):i.length===0&&o!==`IEND`&&n.push(e.subarray(a,c)),a=c}if(t?.length!==13||i.length===0)throw Error(`not a PNG image`);return{header:t,metadataChunks:n,imageData:Buffer.concat(i)}}function u(e,n){let r=Buffer.concat([Buffer.from(e,`latin1`),n]),i=Buffer.alloc(r.length+8);return i.writeUInt32BE(n.length,0),r.copy(i,4),i.writeUInt32BE(t.crc32(r),i.length-4),i}export{o as GuiRecorder,n as MAX_GUI_RECORDING_BYTES,c as encodeAnimatedPng};
1
+ import e from"node:crypto";import t from"node:zlib";const n=2097152,r=Buffer.from(`89504e470d0a1a0a`,`hex`),i={0:1,2:3,3:1,4:2,6:4},a=65535;var o=class{#e=/* @__PURE__ */ new Map;add(t,n,r){let i=this.#e.get(t)??{path:``,frames:[],lastDigest:``,changeCount:0,keptChangeInterval:1,lastSeenAtMs:0,isTooLarge:!1};this.#e.set(t,i),i.path=n.path.replace(/\.png$/,`_recording.png`),i.lastSeenAtMs=r;let a=e.createHash(`sha256`).update(n.data).digest(`base64`);if(i.lastDigest===a)return;i.lastDigest=a;let o=i.changeCount++;i.isTooLarge||o%i.keptChangeInterval!==0||(i.frames.push({png:Buffer.from(n.data,`base64`),capturedAtMs:r}),this.#t(({frames:e})=>e.reduce((e,t)=>e+t.png.length,0)))}build(e){let t=/* @__PURE__ */ new Map,n=({frames:n,lastSeenAtMs:r})=>{let i=t.get(n)??l(n.map((t,i)=>({png:t.png,delayMs:(n[i+1]?.capturedAtMs??r+e)-t.capturedAtMs})));return t.set(n,i),i};return this.#t(e=>s(e)?n(e).length:0),[...this.#e.values()].filter(s).map(e=>({path:e.path,data:n(e).toString(`base64`),encoding:`base64`}))}#t(e){let t=[...this.#e.values()];for(;;){let n=new Map(t.map(t=>[t,e(t)]));if([...n.values()].reduce((e,t)=>e+t,0)<=2097152)return;let r=t.filter(e=>e.frames.length>0).toSorted((e,t)=>(n.get(t)??0)-(n.get(e)??0)),[i]=r.filter(e=>!c(e)).toSorted((e,t)=>e.changeCount-t.changeCount),a=r.find(e=>e.frames.length>2);if(i)i.frames=[];else if(a)a.frames=a.frames.filter((e,t)=>t%2==0),a.keptChangeInterval*=2;else{let[e]=r;e.frames=[],e.isTooLarge=!0}}}};function s(e){return c(e)&&e.frames.length>1}function c(e){return e.changeCount>=3}function l(e){let n=e.map(e=>({...u(e.png),delayMs:e.delayMs})),o=n[0]?.header.subarray(8);if(!o)throw Error(`an animated PNG needs at least one frame`);let s=n.filter(e=>e.header.subarray(8).equals(o)).map(e=>({...e,width:e.header.readUInt32BE(0),height:e.header.readUInt32BE(4)})),[c]=s,l=Math.max(...s.map(e=>e.width)),f=Math.max(...s.map(e=>e.height)),p=Buffer.from(c.header);p.writeUInt32BE(l,0),p.writeUInt32BE(f,4);let m=Buffer.alloc(8);m.writeUInt32BE(s.length,0);let h=[r,d(`IHDR`,p),...c.metadataChunks,d(`acTL`,m)],g=c.width===l&&c.height===f;if(!g){let e=o[0]*(i[o[1]]??1),n=Buffer.alloc(f*(1+Math.ceil(l*e/8)));h.push(d(`IDAT`,t.deflateSync(n)))}let _=0;for(let[e,t]of s.entries()){let n=Buffer.alloc(26);n.writeUInt32BE(_++,0),n.writeUInt32BE(t.width,4),n.writeUInt32BE(t.height,8);let r=Math.max(1,Math.min(1e3,Math.floor(a*1e3/t.delayMs))),i=Math.round(t.delayMs*r/1e3);if(n.writeUInt16BE(Math.min(Math.max(i,1),a),20),n.writeUInt16BE(r,22),n.writeUInt8(1,24),h.push(d(`fcTL`,n)),e===0&&g)h.push(d(`IDAT`,t.imageData));else{let e=Buffer.alloc(4);e.writeUInt32BE(_++),h.push(d(`fdAT`,Buffer.concat([e,t.imageData])))}}return h.push(d(`IEND`,Buffer.alloc(0))),Buffer.concat(h)}function u(e){if(!e.subarray(0,r.length).equals(r))throw Error(`not a PNG image`);let t,n=[],i=[],a=r.length;for(;a+12<=e.length;){let r=e.readUInt32BE(a),o=e.toString(`latin1`,a+4,a+8),s=e.subarray(a+8,a+8+r),c=a+12+r;o===`IHDR`?t=s:o===`IDAT`?i.push(s):i.length===0&&o!==`IEND`&&n.push(e.subarray(a,c)),a=c}if(t?.length!==13||i.length===0)throw Error(`not a PNG image`);return{header:t,metadataChunks:n,imageData:Buffer.concat(i)}}function d(e,n){let r=Buffer.concat([Buffer.from(e,`latin1`),n]),i=Buffer.alloc(r.length+8);return i.writeUInt32BE(n.length,0),r.copy(i,4),i.writeUInt32BE(t.crc32(r),i.length-4),i}export{o as GuiRecorder,n as MAX_GUI_RECORDING_BYTES,l as encodeAnimatedPng};
2
2
  //# sourceMappingURL=guiRecording.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"guiRecording.js","names":["crypto","zlib","MAX_GUI_RECORDING_BYTES","PNG_SIGNATURE","CHANNELS_BY_COLOR_TYPE","MAX_UINT16","GuiRecorder","captures","#windowIdToCaptures","windowId","screenshot","capturedAtMs","digest","changeIndex","#shrinkToFit","frames","sum","frame","framesToAnimatedPng","encode","animatedPng","encodeAnimatedPng","index","lastSeenAtMs","frameIntervalMs","isRecorded","isAnimated","allCaptures","sizes","measure","size","bySizeDescending","b","a","notAnimated","shrinkable","largest","parsedFrames","parsePng","pixelFormat","animationFrames","firstFrame","width","height","header","animationControl","chunks","createChunk","startsWithFirstFrame","bitsPerPixel","blankScanlines","sequenceNumber","frameControl","delayUnitsPerSecond","delay","sequence","png","metadataChunks","imageDataChunks","offset","dataLength","type","data","nextOffset","typeAndData","chunk"],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n const isRecorded = (captures: WindowCaptures): boolean => isAnimated(captures) && captures.frames.length > 1;\n\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"oDAIA,MAAaE,EAA0B,QAuCjCC,EAAgB,OAAO,KAAK,mBAAoB,KAAK,EACrDC,EAAiD,CAAE,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,EAAG,CAAE,EAChFC,EAAa,MASnB,IAAaC,EAAb,KAAyB,CACvB,mBAA+B,IAAI,IAEnC,IAAI,EAAkB,EAA4C,EAA4B,CAC5F,IAAMC,EAAW,KAAKC,GAAoB,IAAIC,CAAQ,GAAK,CACzD,KAAM,GACN,OAAQ,CAAC,EACT,WAAY,GACZ,YAAa,EACb,mBAAoB,EACpB,aAAc,EACd,WAAY,EACd,EACA,KAAKD,GAAoB,IAAIC,EAAUF,CAAQ,EAC/C,EAAS,KAAOG,EAAW,KAAK,QAAQ,SAAU,gBAAgB,EAClE,EAAS,aAAeC,EAExB,IAAMC,EAASZ,EAAO,WAAW,QAAQ,CAAC,CAAC,OAAOU,EAAW,IAAI,CAAC,CAAC,OAAO,QAAQ,EAClF,GAAIH,EAAS,aAAeK,EAAQ,OAEpC,EAAS,WAAaA,EACtB,IAAMC,EAAc,EAAS,cACzBN,EAAS,YAAcM,EAAcN,EAAS,qBAAuB,IAEzEA,EAAS,OAAO,KAAK,CAAE,IAAK,OAAO,KAAKG,EAAW,KAAM,QAAQ,EAAG,cAAa,CAAC,EAClF,KAAKI,IAAc,CAAE,YAAaC,EAAO,QAAQ,EAAK,IAAUC,EAAMC,EAAM,IAAI,OAAQ,CAAC,CAAC,EAC5F,CAGA,MAAM,EAA6C,CACjD,IAAMC,kBAAsB,IAAI,IAC1BC,GAAU,CAAE,SAAQ,kBAA2C,CACnE,IAAMC,EACJF,EAAoB,IAAIH,CAAM,GAC9BM,EACEN,EAAO,KAAK,EAAO,KAAW,CAC5B,IAAKE,EAAM,IACX,SAAUF,EAAOO,EAAQ,EAAE,EAAE,cAAgBC,EAAeC,GAAmBP,EAAM,YACvF,EAAE,CACJ,EAEF,OADAC,EAAoB,IAAIH,EAAQK,CAAW,EACpCA,CACT,EACMK,EAAc,GAAsCC,EAAWnB,CAAQ,GAAKA,EAAS,OAAO,OAAS,EAI3G,OADA,KAAKO,GAAc,GAAcW,EAAWlB,CAAQ,EAAIY,EAAOZ,CAAQ,CAAC,CAAC,OAAS,CAAE,EAC7E,CAAC,GAAG,KAAKC,GAAoB,OAAO,CAAC,CAAC,CAAC,OAAOiB,CAAU,CAAC,CAAC,IAAK,IAAc,CAClF,KAAMlB,EAAS,KACf,KAAMY,EAAOZ,CAAQ,CAAC,CAAC,SAAS,QAAQ,EACxC,SAAU,QACZ,EAAE,CACJ,CAQA,GAAa,EAAqD,CAChE,IAAMoB,EAAc,CAAC,GAAG,KAAKnB,GAAoB,OAAO,CAAC,EACzD,OAAS,CACP,IAAMoB,EAAQ,IAAI,IAAID,EAAY,IAAK,GAAa,CAACpB,EAAUsB,EAAQtB,CAAQ,CAAC,CAAC,CAAC,EAClF,GAAI,CAAC,GAAGqB,EAAM,OAAO,CAAC,CAAC,CAAC,QAAQ,EAAK,IAASZ,EAAMc,EAAM,CAAC,GAAA,QAA8B,OAEzF,IAAMC,EAAmBJ,EACtB,OAAQ,GAAapB,EAAS,OAAO,OAAS,CAAC,CAAC,CAChD,UAAU,EAAG,KAAOqB,EAAM,IAAII,CAAC,GAAK,IAAMJ,EAAM,IAAIK,CAAC,GAAK,EAAE,EAEzD,CAACC,GAAeH,EACnB,OAAQ,GAAa,CAACL,EAAWnB,CAAQ,CAAC,CAAC,CAC3C,UAAU,EAAG,IAAM0B,EAAE,YAAcD,EAAE,WAAW,EAC7CG,EAAaJ,EAAiB,KAAM,GAAaxB,EAAS,OAAO,OAAS,CAAC,EACjF,GAAI2B,EACF,EAAY,OAAS,CAAC,OACjB,GAAIC,EACT,EAAW,OAASA,EAAW,OAAO,QAAQ,EAAG,IAAUb,EAAQ,GAAM,CAAC,EAC1E,EAAW,oBAAsB,MAC5B,CACL,GAAM,CAACc,GAAWL,EAClB,EAAQ,OAAS,CAAC,EAClB,EAAQ,WAAa,EACvB,CACF,CACF,CACF,EAEA,SAASL,EAAW,EAAmC,CACrD,OAAOnB,EAAS,aAAe,CACjC,CAOA,SAAgBc,EAAkB,EAA2C,CAC3E,IAAMgB,EAAetB,EAAO,IAAK,IAAW,CAAE,GAAGuB,EAASrB,EAAM,GAAG,EAAG,QAASA,EAAM,OAAQ,EAAE,EACzFsB,EAAcF,EAAa,EAAE,EAAE,OAAO,SAAS,CAAC,EACtD,GAAI,CAACE,EAAa,MAAU,MAAM,0CAA0C,EAC5E,IAAMC,EAAkBH,EACrB,OAAQ,GAAUpB,EAAM,OAAO,SAAS,CAAC,CAAC,CAAC,OAAOsB,CAAW,CAAC,CAAC,CAC/D,IAAK,IAAW,CAAE,GAAGtB,EAAO,MAAOA,EAAM,OAAO,aAAa,CAAC,EAAG,OAAQA,EAAM,OAAO,aAAa,CAAC,CAAE,EAAE,EACrG,CAACwB,GAAcD,EACfE,EAAQ,KAAK,IAAI,GAAGF,EAAgB,IAAK,GAAUvB,EAAM,KAAK,CAAC,EAC/D0B,EAAS,KAAK,IAAI,GAAGH,EAAgB,IAAK,GAAUvB,EAAM,MAAM,CAAC,EAEjE2B,EAAS,OAAO,KAAKH,EAAW,MAAM,EAC5CG,EAAO,cAAcF,EAAO,CAAC,EAC7BE,EAAO,cAAcD,EAAQ,CAAC,EAC9B,IAAME,EAAmB,OAAO,MAAM,CAAC,EACvCA,EAAiB,cAAcL,EAAgB,OAAQ,CAAC,EACxD,IAAMM,EAAS,CACb3C,EACA4C,EAAY,OAAQH,CAAM,EAC1B,GAAGH,EAAW,eACdM,EAAY,OAAQF,CAAgB,CACtC,EAIMG,EAAuBP,EAAW,QAAUC,GAASD,EAAW,SAAWE,EACjF,GAAI,CAACK,EAAsB,CACzB,IAAMC,EAAgBV,EAAY,IAAiBnC,EAAuBmC,EAAY,KAAiB,GACjGW,EAAiB,OAAO,MAAMP,GAAU,EAAI,KAAK,KAAMD,EAAQO,EAAgB,CAAC,EAAE,EACxFH,EAAO,KAAKC,EAAY,OAAQ9C,EAAK,YAAYiD,CAAc,CAAC,CAAC,CACnE,CAEA,IAAIC,EAAiB,EACrB,IAAK,GAAM,CAAC7B,EAAOL,KAAUuB,EAAgB,QAAQ,EAAG,CACtD,IAAMY,EAAe,OAAO,MAAM,EAAE,EACpCA,EAAa,cAAc,IAAkB,CAAC,EAC9CA,EAAa,cAAcnC,EAAM,MAAO,CAAC,EACzCmC,EAAa,cAAcnC,EAAM,OAAQ,CAAC,EAG1C,IAAMoC,EAAsB,KAAK,IAAI,EAAG,KAAK,IAAI,IAAM,KAAK,MAAOhD,EAAa,IAAQY,EAAM,OAAO,CAAC,CAAC,EACjGqC,EAAQ,KAAK,MAAOrC,EAAM,QAAUoC,EAAuB,GAAI,EAOrE,GANAD,EAAa,cAAc,KAAK,IAAI,KAAK,IAAIE,EAAO,CAAC,EAAGjD,CAAU,EAAG,EAAE,EACvE+C,EAAa,cAAcC,EAAqB,EAAE,EAElDD,EAAa,WAAW,EAAG,EAAE,EAC7BN,EAAO,KAAKC,EAAY,OAAQK,CAAY,CAAC,EAEzC9B,IAAU,GAAK0B,EACjBF,EAAO,KAAKC,EAAY,OAAQ9B,EAAM,SAAS,CAAC,MAC3C,CACL,IAAMsC,EAAW,OAAO,MAAM,CAAC,EAC/BA,EAAS,cAAc,GAAgB,EACvCT,EAAO,KAAKC,EAAY,OAAQ,OAAO,OAAO,CAACQ,EAAUtC,EAAM,SAAS,CAAC,CAAC,CAAC,CAC7E,CACF,CAEA,OADA6B,EAAO,KAAKC,EAAY,OAAQ,OAAO,MAAM,CAAC,CAAC,CAAC,EACzC,OAAO,OAAOD,CAAM,CAC7B,CAEA,SAASR,EAAS,EAAwB,CACxC,GAAI,CAACkB,EAAI,SAAS,EAAGrD,EAAc,MAAM,CAAC,CAAC,OAAOA,CAAa,EAAG,MAAU,MAAM,iBAAiB,EAEnG,IAAIyC,EACEa,EAA2B,CAAC,EAC5BC,EAA4B,CAAC,EAC/BC,EAASxD,EAAc,OAC3B,KAAOwD,EAAS,IAAMH,EAAI,QAAQ,CAChC,IAAMI,EAAaJ,EAAI,aAAaG,CAAM,EACpCE,EAAOL,EAAI,SAAS,SAAUG,EAAS,EAAGA,EAAS,CAAC,EACpDG,EAAON,EAAI,SAASG,EAAS,EAAGA,EAAS,EAAIC,CAAU,EACvDG,EAAaJ,EAAS,GAAKC,EAC7BC,IAAS,OACX,EAASC,EACAD,IAAS,OAClBH,EAAgB,KAAKI,CAAI,EAChBJ,EAAgB,SAAW,GAAKG,IAAS,QAClDJ,EAAe,KAAKD,EAAI,SAASG,EAAQI,CAAU,CAAC,EAEtD,EAASA,CACX,CACA,GAAInB,GAAQ,SAAW,IAAMc,EAAgB,SAAW,EAAG,MAAU,MAAM,iBAAiB,EAC5F,MAAO,CAAE,SAAQ,iBAAgB,UAAW,OAAO,OAAOA,CAAe,CAAE,CAC7E,CAEA,SAASX,EAAY,EAAc,EAAsB,CACvD,IAAMiB,EAAc,OAAO,OAAO,CAAC,OAAO,KAAKH,EAAM,QAAQ,EAAGC,CAAI,CAAC,EAC/DG,EAAQ,OAAO,MAAMD,EAAY,OAAS,CAAC,EAIjD,OAHAC,EAAM,cAAcH,EAAK,OAAQ,CAAC,EAClCE,EAAY,KAAKC,EAAO,CAAC,EACzBA,EAAM,cAAchE,EAAK,MAAM+D,CAAW,EAAGC,EAAM,OAAS,CAAC,EACtDA,CACT"}
1
+ {"version":3,"file":"guiRecording.js","names":["crypto","zlib","MAX_GUI_RECORDING_BYTES","PNG_SIGNATURE","CHANNELS_BY_COLOR_TYPE","MAX_UINT16","GuiRecorder","captures","#windowIdToCaptures","windowId","screenshot","capturedAtMs","digest","changeIndex","#shrinkToFit","frames","sum","frame","framesToAnimatedPng","encode","animatedPng","encodeAnimatedPng","index","lastSeenAtMs","frameIntervalMs","isRecorded","allCaptures","sizes","measure","size","bySizeDescending","b","a","notAnimated","isAnimated","shrinkable","largest","parsedFrames","parsePng","pixelFormat","animationFrames","firstFrame","width","height","header","animationControl","chunks","createChunk","startsWithFirstFrame","bitsPerPixel","blankScanlines","sequenceNumber","frameControl","delayUnitsPerSecond","delay","sequence","png","metadataChunks","imageDataChunks","offset","dataLength","type","data","nextOffset","typeAndData","chunk"],"sources":["../../src/helpers/guiRecording.ts"],"sourcesContent":["import crypto from 'node:crypto';\nimport zlib from 'node:zlib';\n\n/** Bounds the recordings of one GUI run in total, so a judge server can budget for them. */\nexport const MAX_GUI_RECORDING_BYTES = 2 * 1024 * 1024;\n\nexport interface GuiRecordingFile {\n path: string;\n data: string;\n encoding: 'base64';\n}\n\ninterface CapturedFrame {\n png: Buffer;\n capturedAtMs: number;\n}\n\ninterface WindowCaptures {\n path: string;\n /** The captures kept for the recording; none once the window turned out too large to record. */\n frames: CapturedFrame[];\n /** A digest of the last capture, kept or not, to tell whether the window changed. */\n lastDigest: string;\n changeCount: number;\n /** Every how many changes a capture is kept; doubled whenever half of the kept frames are dropped. */\n keptChangeInterval: number;\n lastSeenAtMs: number;\n isTooLarge: boolean;\n}\n\ninterface AnimationFrame {\n png: Buffer;\n delayMs: number;\n}\n\ninterface ParsedPng {\n /** The IHDR data: width, height, and the 5-byte pixel format. */\n header: Buffer;\n /** The chunks between IHDR and the image data (e.g. a palette), as they are in the file. */\n metadataChunks: Buffer[];\n imageData: Buffer;\n}\n\nconst PNG_SIGNATURE = Buffer.from('89504e470d0a1a0a', 'hex');\nconst CHANNELS_BY_COLOR_TYPE: Record<number, number> = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };\nconst MAX_UINT16 = 65_535;\n// A window is typically captured blank once before its first paint, which is not an animation.\nconst MIN_RECORDED_CHANGE_COUNT = 3;\n\n/**\n * Collects the screenshots of each window over a run and turns the windows that kept changing into\n * animated PNGs, which browsers play wherever they show a PNG. The captures it keeps are bounded\n * like the recordings, however long the run lasts and whatever the windows show.\n */\nexport class GuiRecorder {\n readonly #windowIdToCaptures = new Map<string, WindowCaptures>();\n\n add(windowId: string, screenshot: { path: string; data: string }, capturedAtMs: number): void {\n const captures = this.#windowIdToCaptures.get(windowId) ?? {\n path: '',\n frames: [],\n lastDigest: '',\n changeCount: 0,\n keptChangeInterval: 1,\n lastSeenAtMs: 0,\n isTooLarge: false,\n };\n this.#windowIdToCaptures.set(windowId, captures);\n captures.path = screenshot.path.replace(/\\.png$/, '_recording.png');\n captures.lastSeenAtMs = capturedAtMs;\n // An unchanged window only extends how long its last frame is shown.\n const digest = crypto.createHash('sha256').update(screenshot.data).digest('base64');\n if (captures.lastDigest === digest) return;\n\n captures.lastDigest = digest;\n const changeIndex = captures.changeCount++;\n if (captures.isTooLarge || changeIndex % captures.keptChangeInterval !== 0) return;\n\n captures.frames.push({ png: Buffer.from(screenshot.data, 'base64'), capturedAtMs });\n this.#shrinkToFit(({ frames }) => frames.reduce((sum, frame) => sum + frame.png.length, 0));\n }\n\n /** @param frameIntervalMs How long the last capture of a window is shown. */\n build(frameIntervalMs: number): GuiRecordingFile[] {\n const framesToAnimatedPng = new Map<CapturedFrame[], Buffer>();\n const encode = ({ frames, lastSeenAtMs }: WindowCaptures): Buffer => {\n const animatedPng =\n framesToAnimatedPng.get(frames) ??\n encodeAnimatedPng(\n frames.map((frame, index) => ({\n png: frame.png,\n delayMs: (frames[index + 1]?.capturedAtMs ?? lastSeenAtMs + frameIntervalMs) - frame.capturedAtMs,\n }))\n );\n framesToAnimatedPng.set(frames, animatedPng);\n return animatedPng;\n };\n // The chunks an animated PNG adds per frame can exceed what the frames alone were bounded to.\n this.#shrinkToFit((captures) => (isRecorded(captures) ? encode(captures).length : 0));\n return [...this.#windowIdToCaptures.values()].filter(isRecorded).map((captures) => ({\n path: captures.path,\n data: encode(captures).toString('base64'),\n encoding: 'base64',\n }));\n }\n\n /**\n * Frees space until all windows together fit `MAX_GUI_RECORDING_BYTES`, giving up what matters\n * least first: the early captures of a window that is not animated (yet), then every other frame\n * of the largest animated window, and only when no window has more than two frames a whole window,\n * which is then not recorded.\n */\n #shrinkToFit(measure: (captures: WindowCaptures) => number): void {\n const allCaptures = [...this.#windowIdToCaptures.values()];\n for (;;) {\n const sizes = new Map(allCaptures.map((captures) => [captures, measure(captures)]));\n if ([...sizes.values()].reduce((sum, size) => sum + size, 0) <= MAX_GUI_RECORDING_BYTES) return;\n\n const bySizeDescending = allCaptures\n .filter((captures) => captures.frames.length > 0)\n .toSorted((a, b) => (sizes.get(b) ?? 0) - (sizes.get(a) ?? 0));\n // The fewer changes a window showed, the less likely it is the start of an animation.\n const [notAnimated] = bySizeDescending\n .filter((captures) => !isAnimated(captures))\n .toSorted((a, b) => a.changeCount - b.changeCount);\n const shrinkable = bySizeDescending.find((captures) => captures.frames.length > 2);\n if (notAnimated) {\n notAnimated.frames = [];\n } else if (shrinkable) {\n shrinkable.frames = shrinkable.frames.filter((_, index) => index % 2 === 0);\n shrinkable.keptChangeInterval *= 2;\n } else {\n const [largest] = bySizeDescending as [WindowCaptures, ...WindowCaptures[]];\n largest.frames = [];\n largest.isTooLarge = true;\n }\n }\n }\n}\n\nfunction isRecorded(captures: WindowCaptures): boolean {\n return isAnimated(captures) && captures.frames.length > 1;\n}\n\nfunction isAnimated(captures: WindowCaptures): boolean {\n return captures.changeCount >= MIN_RECORDED_CHANGE_COUNT;\n}\n\n/**\n * Assembles PNG images into an animated PNG (APNG) that loops forever. The compressed image data of\n * each frame is reused as is, so the frames must share the pixel format of the first one; frames of\n * another format are left out. A frame smaller than the largest one is drawn at the top-left corner.\n */\nexport function encodeAnimatedPng(frames: readonly AnimationFrame[]): Buffer {\n const parsedFrames = frames.map((frame) => ({ ...parsePng(frame.png), delayMs: frame.delayMs }));\n const pixelFormat = parsedFrames[0]?.header.subarray(8);\n if (!pixelFormat) throw new Error('an animated PNG needs at least one frame');\n const animationFrames = parsedFrames\n .filter((frame) => frame.header.subarray(8).equals(pixelFormat))\n .map((frame) => ({ ...frame, width: frame.header.readUInt32BE(0), height: frame.header.readUInt32BE(4) }));\n const [firstFrame] = animationFrames as [(typeof animationFrames)[number], ...typeof animationFrames];\n const width = Math.max(...animationFrames.map((frame) => frame.width));\n const height = Math.max(...animationFrames.map((frame) => frame.height));\n\n const header = Buffer.from(firstFrame.header);\n header.writeUInt32BE(width, 0);\n header.writeUInt32BE(height, 4);\n const animationControl = Buffer.alloc(8);\n animationControl.writeUInt32BE(animationFrames.length, 0);\n const chunks = [\n PNG_SIGNATURE,\n createChunk('IHDR', header),\n ...firstFrame.metadataChunks,\n createChunk('acTL', animationControl),\n ];\n\n // The image shown by viewers without animation support must fill the canvas. Only a first frame\n // of that size can serve as it; otherwise it is a blank image that is not part of the animation.\n const startsWithFirstFrame = firstFrame.width === width && firstFrame.height === height;\n if (!startsWithFirstFrame) {\n const bitsPerPixel = (pixelFormat[0] as number) * (CHANNELS_BY_COLOR_TYPE[pixelFormat[1] as number] ?? 1);\n const blankScanlines = Buffer.alloc(height * (1 + Math.ceil((width * bitsPerPixel) / 8)));\n chunks.push(createChunk('IDAT', zlib.deflateSync(blankScanlines)));\n }\n\n let sequenceNumber = 0;\n for (const [index, frame] of animationFrames.entries()) {\n const frameControl = Buffer.alloc(26);\n frameControl.writeUInt32BE(sequenceNumber++, 0);\n frameControl.writeUInt32BE(frame.width, 4);\n frameControl.writeUInt32BE(frame.height, 8);\n // The x and y offsets at 12 and 16 stay 0.\n // The delay is a fraction of two 16-bit integers: milliseconds, or the finest unit a longer delay fits in.\n const delayUnitsPerSecond = Math.max(1, Math.min(1000, Math.floor((MAX_UINT16 * 1000) / frame.delayMs)));\n const delay = Math.round((frame.delayMs * delayUnitsPerSecond) / 1000);\n frameControl.writeUInt16BE(Math.min(Math.max(delay, 1), MAX_UINT16), 20);\n frameControl.writeUInt16BE(delayUnitsPerSecond, 22);\n // Every frame is cleared before the next one, so a larger frame does not stay visible around a smaller one.\n frameControl.writeUInt8(1, 24);\n chunks.push(createChunk('fcTL', frameControl));\n\n if (index === 0 && startsWithFirstFrame) {\n chunks.push(createChunk('IDAT', frame.imageData));\n } else {\n const sequence = Buffer.alloc(4);\n sequence.writeUInt32BE(sequenceNumber++);\n chunks.push(createChunk('fdAT', Buffer.concat([sequence, frame.imageData])));\n }\n }\n chunks.push(createChunk('IEND', Buffer.alloc(0)));\n return Buffer.concat(chunks);\n}\n\nfunction parsePng(png: Buffer): ParsedPng {\n if (!png.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) throw new Error('not a PNG image');\n\n let header: Buffer | undefined;\n const metadataChunks: Buffer[] = [];\n const imageDataChunks: Buffer[] = [];\n let offset = PNG_SIGNATURE.length;\n while (offset + 12 <= png.length) {\n const dataLength = png.readUInt32BE(offset);\n const type = png.toString('latin1', offset + 4, offset + 8);\n const data = png.subarray(offset + 8, offset + 8 + dataLength);\n const nextOffset = offset + 12 + dataLength;\n if (type === 'IHDR') {\n header = data;\n } else if (type === 'IDAT') {\n imageDataChunks.push(data);\n } else if (imageDataChunks.length === 0 && type !== 'IEND') {\n metadataChunks.push(png.subarray(offset, nextOffset));\n }\n offset = nextOffset;\n }\n if (header?.length !== 13 || imageDataChunks.length === 0) throw new Error('not a PNG image');\n return { header, metadataChunks, imageData: Buffer.concat(imageDataChunks) };\n}\n\nfunction createChunk(type: string, data: Buffer): Buffer {\n const typeAndData = Buffer.concat([Buffer.from(type, 'latin1'), data]);\n const chunk = Buffer.alloc(typeAndData.length + 8);\n chunk.writeUInt32BE(data.length, 0);\n typeAndData.copy(chunk, 4);\n chunk.writeUInt32BE(zlib.crc32(typeAndData), chunk.length - 4);\n return chunk;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exercode/problem-utils",
3
- "version": "4.3.2",
3
+ "version": "4.3.3",
4
4
  "description": ":100: A set of utilities for judging programs on Exercode (https://exercode.willbooster.com/).",
5
5
  "keywords": [
6
6
  "exercode",
@@ -60,11 +60,11 @@
60
60
  "@types/bun": "1.4.2",
61
61
  "@willbooster/oxfmt-config": "1.2.2",
62
62
  "@willbooster/oxlint-config": "1.4.8",
63
- "@willbooster/wb": "22.23.1",
64
- "build-ts": "21.0.21",
65
- "oxfmt": "0.68.0",
66
- "oxlint": "1.83.0",
67
- "oxlint-tsgolint": "7.0.2002",
63
+ "@willbooster/wb": "22.24.0",
64
+ "build-ts": "21.0.25",
65
+ "oxfmt": "0.71.0",
66
+ "oxlint": "1.86.0",
67
+ "oxlint-tsgolint": "7.0.2003",
68
68
  "sort-package-json": "4.0.0",
69
69
  "typescript": "7.0.2",
70
70
  "undici-types": "8.11.2"