luajitos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

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