#include "export.hpp" #include #include #include #include #include #include #include #include #include #include #include #include namespace sppm { namespace { using Bytes = std::vector; void be32(Bytes& out, std::uint32_t value) { out.push_back(static_cast(value >> 24)); out.push_back(static_cast(value >> 16)); out.push_back(static_cast(value >> 8)); out.push_back(static_cast(value)); } void be16(Bytes& out, std::uint16_t value) { out.push_back(static_cast(value >> 8)); out.push_back(static_cast(value)); } void type(Bytes& out, const char* value) { out.insert(out.end(), value, value + 4); } std::size_t boxBegin(Bytes& out, const char* name) { const std::size_t start = out.size(); be32(out, 0); type(out, name); return start; } void boxEnd(Bytes& out, std::size_t start) { const std::size_t size = out.size() - start; out[start] = static_cast(size >> 24); out[start + 1] = static_cast(size >> 16); out[start + 2] = static_cast(size >> 8); out[start + 3] = static_cast(size); } std::size_t fullBoxBegin(Bytes& out, const char* name, std::uint8_t version = 0, std::uint32_t flags = 0) { const auto start = boxBegin(out, name); out.push_back(version); out.push_back(static_cast(flags >> 16)); out.push_back(static_cast(flags >> 8)); out.push_back(static_cast(flags)); return start; } Bytes mp4MovieBox(const std::vector& sizes, const std::vector& offsets, double fps) { constexpr std::uint32_t timeScale = 1000000; const std::uint32_t frameDelta = static_cast(std::lround(timeScale / fps)); const std::uint32_t duration = static_cast(std::llround(sizes.size() * static_cast(timeScale) / fps)); Bytes out; auto moov = boxBegin(out, "moov"); auto mvhd = fullBoxBegin(out, "mvhd"); be32(out, 0); be32(out, 0); be32(out, timeScale); be32(out, duration); be32(out, 0x00010000); be16(out, 0x0100); be16(out, 0); be32(out, 0); be32(out, 0); const std::uint32_t matrix[] = {0x00010000,0,0, 0,0x00010000,0, 0,0,0x40000000}; for (auto value : matrix) be32(out, value); for (int i = 0; i < 6; ++i) be32(out, 0); be32(out, 2); boxEnd(out, mvhd); auto trak = boxBegin(out, "trak"); auto tkhd = fullBoxBegin(out, "tkhd", 0, 7); be32(out, 0); be32(out, 0); be32(out, 1); be32(out, 0); be32(out, duration); be32(out, 0); be32(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); for (auto value : matrix) be32(out, value); be32(out, static_cast(kWidth) << 16); be32(out, static_cast(kHeight) << 16); boxEnd(out, tkhd); auto mdia = boxBegin(out, "mdia"); auto mdhd = fullBoxBegin(out, "mdhd"); be32(out, 0); be32(out, 0); be32(out, timeScale); be32(out, duration); be16(out, 0x55c4); be16(out, 0); boxEnd(out, mdhd); auto hdlr = fullBoxBegin(out, "hdlr"); be32(out, 0); type(out, "vide"); be32(out, 0); be32(out, 0); be32(out, 0); out.insert(out.end(), {'S','w','i','t','c','h','n','o','t','e',' ','V','i','d','e','o',0}); boxEnd(out, hdlr); auto minf = boxBegin(out, "minf"); auto vmhd = fullBoxBegin(out, "vmhd", 0, 1); be16(out, 0); be16(out, 0); be16(out, 0); be16(out, 0); boxEnd(out, vmhd); auto dinf = boxBegin(out, "dinf"); auto dref = fullBoxBegin(out, "dref"); be32(out, 1); auto url = fullBoxBegin(out, "url ", 0, 1); boxEnd(out, url); boxEnd(out, dref); boxEnd(out, dinf); auto stbl = boxBegin(out, "stbl"); auto stsd = fullBoxBegin(out, "stsd"); be32(out, 1); auto jpeg = boxBegin(out, "mjpg"); be32(out, 0); be16(out, 0); be16(out, 1); be16(out, 0); be16(out, 0); be32(out, 0); be32(out, 0); be32(out, 0); be16(out, kWidth); be16(out, kHeight); be32(out, 0x00480000); be32(out, 0x00480000); be32(out, 0); be16(out, 1); out.insert(out.end(), 32, 0); be16(out, 0x0018); be16(out, 0xffff); boxEnd(out, jpeg); boxEnd(out, stsd); auto stts = fullBoxBegin(out, "stts"); be32(out, 1); be32(out, static_cast(sizes.size())); be32(out, frameDelta); boxEnd(out, stts); auto stsc = fullBoxBegin(out, "stsc"); be32(out, 1); be32(out, 1); be32(out, 1); be32(out, 1); boxEnd(out, stsc); auto stsz = fullBoxBegin(out, "stsz"); be32(out, 0); be32(out, static_cast(sizes.size())); for (auto size : sizes) be32(out, size); boxEnd(out, stsz); auto stco = fullBoxBegin(out, "stco"); be32(out, static_cast(offsets.size())); for (auto offset : offsets) be32(out, offset); boxEnd(out, stco); auto stss = fullBoxBegin(out, "stss"); be32(out, static_cast(sizes.size())); for (std::size_t i = 0; i < sizes.size(); ++i) be32(out, static_cast(i + 1)); boxEnd(out, stss); boxEnd(out, stbl); boxEnd(out, minf); boxEnd(out, mdia); boxEnd(out, trak); boxEnd(out, moov); return out; } Color opaqueFramePixel(const Frame& frame, const Palette& palette, std::size_t pixel) { Color result{255, 255, 255, 255}; for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) { const auto index = frame.layers[layerIndex][pixel]; if (!index) continue; const auto& source = palette[index]; if (!source.a) continue; const unsigned alpha = source.a; result.r = static_cast((source.r * alpha + result.r * (255 - alpha) + 127) / 255); result.g = static_cast((source.g * alpha + result.g * (255 - alpha) + 127) / 255); result.b = static_cast((source.b * alpha + result.b * (255 - alpha) + 127) / 255); } return result; } bool checkDocument(const Document& document, bool cover, std::string& error) { if (document.frames.empty() || document.frames.size() > kMaxFrames) { error = "frame count must be between 1 and 9999"; return false; } if (document.metadata.fps < 1 || document.metadata.fps > 30) { error = "frame rate must be between 1 and 30 fps"; return false; } if (cover && document.metadata.coverFrame >= document.frames.size()) { error = "cover frame is out of range"; return false; } for (const Frame& frame : document.frames) { for (const auto& layer : frame.layers) { if (layer.size() != kPixelsPerLayer) { error = "a layer has the wrong pixel count"; return false; } } } error.clear(); return true; } // Straight-alpha source-over compositing, with A foremost and D rearmost. Color compositePixel(const Frame& frame, const Palette& palette, std::size_t pixel) { Color result{0, 0, 0, 0}; for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) { const auto& layer = frame.layers[layerIndex]; const std::uint8_t index = layer[pixel]; if (index == 0) continue; const Color& source = palette[index]; if (source.a == 0) continue; if (source.a == 255) { result = source; continue; } const std::uint32_t sourceWeight = static_cast(source.a) * 255U; const std::uint32_t behindWeight = static_cast(result.a) * (255U - source.a); const std::uint32_t total = sourceWeight + behindWeight; result.r = static_cast( (source.r * sourceWeight + result.r * behindWeight + total / 2U) / total); result.g = static_cast( (source.g * sourceWeight + result.g * behindWeight + total / 2U) / total); result.b = static_cast( (source.b * sourceWeight + result.b * behindWeight + total / 2U) / total); result.a = static_cast((total + 127U) / 255U); } return result; } std::uint8_t nearestPaletteIndex(const Color& color, const Palette& palette) { std::uint32_t bestDistance = 3U * 255U * 255U + 1U; std::uint8_t best = 0; for (std::size_t i = 1; i < palette.size(); ++i) { const Color& candidate = palette[i]; if (candidate.a == 0) continue; const int dr = static_cast(color.r) - candidate.r; const int dg = static_cast(color.g) - candidate.g; const int db = static_cast(color.b) - candidate.b; const std::uint32_t distance = static_cast(dr * dr + dg * dg + db * db); if (distance < bestDistance) { bestDistance = distance; best = static_cast(i); if (distance == 0) break; } } return best; } // GIF has a single transparent index, so partially transparent colors are // composited first, then matched to the nearest RGB color in the palette. std::vector gifPixels(const Frame& frame, const Palette& palette) { std::vector pixels(kPixelsPerLayer, 0); bool hasPartialAlpha = false; for (std::size_t i = 1; i < palette.size(); ++i) { if (palette[i].a != 0 && palette[i].a != 255) { hasPartialAlpha = true; break; } } if (!hasPartialAlpha) { for (std::size_t layerIndex = frame.layers.size(); layerIndex-- > 0;) { const auto& layer = frame.layers[layerIndex]; for (std::size_t pixel = 0; pixel < pixels.size(); ++pixel) { const std::uint8_t index = layer[pixel]; if (index != 0 && palette[index].a != 0) pixels[pixel] = index; } } return pixels; } std::unordered_map matchCache; matchCache.reserve(512); for (std::size_t pixel = 0; pixel < pixels.size(); ++pixel) { const Color color = compositePixel(frame, palette, pixel); if (color.a < 128) continue; const std::uint32_t key = (static_cast(color.r) << 16U) | (static_cast(color.g) << 8U) | color.b; const auto found = matchCache.find(key); if (found != matchCache.end()) { pixels[pixel] = found->second; } else { const std::uint8_t index = nearestPaletteIndex(color, palette); matchCache.emplace(key, index); pixels[pixel] = index; } } return pixels; } struct BitPacker { std::vector data; std::uint32_t pending = 0; unsigned pendingBits = 0; void code(std::uint16_t value, unsigned width) { pending |= static_cast(value) << pendingBits; pendingBits += width; while (pendingBits >= 8) { data.push_back(static_cast(pending & 0xFFU)); pending >>= 8U; pendingBits -= 8U; } } void finish() { if (pendingBits != 0) data.push_back(static_cast(pending)); } }; std::vector gifLzw(const std::vector& pixels) { constexpr std::uint16_t clearCode = 256; constexpr std::uint16_t endCode = 257; std::unordered_map dictionary; dictionary.reserve(4096); std::uint16_t nextCode = 258; unsigned codeWidth = 9; BitPacker writer; writer.data.reserve(pixels.size() / 2); writer.code(clearCode, codeWidth); std::uint16_t prefix = pixels.front(); for (std::size_t i = 1; i < pixels.size(); ++i) { const std::uint8_t suffix = pixels[i]; const std::uint32_t key = (static_cast(prefix) << 8U) | suffix; const auto found = dictionary.find(key); if (found != dictionary.end()) { prefix = found->second; continue; } writer.code(prefix, codeWidth); if (nextCode < 4096) { dictionary.emplace(key, nextCode++); // GIF's decoder learns each new phrase one code later. if (codeWidth < 12 && nextCode > (1U << codeWidth)) ++codeWidth; } else { writer.code(clearCode, codeWidth); dictionary.clear(); nextCode = 258; codeWidth = 9; } prefix = suffix; } writer.code(prefix, codeWidth); writer.code(endCode, codeWidth); writer.finish(); return writer.data; } void byte(std::ostream& out, std::uint8_t value) { out.put(static_cast(value)); } void le16(std::ostream& out, std::uint16_t value) { byte(out, static_cast(value)); byte(out, static_cast(value >> 8U)); } void be32(std::ostream& out, std::uint32_t value) { byte(out, static_cast(value >> 24U)); byte(out, static_cast(value >> 16U)); byte(out, static_cast(value >> 8U)); byte(out, static_cast(value)); } std::uint32_t crc32(const char* type, const std::vector& data) { std::uint32_t crc = 0xFFFFFFFFU; auto update = [&crc](std::uint8_t value) { crc ^= value; for (unsigned bit = 0; bit < 8; ++bit) { crc = (crc >> 1U) ^ ((crc & 1U) ? 0xEDB88320U : 0U); } }; for (int i = 0; i < 4; ++i) update(static_cast(type[i])); for (std::uint8_t value : data) update(value); return crc ^ 0xFFFFFFFFU; } void pngChunk(std::ostream& out, const char* type, const std::vector& data) { be32(out, static_cast(data.size())); out.write(type, 4); if (!data.empty()) { out.write(reinterpret_cast(data.data()), static_cast(data.size())); } be32(out, crc32(type, data)); } std::vector zlibStored(const std::vector& raw) { std::vector encoded; encoded.reserve(raw.size() + 6 + (raw.size() / 65535 + 1) * 5); encoded.push_back(0x78); // zlib CMF: deflate, 32 KiB window encoded.push_back(0x01); // zlib FLG: no compression std::size_t offset = 0; while (offset < raw.size()) { const std::size_t length = std::min(65535, raw.size() - offset); const bool final = offset + length == raw.size(); const std::uint16_t len = static_cast(length); const std::uint16_t inverse = static_cast(~len); encoded.push_back(final ? 0x01 : 0x00); encoded.push_back(static_cast(len)); encoded.push_back(static_cast(len >> 8U)); encoded.push_back(static_cast(inverse)); encoded.push_back(static_cast(inverse >> 8U)); encoded.insert(encoded.end(), raw.begin() + static_cast(offset), raw.begin() + static_cast(offset + length)); offset += length; } std::uint32_t a = 1; std::uint32_t b = 0; for (std::uint8_t value : raw) { a = (a + value) % 65521U; b = (b + a) % 65521U; } const std::uint32_t adler = (b << 16U) | a; encoded.push_back(static_cast(adler >> 24U)); encoded.push_back(static_cast(adler >> 16U)); encoded.push_back(static_cast(adler >> 8U)); encoded.push_back(static_cast(adler)); return encoded; } } // namespace bool exportGif(const Document& document, const std::string& path, std::string& error) { if (!checkDocument(document, false, error)) return false; if (path.empty()) { error = "GIF output path is empty"; return false; } std::ofstream out(path, std::ios::binary | std::ios::trunc); if (!out) { error = "could not open GIF output"; return false; } out.write("GIF89a", 6); le16(out, kWidth); le16(out, kHeight); byte(out, 0xF7); // global 256-color table, 8-bit color resolution byte(out, 1); // white in the default palette as the canvas background byte(out, 0); for (const Color& color : document.palette) { byte(out, color.r); byte(out, color.g); byte(out, color.b); } // Netscape application extension: loop indefinitely. const std::array loop = { 0x21, 0xFF, 0x0B, 'N', 'E', 'T', 'S', 'C', 'A', 'P', 'E', '2', '.', '0', 0x03, 0x01, 0x00, 0x00, 0x00}; out.write(reinterpret_cast(loop.data()), static_cast(loop.size())); for (std::size_t i = 0; i < document.frames.size(); ++i) { const double fps = frameRate(document.metadata); const unsigned start = static_cast(std::llround(i * 100.0 / fps)); const unsigned end = static_cast(std::llround((i + 1U) * 100.0 / fps)); const std::uint16_t delay = static_cast(std::max(1U, end - start)); byte(out, 0x21); byte(out, 0xF9); byte(out, 0x04); byte(out, 0x09); // restore background before next frame, index 0 transparent le16(out, delay); byte(out, 0); byte(out, 0); byte(out, 0x2C); // full-canvas image descriptor le16(out, 0); le16(out, 0); le16(out, kWidth); le16(out, kHeight); byte(out, 0); // use global palette byte(out, 8); // LZW minimum code size const std::vector pixels = gifPixels(document.frames[i], document.palette); const std::vector compressed = gifLzw(pixels); for (std::size_t offset = 0; offset < compressed.size();) { const std::size_t length = std::min(255, compressed.size() - offset); byte(out, static_cast(length)); out.write(reinterpret_cast(compressed.data() + offset), static_cast(length)); offset += length; } byte(out, 0); // terminate LZW sub-blocks if (!out) { error = "could not write GIF frame"; return false; } } byte(out, 0x3B); out.flush(); if (!out) { error = "could not finish GIF output"; return false; } error.clear(); return true; } bool exportPngCover(const Document& document, const std::string& path, std::string& error) { if (!checkDocument(document, true, error)) return false; if (path.empty()) { error = "PNG output path is empty"; return false; } const Frame& frame = document.frames[document.metadata.coverFrame]; std::vector raw; raw.reserve(static_cast(kHeight) * (1U + 4U * kWidth)); for (std::size_t y = 0; y < kHeight; ++y) { raw.push_back(0); // PNG filter type 0 for (std::size_t x = 0; x < kWidth; ++x) { const Color pixel = compositePixel(frame, document.palette, y * kWidth + x); raw.push_back(pixel.r); raw.push_back(pixel.g); raw.push_back(pixel.b); raw.push_back(pixel.a); } } std::ofstream out(path, std::ios::binary | std::ios::trunc); if (!out) { error = "could not open PNG output"; return false; } constexpr std::array signature = {137, 80, 78, 71, 13, 10, 26, 10}; out.write(reinterpret_cast(signature.data()), static_cast(signature.size())); const std::vector header = { static_cast(kWidth >> 24U), static_cast(kWidth >> 16U), static_cast(kWidth >> 8U), static_cast(kWidth), static_cast(kHeight >> 24U), static_cast(kHeight >> 16U), static_cast(kHeight >> 8U), static_cast(kHeight), 8, 6, 0, 0, 0}; // RGBA, 8 bits/channel, no interlace pngChunk(out, "IHDR", header); pngChunk(out, "IDAT", zlibStored(raw)); pngChunk(out, "IEND", {}); out.flush(); if (!out) { error = "could not finish PNG output"; return false; } error.clear(); return true; } bool exportMp4(const Document& document, const std::string& path, std::string& error) { if (!checkDocument(document, false, error)) return false; if (path.empty()) { error = "MP4 output path is empty"; return false; } const double fps = frameRate(document.metadata); if (!(fps > 0.0 && fps <= 30.0)) { error = "frame rate must be between 0 and 30 fps"; return false; } Bytes ftyp; auto ftypBox = boxBegin(ftyp, "ftyp"); type(ftyp, "isom"); be32(ftyp, 0x00000200); type(ftyp, "isom"); type(ftyp, "iso2"); type(ftyp, "mp41"); type(ftyp, "mjpg"); boxEnd(ftyp, ftypBox); std::ofstream out(path, std::ios::binary | std::ios::trunc); if (!out) { error = "could not create MP4 file"; return false; } out.write(reinterpret_cast(ftyp.data()), static_cast(ftyp.size())); const std::uint32_t mdatStart = static_cast(ftyp.size()); const std::uint8_t mdatHeader[8] = {0,0,0,0,'m','d','a','t'}; out.write(reinterpret_cast(mdatHeader), sizeof(mdatHeader)); std::vector sampleSizes; std::vector sampleOffsets; sampleSizes.reserve(document.frames.size()); sampleOffsets.reserve(document.frames.size()); constexpr int jpegCapacity = 2 * 1024 * 1024; std::vector jpegBuffer(static_cast(jpegCapacity)); for (std::size_t frameIndex = 0; frameIndex < document.frames.size(); ++frameIndex) { SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormat(0, kWidth, kHeight, 32, SDL_PIXELFORMAT_RGBA32); if (!surface) { error = "could not allocate frame image"; return false; } if (SDL_LockSurface(surface) != 0) { SDL_FreeSurface(surface); error = "could not prepare frame image"; return false; } auto* rows = static_cast(surface->pixels); for (std::size_t y = 0; y < kHeight; ++y) { auto* row = reinterpret_cast(rows + y * static_cast(surface->pitch)); for (std::size_t x = 0; x < kWidth; ++x) { const Color pixel = opaqueFramePixel(document.frames[frameIndex], document.palette, y * kWidth + x); row[x] = SDL_MapRGBA(surface->format, pixel.r, pixel.g, pixel.b, 255); } } SDL_UnlockSurface(surface); SDL_RWops* jpegStream = SDL_RWFromMem(jpegBuffer.data(), jpegCapacity); if (!jpegStream || IMG_SaveJPG_RW(surface, jpegStream, 0, 82) != 0) { if (jpegStream) SDL_RWclose(jpegStream); SDL_FreeSurface(surface); error = "could not encode an MP4 frame as JPEG"; return false; } const Sint64 jpegSize = SDL_RWtell(jpegStream); SDL_RWclose(jpegStream); SDL_FreeSurface(surface); if (jpegSize <= 0 || jpegSize > jpegCapacity) { error = "encoded frame is too large"; return false; } const auto position = out.tellp(); if (position < 0 || static_cast(position) > UINT32_MAX) { error = "MP4 exceeds the supported 4 GB file limit"; return false; } sampleOffsets.push_back(static_cast(position)); sampleSizes.push_back(static_cast(jpegSize)); out.write(reinterpret_cast(jpegBuffer.data()), jpegSize); if (!out) { error = "could not write MP4 frame data"; return false; } } const auto endOfMedia = out.tellp(); if (endOfMedia < 0 || static_cast(endOfMedia) - mdatStart > UINT32_MAX) { error = "MP4 exceeds the supported 4 GB file limit"; return false; } const std::uint32_t mdatSize = static_cast(static_cast(endOfMedia) - mdatStart); out.seekp(mdatStart); const std::uint8_t encodedMdatSize[4] = { static_cast(mdatSize >> 24), static_cast(mdatSize >> 16), static_cast(mdatSize >> 8), static_cast(mdatSize)}; out.write(reinterpret_cast(encodedMdatSize), sizeof(encodedMdatSize)); out.seekp(endOfMedia); const Bytes moov = mp4MovieBox(sampleSizes, sampleOffsets, fps); out.write(reinterpret_cast(moov.data()), static_cast(moov.size())); out.flush(); if (!out) { error = "could not finish MP4 index"; return false; } out.close(); error.clear(); return true; } } // namespace sppm