mcap/modules/Image_Decode/module.jai
Musa Mahmood baa188b0e4 [0.0.9] Import: videos and animated GIFs, in .mcap version 4
- Import brings in videos (MP4, MOV, MKV, WebM, AVI, WMV, MPEG, MTS/M2TS, FLV) on Windows, read with
  FFmpeg: the file as it was in the original, a frame a tenth of the way in (at most 3 s), turned
  the right way up, as the rendered image (what the grid, Capture window, OCR and labels see), and
  import.json with Duration, FrameRate, VideoCodec, Rotation, PosterTime, Audio, and the camera,
  date and location from the container. Elsewhere videos are skipped as "video (Windows only)".
- .mcap version 4: the original's size is a u64, so a video can be any size. Versions 1-3 still read.
  mcap_read_without_original and mcap_write_streamed read and rewrite a file with its original left
  on disk; mcap_store, mcap_erase, the import index and the import itself never hold a video in memory.
- Animated GIFs: Frames and Duration in import.json (gif_info).
- Grid: a play mark and duration on video thumbnails, GIF on animations. Capture window: what the
  video is, and Play in the default player (written out to %TEMP%\mcap).
- modules/ffmpeg: FFmpeg 6.0 bindings and the five DLLs used (avformat, avcodec, avutil, swscale,
  swresample), copied beside the executable by the build.
  swscale converts into a buffer of its own allocator with padded rows: an exact-sized one let
  its SIMD write past the end, crashing on frames of odd widths (1916, 1106 ...).
- image-probe shows videos and GIF frame counts; ocr-bench reads a video's frame.
- Tests: new video formats, display-matrix rotation, ISO 6709, video dates, GIF frames, and v4
  serialize/parse, v3 compatibility, streamed writes, store/erase and extraction.

SnagitConverter's copy of Capture_File needs syncing to read version 4 files.
2026-10-09 23:10:55 +01:00

302 lines
13 KiB
Plaintext

// Image_Decode: any image file's bytes to RGBA pixels, the right way up, and the PNG previews .mcap
// files hold. stb_image reads PNG, JPEG, GIF, BMP, PSD and TGA; everything else (HEIC, AVIF, WebP,
// TIFF, camera RAW) goes to Windows' own codecs through WIC, which read what the installed Store
// extensions (HEIF, WebP, Raw Image) can. A photo's EXIF orientation is applied, so a phone's portrait
// photo comes out standing up.
//
// What the bytes are comes from their first bytes (image_kind), never from a file name, except for
// the camera RAW formats that are TIFF inside: image_kind_from_name tells those apart.
//
// Videos (video.jai) are told apart here too, by their first bytes or their extension; video_poster
// reads a frame from one with FFmpeg, on Windows, and gif_info counts an animated GIF's frames.
//
// Thread-safe: every call stands alone, and WIC is set up and released on the calling thread.
Image_Kind :: enum u8 {
UNKNOWN;
PNG; JPEG; GIF; BMP; PSD; TGA; // stb_image
WEBP; HEIF; AVIF; TIFF; RAW; // WIC
VIDEO; // MP4, QuickTime, Matroska and WebM, AVI, WMV, MPEG: see video_poster
}
// Pixels from image_decode: RGBA, 4 bytes a pixel, rows packed. Free with image_free.
Image :: struct {
pixels: *u8;
width, height: s32;
from_stb: bool; // pixels are stb_image's, freed with stbi_image_free; otherwise the default allocator's
}
image_free :: (image: *Image) {
if image.pixels {
if image.from_stb stbi_image_free(image.pixels);
else free(image.pixels,, context.default_allocator);
}
image.* = .{};
}
// What the bytes hold, from their first bytes. A TIFF may be a camera's RAW file; see image_kind_from_name.
image_kind :: (bytes: [] u8) -> Image_Kind {
starts :: (bytes: [] u8, at: s64, magic: string) -> bool {
if bytes.count < at + magic.count return false;
return memcmp(bytes.data + at, magic.data, magic.count) == 0;
}
if starts(bytes, 0, "\x89PNG\r\n\x1A\n") return .PNG;
if starts(bytes, 0, "\xFF\xD8\xFF") return .JPEG;
if starts(bytes, 0, "GIF87a") || starts(bytes, 0, "GIF89a") return .GIF;
if starts(bytes, 0, "BM") && bytes.count > 26 return .BMP;
if starts(bytes, 0, "8BPS") return .PSD;
if starts(bytes, 0, "RIFF") && starts(bytes, 8, "WEBP") return .WEBP;
if starts(bytes, 0, "II*\0") || starts(bytes, 0, "MM\0*") return .TIFF;
if starts(bytes, 0, "IIRO") || starts(bytes, 0, "IIU\0") || starts(bytes, 0, "FUJIFILMCCD-RAW") return .RAW; // Olympus, Panasonic, Fuji
if starts(bytes, 4, "ftyp") {
// ISO base media: HEIF and AVIF images, Canon's CR3, or a video, by the major brand and the
// compatible ones. AVIF first: an AVIF often names the general mif1 brand too.
size := be32(bytes, 0);
end := min(cast(s64) size, bytes.count);
if end < 16 return .UNKNOWN;
found: [#run enum_highest_value(Image_Kind) + 1] bool;
found[ftyp_brand_kind(slice_bytes(bytes, 8, 4))] = true;
at := 16;
while at + 4 <= end {
found[ftyp_brand_kind(slice_bytes(bytes, at, 4))] = true;
at += 4;
}
if found[Image_Kind.AVIF] return .AVIF;
if found[Image_Kind.HEIF] return .HEIF;
if found[Image_Kind.RAW] return .RAW;
return .VIDEO; // an ftyp with no image brand: an MP4, MOV, 3GP and the like
}
if starts(bytes, 4, "moov") || starts(bytes, 4, "mdat") || starts(bytes, 4, "wide") || starts(bytes, 4, "free") return .VIDEO; // old QuickTime
if starts(bytes, 0, "\x1A\x45\xDF\xA3") return .VIDEO; // Matroska and WebM (EBML)
if starts(bytes, 0, "RIFF") && starts(bytes, 8, "AVI ") return .VIDEO;
if starts(bytes, 0, "\x30\x26\xB2\x75\x8E\x66\xCF\x11") return .VIDEO; // ASF: WMV
if starts(bytes, 0, "\x00\x00\x01\xBA") || starts(bytes, 0, "\x00\x00\x01\xB3") return .VIDEO; // MPEG program stream, or bare MPEG video
if starts(bytes, 0, "FLV\x01") return .VIDEO;
if bytes.count >= 189 && bytes[0] == 0x47 && bytes[188] == 0x47 return .VIDEO; // MPEG transport stream: a sync byte every 188
if bytes.count >= 197 && bytes[4] == 0x47 && bytes[196] == 0x47 return .VIDEO; // ... with a 4-byte time code before each (.m2ts, .mts)
return .UNKNOWN;
}
// The container a video is in, for people to read: "MP4", "QuickTime", "WebM" ... from its first
// bytes and, where those are shared (MP4 and QuickTime, Matroska and WebM), its extension.
video_format_name :: (head: [] u8, name: string) -> string {
extension := to_lower_copy(path_extension(name),, temp);
starts :: (bytes: [] u8, at: s64, magic: string) -> bool {
return bytes.count >= at + magic.count && memcmp(bytes.data + at, magic.data, magic.count) == 0;
}
if starts(head, 4, "ftyp") {
if starts(head, 8, "qt ") return "QuickTime";
if starts(head, 8, "3g") return "3GP";
return "MP4";
}
if starts(head, 4, "moov") || starts(head, 4, "mdat") || starts(head, 4, "wide") || starts(head, 4, "free") return "QuickTime";
if starts(head, 0, "\x1A\x45\xDF\xA3") return ifx extension == "webm" then "WebM" else "Matroska";
if starts(head, 0, "RIFF") return "AVI";
if starts(head, 0, "\x30\x26\xB2\x75") return "WMV";
if starts(head, 0, "FLV") return "FLV";
if starts(head, 0, "\x00\x00\x01") return "MPEG";
if head.count && (head[0] == 0x47 || (head.count > 4 && head[4] == 0x47)) return "MPEG-TS";
return "video";
}
// The kind for bytes that image_kind cannot place, or places as TIFF, from the file's name: camera RAW
// formats built on TIFF, and TGA, which has no magic of its own. kind is image_kind's answer.
image_kind_from_name :: (kind: Image_Kind, name: string) -> Image_Kind {
extension := to_lower_copy(path_extension(name),, temp);
if kind == .TIFF {
for RAW_TIFF_EXTENSIONS if extension == it return .RAW;
return .TIFF;
}
if kind == .UNKNOWN && extension == "tga" return .TGA;
return kind;
}
RAW_TIFF_EXTENSIONS :: string.["dng", "cr2", "nef", "nrw", "arw", "srf", "sr2", "pef", "erf", "kdc", "dcr", "mos", "3fr", "iiq", "srw", "x3f", "mrw"];
image_kind_name :: (kind: Image_Kind) -> string {
if kind == {
case .PNG; return "PNG";
case .JPEG; return "JPEG";
case .GIF; return "GIF";
case .BMP; return "BMP";
case .PSD; return "PSD";
case .TGA; return "TGA";
case .WEBP; return "WebP";
case .HEIF; return "HEIF";
case .AVIF; return "AVIF";
case .TIFF; return "TIFF";
case .RAW; return "RAW";
case .VIDEO; return "video";
}
return "unknown";
}
// The image in bytes as RGBA, turned and flipped as its EXIF orientation (or, for HEIF and AVIF, its
// container) says, unless orient is false. kind may be passed when known; image_kind is used otherwise.
// On failure, why.
image_decode :: (bytes: [] u8, kind := Image_Kind.UNKNOWN, orient := true) -> Image, ok: bool, error: string {
image: Image;
if !bytes.count return image, false, "it is empty";
if kind == .UNKNOWN kind = image_kind(bytes);
if kind == .VIDEO return image, false, "it is a video";
stb_kinds :: Image_Kind.[.PNG, .JPEG, .GIF, .BMP, .PSD, .TGA, .UNKNOWN];
decoded := false;
error: string;
if array_find(stb_kinds, kind) && bytes.count < S32_MAX {
w, h, channels: s32;
pixels := stbi_load_from_memory(bytes.data, cast(s32) bytes.count, *w, *h, *channels, 4);
if pixels && w > 0 && h > 0 {
image = .{pixels = pixels, width = w, height = h, from_stb = true};
decoded = true;
} else {
if pixels stbi_image_free(pixels);
error = to_string(stbi_failure_reason());
}
}
if !decoded {
#if OS == .WINDOWS {
wic_ok: bool;
wic_error: string;
image, wic_ok, wic_error = wic_decode(bytes);
if !wic_ok {
if error return image, false, tprint("% (stb_image: %)", wic_error, error);
return image, false, wic_error;
}
} else {
return image, false, ifx error then tprint("stb_image: %", error) else tprint("% images are only read on Windows", image_kind_name(kind));
}
}
if orient {
orientation := 1;
if kind == .HEIF || kind == .AVIF {
// The container's irot and imir are the orientation; the EXIF tag only repeats it. WIC applies
// them (HEIF requires a reader to); a quarter turn it left out shows as the sides not swapped.
heif := heif_info(bytes);
if heif.rotation == 90 || heif.rotation == 270 {
if heif.width && image.width == heif.width && image.height == heif.height {
orientation = ifx heif.rotation == 90 then 8 else 6; // irot turns anticlockwise
}
}
} else {
exif := exif_read(bytes, kind);
orientation = exif.orientation;
}
if orientation > 1 && orientation <= 8 image_orient(*image, orientation);
}
return image, true, "";
}
// Turns and flips the pixels as EXIF orientation 1-8 says, into new pixels from the default allocator.
image_orient :: (image: *Image, orientation: s64) {
if orientation <= 1 || orientation > 8 || !image.pixels return;
w, h := image.width, image.height;
swaps := orientation >= 5;
out_w := ifx swaps then h else w;
out_h := ifx swaps then w else h;
out := cast(*u32) alloc(cast(s64) w * h * 4,, context.default_allocator);
source := cast(*u32) image.pixels;
for dy: 0..out_h - 1 {
row := out + cast(s64) dy * out_w;
for dx: 0..out_w - 1 {
sx, sy: s32;
if orientation == {
case 2; sx, sy = w - 1 - dx, dy; // mirrored left to right
case 3; sx, sy = w - 1 - dx, h - 1 - dy; // half a turn
case 4; sx, sy = dx, h - 1 - dy; // mirrored top to bottom
case 5; sx, sy = dy, dx; // transposed
case 6; sx, sy = dy, h - 1 - dx; // stored a quarter turn anticlockwise: turned clockwise
case 7; sx, sy = w - 1 - dy, h - 1 - dx; // transversed
case 8; sx, sy = w - 1 - dy, dx; // stored a quarter turn clockwise: turned anticlockwise
}
row[dx] = source[cast(s64) sy * w + sx];
}
}
image_free(image);
image.* = .{pixels = cast(*u8) out, width = out_w, height = out_h};
}
// The preview an .mcap holds: the pixels scaled so the longest side is at most max_side, as a PNG
// (temp, or stb's when reused is set: see below). The width and height are the preview's.
image_preview_png :: (image: Image, max_side: s64) -> png: [] u8, width: s64, height: s64, ok: bool, error: string {
w, h := image.width, image.height;
pixels := image.pixels;
longest := max(w, h);
if longest > max_side {
scale := cast(float64) max_side / longest;
out_w := max(cast(s32) 1, cast(s32) (w * scale + 0.5));
out_h := max(cast(s32) 1, cast(s32) (h * scale + 0.5));
scaled := NewArray(cast(s64) out_w * out_h * 4, u8, initialized = false,, temp);
if !stbir_resize_uint8_srgb(pixels, w, h, 0, scaled.data, out_w, out_h, 0, 4, 3, 0) return .[], 0, 0, false, "its image cannot be scaled";
pixels = scaled.data;
w, h = out_w, out_h;
}
length: s32;
encoded := stbi_write_png_to_mem(pixels, w * 4, w, h, 4, *length);
if !encoded return .[], 0, 0, false, "the preview cannot be encoded";
// stb_image_write allocates with the C runtime's malloc, and stb_image frees with its free: both
// libraries are linked against the same runtime.
defer stbi_image_free(encoded);
return cast([] u8) copy_string(string.{length, encoded},, temp), w, h, true, "";
}
// The width and height a PNG says it has, from its header, without decoding it.
png_size :: (png: [] u8) -> width: s64, height: s64, ok: bool {
if png.count < 24 return 0, 0, false;
for u8.[0x89, #char "P", #char "N", #char "G", 0x0D, 0x0A, 0x1A, 0x0A] if png[it_index] != it return 0, 0, false;
return be32(png, 16), be32(png, 20), true;
}
#load "exif.jai";
#load "video.jai";
#if OS == .WINDOWS #load "wic.jai";
#if OS == .WINDOWS #load "video_ffmpeg.jai";
#scope_module
be16 :: (b: [] u8, at: s64) -> u16 {
if at < 0 || at + 2 > b.count return 0;
return ((cast(u16) b[at]) << 8) | (cast(u16) b[at + 1]);
}
be32 :: (b: [] u8, at: s64) -> u32 {
if at < 0 || at + 4 > b.count return 0;
return ((cast(u32) b[at]) << 24) | ((cast(u32) b[at + 1]) << 16) | ((cast(u32) b[at + 2]) << 8) | (cast(u32) b[at + 3]);
}
be64 :: (b: [] u8, at: s64) -> u64 {
return ((cast(u64) be32(b, at)) << 32) | (cast(u64) be32(b, at + 4));
}
le32 :: (b: [] u8, at: s64) -> u32 {
if at < 0 || at + 4 > b.count return 0;
return (cast(u32) b[at]) | ((cast(u32) b[at + 1]) << 8) | ((cast(u32) b[at + 2]) << 16) | ((cast(u32) b[at + 3]) << 24);
}
// count bytes at at, cut short at the end; empty when at is past it.
slice_bytes :: (b: [] u8, at: s64, count: s64) -> [] u8 {
if at < 0 || at >= b.count || count <= 0 return .[];
result: [] u8;
result.data = b.data + at;
result.count = min(count, b.count - at);
return result;
}
ftyp_brand_kind :: (brand: [] u8) -> Image_Kind {
if brand.count != 4 return .UNKNOWN;
text := cast(string) brand;
if text == "avif" || text == "avis" return .AVIF;
if text == "heic" || text == "heix" || text == "hevc" || text == "hevx" || text == "heim" || text == "heis" || text == "mif1" || text == "msf1" return .HEIF;
if text == "crx " return .RAW; // Canon CR3
return .UNKNOWN;
}
#import "Basic";
#import "String";
#import "Math";
#import "stb_image";
#import "stb_image_write";
#import "stb_image_resize";
#if OS == .WINDOWS #import "Windows";