AnimatedImage.lua (13401B)
1 -- AnimatedImage.lua - Animated Image (GIF) Creation and Manipulation Library 2 -- Requires: "imaging" permission 3 4 local AnimatedImage = {} 5 6 -- Global fs override (can be set by app or use sandbox fs) 7 AnimatedImage.fsOverride = nil 8 9 -- Check for imaging permission 10 local function checkPermission() 11 if not ADMIN_HasPermission then 12 error("AnimatedImage library requires permission system, but ADMIN_HasPermission is not available") 13 end 14 15 if not ADMIN_HasPermission("imaging") then 16 error("Permission denied: 'imaging' permission required to use AnimatedImage library") 17 end 18 end 19 20 -- Require Image library for frame management 21 local Image = require("Image") 22 23 -- Helper: Get filesystem to use (fsOverride > sandbox fs > CRamdisk) 24 local function getFS() 25 -- 1. Check for explicit override 26 if AnimatedImage.fsOverride then 27 return AnimatedImage.fsOverride 28 end 29 30 -- 2. Check for sandbox fs 31 if fs then 32 return fs 33 end 34 35 -- 3. Fall back to CRamdisk (if available) 36 return nil 37 end 38 39 -- Helper: Write file using appropriate filesystem 40 local function writeFile(path, data) 41 local filesystem = getFS() 42 43 if filesystem then 44 -- Use SafeFS or custom fs 45 local success, err = filesystem:write(path, data) 46 if not success then 47 return nil, err or ("Failed to write file: " .. path) 48 end 49 return true 50 else 51 -- Fall back to CRamdisk 52 if not CRamdiskOpen or not CRamdiskWrite or not CRamdiskClose then 53 return nil, "No filesystem available (fs not set and CRamdisk functions not available)" 54 end 55 56 local handle = CRamdiskOpen(path, "w") 57 if not handle then 58 return nil, "Failed to open file for writing: " .. path 59 end 60 61 local success = CRamdiskWrite(handle, data) 62 CRamdiskClose(handle) 63 64 if not success then 65 return nil, "Failed to write file: " .. path 66 end 67 68 return true 69 end 70 end 71 72 -- AnimatedImage object 73 local AnimatedImageObj = {} 74 AnimatedImageObj.__index = AnimatedImageObj 75 AnimatedImageObj.__metatable = false -- Prevent metatable access/modification 76 77 -- Create a new animated image 78 -- @param width Frame width in pixels 79 -- @param height Frame height in pixels 80 -- @param numFrames Number of frames (default: 1) 81 -- @param delay Delay between frames in centiseconds (default: 10 = 100ms) 82 -- @return AnimatedImage object 83 function AnimatedImage.new(width, height, numFrames, delay) 84 checkPermission() 85 86 if not width or not height then 87 error("AnimatedImage.new requires width and height") 88 end 89 90 if width <= 0 or width > 4096 then 91 error("AnimatedImage width must be between 1 and 4096") 92 end 93 94 if height <= 0 or height > 4096 then 95 error("AnimatedImage height must be between 1 and 4096") 96 end 97 98 numFrames = numFrames or 1 99 delay = delay or 10 -- 10 centiseconds = 100ms 100 101 if numFrames < 1 or numFrames > 1000 then 102 error("Number of frames must be between 1 and 1000") 103 end 104 105 local self = setmetatable({}, AnimatedImageObj) 106 self.width = width 107 self.height = height 108 self.numFrames = numFrames 109 self.delay = delay 110 self.loop = true -- Loop animation by default 111 self.frames = {} 112 113 -- Create all frames as Image objects 114 for i = 1, numFrames do 115 self.frames[i] = Image.new(width, height, true) 116 end 117 118 return self 119 end 120 121 -- Get a specific frame 122 -- @param frameIndex Frame index (1-based) 123 -- @return Image object 124 function AnimatedImageObj:getFrame(frameIndex) 125 checkPermission() 126 127 if not frameIndex or frameIndex < 1 or frameIndex > self.numFrames then 128 error("Frame index out of bounds: " .. tostring(frameIndex)) 129 end 130 131 return self.frames[frameIndex] 132 end 133 134 -- Set a specific frame 135 -- @param frameIndex Frame index (1-based) 136 -- @param image Image object 137 function AnimatedImageObj:setFrame(frameIndex, image) 138 checkPermission() 139 140 if not frameIndex or frameIndex < 1 or frameIndex > self.numFrames then 141 error("Frame index out of bounds: " .. tostring(frameIndex)) 142 end 143 144 if not image then 145 error("setFrame requires an Image object") 146 end 147 148 if image.width ~= self.width or image.height ~= self.height then 149 error("Frame dimensions must match: " .. self.width .. "x" .. self.height) 150 end 151 152 self.frames[frameIndex] = image 153 end 154 155 -- Fill all frames with a color 156 -- @param color Color in any format accepted by Image 157 function AnimatedImageObj:fillAll(color) 158 checkPermission() 159 160 for i = 1, self.numFrames do 161 self.frames[i]:fill(color) 162 end 163 end 164 165 -- Clone the animated image 166 -- @return New AnimatedImage object 167 function AnimatedImageObj:clone() 168 checkPermission() 169 170 local newAnim = AnimatedImage.new(self.width, self.height, self.numFrames, self.delay) 171 newAnim.loop = self.loop 172 173 for i = 1, self.numFrames do 174 newAnim.frames[i] = self.frames[i]:clone() 175 end 176 177 return newAnim 178 end 179 180 -- Save as animated GIF 181 -- @param path Path to save GIF file 182 -- @param options Optional table with fields: fs (SafeFS instance) 183 -- @return true on success, nil and error message on failure 184 function AnimatedImageObj:saveAsGIF(path, options) 185 checkPermission() 186 187 if not path then 188 error("saveAsGIF requires a file path") 189 end 190 191 options = options or {} 192 local safeFSInstance = options.fs 193 194 -- Build GIF file format 195 local gifData = self:_encodeGIF() 196 197 if not gifData then 198 return nil, "Failed to encode GIF" 199 end 200 201 -- Use provided SafeFS if specified in options 202 if safeFSInstance then 203 local success, err = safeFSInstance:write(path, gifData) 204 if not success then 205 return nil, "SafeFS write failed: " .. (err or "unknown error") 206 end 207 return true 208 end 209 210 -- Otherwise use the standard filesystem (fs or CRamdisk) 211 return writeFile(path, gifData) 212 end 213 214 -- Internal: Encode as GIF format 215 -- @return GIF binary data as string 216 function AnimatedImageObj:_encodeGIF() 217 local parts = {} 218 219 -- GIF Header 220 parts[#parts + 1] = "GIF89a" 221 222 -- Logical Screen Descriptor 223 parts[#parts + 1] = self:_uint16le(self.width) 224 parts[#parts + 1] = self:_uint16le(self.height) 225 226 -- Global Color Table Flag (packed byte) 227 -- 1 bit: GCT flag (1 = has global color table) 228 -- 3 bits: Color resolution (111 = 8 bits) 229 -- 1 bit: Sort flag (0) 230 -- 3 bits: GCT size (111 = 256 colors = 2^(7+1)) 231 local gctFlag = 0xF7 -- 11110111 232 parts[#parts + 1] = string.char(gctFlag) 233 234 -- Background Color Index 235 parts[#parts + 1] = string.char(0) 236 237 -- Pixel Aspect Ratio 238 parts[#parts + 1] = string.char(0) 239 240 -- Global Color Table (256 colors, RGB) 241 -- Simple 256-color palette 242 local palette = self:_createPalette() 243 parts[#parts + 1] = palette 244 245 -- Netscape Application Extension (for looping) 246 if self.loop then 247 parts[#parts + 1] = string.char(0x21, 0xFF) -- Extension introducer + application 248 parts[#parts + 1] = string.char(11) -- Block size 249 parts[#parts + 1] = "NETSCAPE2.0" 250 parts[#parts + 1] = string.char(3) -- Sub-block size 251 parts[#parts + 1] = string.char(1) -- Sub-block ID 252 parts[#parts + 1] = self:_uint16le(0) -- Loop count (0 = infinite) 253 parts[#parts + 1] = string.char(0) -- Block terminator 254 end 255 256 -- Add each frame 257 for i = 1, self.numFrames do 258 parts[#parts + 1] = self:_encodeFrame(i) 259 end 260 261 -- GIF Trailer 262 parts[#parts + 1] = string.char(0x3B) 263 264 return table.concat(parts) 265 end 266 267 -- Internal: Create a 256-color palette 268 -- @return Palette data (256 * 3 bytes) 269 function AnimatedImageObj:_createPalette() 270 local palette = {} 271 272 -- Create a simple 256-color palette (6x6x6 color cube + grays) 273 for i = 0, 215 do 274 local r = math.floor(i / 36) * 51 275 local g = math.floor((i % 36) / 6) * 51 276 local b = (i % 6) * 51 277 palette[#palette + 1] = string.char(r, g, b) 278 end 279 280 -- Add 40 grayscale colors 281 for i = 216, 255 do 282 local gray = math.floor((i - 216) * 255 / 39) 283 palette[#palette + 1] = string.char(gray, gray, gray) 284 end 285 286 return table.concat(palette) 287 end 288 289 -- Internal: Find closest palette index for RGB color 290 -- @param r Red (0-255) 291 -- @param g Green (0-255) 292 -- @param b Blue (0-255) 293 -- @return Palette index (0-255) 294 function AnimatedImageObj:_findPaletteIndex(r, g, b) 295 -- Simple quantization to 6x6x6 color cube 296 local rIndex = math.floor(r / 51) 297 local gIndex = math.floor(g / 51) 298 local bIndex = math.floor(b / 51) 299 300 if rIndex > 5 then rIndex = 5 end 301 if gIndex > 5 then gIndex = 5 end 302 if bIndex > 5 then bIndex = 5 end 303 304 return rIndex * 36 + gIndex * 6 + bIndex 305 end 306 307 -- Internal: Encode a single frame 308 -- @param frameIndex Frame index (1-based) 309 -- @return Frame data as string 310 function AnimatedImageObj:_encodeFrame(frameIndex) 311 local parts = {} 312 313 -- Graphic Control Extension 314 parts[#parts + 1] = string.char(0x21, 0xF9) -- Extension introducer + GCE label 315 parts[#parts + 1] = string.char(4) -- Block size 316 317 -- Packed byte: disposal method, user input, transparency 318 -- 000 00 0 0 0 319 -- 3 bits: disposal (0 = no disposal) 320 -- 1 bit: user input (0) 321 -- 1 bit: transparent color flag (0 = no transparency) 322 local packed = 0x00 323 parts[#parts + 1] = string.char(packed) 324 325 -- Delay time (in centiseconds) 326 parts[#parts + 1] = self:_uint16le(self.delay) 327 328 -- Transparent color index 329 parts[#parts + 1] = string.char(0) 330 331 -- Block terminator 332 parts[#parts + 1] = string.char(0) 333 334 -- Image Descriptor 335 parts[#parts + 1] = string.char(0x2C) -- Image separator 336 337 -- Image position and dimensions 338 parts[#parts + 1] = self:_uint16le(0) -- Left 339 parts[#parts + 1] = self:_uint16le(0) -- Top 340 parts[#parts + 1] = self:_uint16le(self.width) 341 parts[#parts + 1] = self:_uint16le(self.height) 342 343 -- Packed byte: local color table, interlace, sort, reserved, LCT size 344 -- 0 0 0 00 000 345 local packed2 = 0x00 -- No local color table 346 parts[#parts + 1] = string.char(packed2) 347 348 -- Image Data (LZW compressed) 349 local imageData = self:_encodeImageData(frameIndex) 350 parts[#parts + 1] = imageData 351 352 return table.concat(parts) 353 end 354 355 -- Internal: Encode image data for a frame (LZW) 356 -- @param frameIndex Frame index 357 -- @return LZW compressed data 358 function AnimatedImageObj:_encodeImageData(frameIndex) 359 local frame = self.frames[frameIndex] 360 361 -- Convert frame to palette indices 362 local indices = {} 363 for y = 0, self.height - 1 do 364 for x = 0, self.width - 1 do 365 local pixel = frame:getPixel(x, y) 366 local index = self:_findPaletteIndex(pixel.r, pixel.g, pixel.b) 367 indices[#indices + 1] = index 368 end 369 end 370 371 -- LZW encode the indices 372 local lzwData = self:_lzwEncode(indices) 373 374 return lzwData 375 end 376 377 -- Internal: Simple LZW compression for GIF 378 -- @param data Array of byte values 379 -- @return LZW compressed data with sub-blocks 380 function AnimatedImageObj:_lzwEncode(data) 381 local parts = {} 382 383 -- LZW minimum code size 384 local minCodeSize = 8 385 parts[#parts + 1] = string.char(minCodeSize) 386 387 -- For simplicity, use uncompressed LZW (just output literal codes) 388 -- A full LZW implementation would build a dictionary and compress 389 390 local clearCode = 256 391 local endCode = 257 392 local nextCode = 258 393 394 local output = {} 395 396 -- Output clear code 397 output[#output + 1] = clearCode 398 399 -- Output all data as literals 400 for i = 1, #data do 401 output[#output + 1] = data[i] 402 end 403 404 -- Output end code 405 output[#output + 1] = endCode 406 407 -- Pack codes into bytes (9-bit codes) 408 local bitBuffer = 0 409 local bitCount = 0 410 local bytes = {} 411 412 for i = 1, #output do 413 local code = output[i] 414 bitBuffer = bit.bor(bitBuffer, bit.lshift(code, bitCount)) 415 bitCount = bitCount + 9 416 417 while bitCount >= 8 do 418 bytes[#bytes + 1] = string.char(bit.band(bitBuffer, 0xFF)) 419 bitBuffer = bit.rshift(bitBuffer, 8) 420 bitCount = bitCount - 8 421 end 422 end 423 424 -- Flush remaining bits 425 if bitCount > 0 then 426 bytes[#bytes + 1] = string.char(bit.band(bitBuffer, 0xFF)) 427 end 428 429 -- Split into sub-blocks (max 255 bytes each) 430 local pos = 1 431 while pos <= #bytes do 432 local blockSize = math.min(255, #bytes - pos + 1) 433 parts[#parts + 1] = string.char(blockSize) 434 435 for i = pos, pos + blockSize - 1 do 436 parts[#parts + 1] = bytes[i] 437 end 438 439 pos = pos + blockSize 440 end 441 442 -- Block terminator 443 parts[#parts + 1] = string.char(0) 444 445 return table.concat(parts) 446 end 447 448 -- Internal: Encode 16-bit unsigned integer as little-endian 449 -- @param value Integer value 450 -- @return 2-byte little-endian string 451 function AnimatedImageObj:_uint16le(value) 452 return string.char( 453 bit.band(value, 0xFF), 454 bit.band(bit.rshift(value, 8), 0xFF) 455 ) 456 end 457 458 -- Open animated GIF file 459 -- @param path Path to GIF file 460 -- @return AnimatedImage object or nil on error 461 function AnimatedImage.open(path) 462 checkPermission() 463 464 -- For now, return error - GIF decoding is complex 465 -- Full implementation would parse GIF format 466 return nil, "GIF decoding not yet implemented" 467 end 468 469 return AnimatedImage