mcap/modules/Image_Decode/video.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

112 lines
5.0 KiB
Plaintext

// Videos and animations: a video's poster frame and what its container says (FFmpeg, modules/ffmpeg,
// on Windows), and how many frames an animated GIF has. A video is read from its file, a little at a
// time, never all in memory.
// What a video is, as video_poster finds it. The strings are temp.
Video_Info :: struct {
width, height: s32; // as shown: turned, and stretched by a non-square pixel shape
duration: float64; // seconds; 0 when unknown
frame_rate: float64; // frames a second; 0 when unknown
codec: string; // "h264", "hevc", "vp9" ...
rotation: s32; // degrees clockwise the frames are turned to be shown: 0, 90, 180 or 270
poster_time: float64; // seconds into the video of the poster frame
has_audio: bool;
// From the container, as written; "" when it has none.
creation_date: string; // Apple's com.apple.quicktime.creationdate: local time and its offset, "2024-05-12T10:15:30+0100"
creation_time: string; // creation_time: UTC, "2024-05-12T09:15:30.000000Z"
make, model, software: string;
location: string; // ISO 6709, "+51.5072-000.1276+011.000/"; see iso6709_parse
}
// The frame a video is shown as, turned the right way up, and what it is. The frame is a tenth of
// the way in (at most VIDEO_POSTER_MAX_SECONDS), past the black or blurred first frames phones often
// start with; the first frame of a clip shorter than a second.
VIDEO_POSTER_MAX_SECONDS :: 3.0;
#if OS != .WINDOWS {
video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string {
return .{}, .{}, false, "videos are only read on Windows";
}
}
// Degrees clockwise to turn a video's frames, from its display matrix (as ffmpeg's
// av_display_rotation_get, whose angle is anticlockwise): 0, 90, 180 or 270.
display_matrix_rotation :: (m: [9] s32) -> s32 {
fixed :: (v: s32) -> float64 { return cast(float64) v / 65536.0; }
scale0 := sqrt(fixed(m[0]) * fixed(m[0]) + fixed(m[3]) * fixed(m[3]));
scale1 := sqrt(fixed(m[1]) * fixed(m[1]) + fixed(m[4]) * fixed(m[4]));
if scale0 == 0 || scale1 == 0 return 0;
anticlockwise := -atan2(fixed(m[1]) / scale1, fixed(m[0]) / scale0) * 180 / PI;
clockwise := cast(s32) floor(-anticlockwise / 90 + 0.5) * 90;
clockwise %= 360;
if clockwise < 0 clockwise += 360;
return clockwise;
}
// "+51.5072-000.1276+011.000/" (ISO 6709: latitude, longitude, then an altitude when there is one)
// as numbers. ok is false when it is not that.
iso6709_parse :: (text: string) -> latitude: float64, longitude: float64, altitude: float64, has_altitude: bool, ok: bool {
values: [3] float64;
count := 0;
at := 0;
while at < text.count && count < 3 {
c := text[at];
if c == #char "/" break;
if c != #char "+" && c != #char "-" return 0, 0, 0, false, false;
end := at + 1;
while end < text.count && (is_digit(text[end]) || text[end] == #char ".") end += 1;
if end == at + 1 return 0, 0, 0, false, false;
value, value_ok := string_to_float64(slice(text, at, end - at));
if !value_ok return 0, 0, 0, false, false;
values[count] = value;
count += 1;
at = end;
}
if count < 2 || values[0] < -90 || values[0] > 90 || values[1] < -180 || values[1] > 180 return 0, 0, 0, false, false;
return values[0], values[1], values[2], count == 3, true;
}
// How many frames a GIF has, and how long it runs in milliseconds, from its blocks; no pixels are
// decoded. A frame with no delay, or 10 ms, shows for 100 ms, as browsers show it. ok is false
// when the bytes are not a GIF; one cut short counts the frames up to where it ends.
gif_info :: (bytes: [] u8) -> frames: s64, duration_ms: s64, ok: bool {
if bytes.count < 13 || (memcmp(bytes.data, "GIF87a".data, 6) != 0 && memcmp(bytes.data, "GIF89a".data, 6) != 0) return 0, 0, false;
at := 13;
flags := bytes[10];
if flags & 0x80 at += 3 * (1 << ((flags & 7) + 1)); // the global colour table
frames, duration := 0, 0;
delay := 0;
skip_blocks :: (bytes: [] u8, at: s64) -> s64 { // data sub-blocks, to the empty one that ends them
while at < bytes.count {
size := cast(s64) bytes[at];
at += 1 + size;
if size == 0 break;
}
return at;
}
while at < bytes.count {
if bytes[at] == {
case 0x21; // an extension
if at + 1 >= bytes.count break;
if bytes[at + 1] == 0xF9 && at + 6 < bytes.count { // graphic control: the next frame's delay, in hundredths
delay = (cast(s64) bytes[at + 4]) | ((cast(s64) bytes[at + 5]) << 8);
}
at = skip_blocks(bytes, at + 2);
case 0x2C; // a frame
if at + 10 > bytes.count break;
local := bytes[at + 9];
at += 10;
if local & 0x80 at += 3 * (1 << ((local & 7) + 1));
at = skip_blocks(bytes, at + 1); // after the LZW code size
if at > bytes.count break;
frames += 1;
duration += ifx delay <= 1 then 100 else delay * 10;
delay = 0;
case; // 0x3B ends it; anything else is damage
break;
}
}
return frames, duration, true;
}