[0.1.28] macOS phase 4: videos through FFmpeg 6.0 for macOS, an LGPL build made by build_macos.jai

This commit is contained in:
Musa Mahmood 2026-10-16 18:38:41 -04:00
parent a66928b223
commit 3e353c77d8
20 changed files with 13165 additions and 33 deletions

2
.gitignore vendored
View File

@ -27,5 +27,7 @@ Thumbs.db
/modules/Labels/models/ /modules/Labels/models/
# Segmentation: exported by tools/export_mask2former.py, not committed # Segmentation: exported by tools/export_mask2former.py, not committed
/modules/Segment/models/ /modules/Segment/models/
# FFmpeg for macOS: the source, build and headers modules/ffmpeg/build_macos.jai makes (its libraries in macos/ are kept)
/modules/ffmpeg/macos-build/
# Releases: made by release.jai # Releases: made by release.jai
/release/ /release/

View File

@ -269,7 +269,7 @@ What becomes of each file:
- **Animated GIFs** are images; their thumbnail is marked **GIF**, and the Capture window says how - **Animated GIFs** are images; their thumbnail is marked **GIF**, and the Capture window says how
long they run and opens them in the default viewer. long they run and opens them in the default viewer.
- **Videos** (MP4, MOV, M4V, 3GP, MKV, WebM, AVI, WMV, MPEG, MTS and M2TS, FLV), read with FFmpeg - **Videos** (MP4, MOV, M4V, 3GP, MKV, WebM, AVI, WMV, MPEG, MTS and M2TS, FLV), read with FFmpeg
(`modules/ffmpeg`, Windows only): an `.mcap` holding the file exactly as it was, of any size (it is (`modules/ffmpeg`; Windows and macOS, not Linux): an `.mcap` holding the file exactly as it was, of any size (it is
copied a few MB at a time, never read whole), and a frame from it a tenth of the way in (at most 3 copied a few MB at a time, never read whole), and a frame from it a tenth of the way in (at most 3
seconds), turned the right way up, which is what the grid, the Capture window, OCR and labels see. seconds), turned the right way up, which is what the grid, the Capture window, OCR and labels see.
The date is the phone's (Apple's creation date), else one in its name, else the video's own The date is the phone's (Apple's creation date), else one in its name, else the video's own

View File

@ -49,7 +49,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
GIT_BRANCH :: "check"; GIT_BRANCH :: "check";
GIT_HASH :: "-"; GIT_HASH :: "-";
VERSION :: "0.1.27"; VERSION :: "0.1.28";
BUILD_DATE :: "-"; BUILD_DATE :: "-";
DEBUG :: true; DEBUG :: true;
COMPILE_TESTS :: true; COMPILE_TESTS :: true;

View File

@ -8,7 +8,7 @@ APP_NAME :: "mcap";
APP_FOLDER_NAME :: "mcap"; APP_FOLDER_NAME :: "mcap";
APP_BUNDLE_IDENTIFIER :: "com.example.mcap"; APP_BUNDLE_IDENTIFIER :: "com.example.mcap";
VERSION :: "0.1.27"; VERSION :: "0.1.28";
// Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated // Where everything goes: bin/, or bin-headless/ for builds made with no_crash_dialog, so automated
// builds never write over the ones you run by hand. // builds never write over the ones you run by hand.
build_directory := "bin"; build_directory := "bin";
@ -215,7 +215,7 @@ finish_main_build :: (build: Pending_Build) {
MACOS_BUNDLE_DYLIBS :: string.["libSDL3.0.dylib"]; MACOS_BUNDLE_DYLIBS :: string.["libSDL3.0.dylib"];
// mcap.app for the release build, mcap-debug.app for the debug one, with an identifier of its own // mcap.app for the release build, mcap-debug.app for the debug one, with an identifier of its own
// so that its permissions are granted apart. Each holds the executable, SDL3 beside it, // so that its permissions are granted apart. Each holds the executable, SDL3 and FFmpeg beside it,
// onnxruntime in Contents/Frameworks, and the icon, Info.plist and models in Contents/Resources. // onnxruntime in Contents/Frameworks, and the icon, Info.plist and models in Contents/Resources.
// The app finds data/, logs/ and images/ in the folder holding the bundle (see // The app finds data/, logs/ and images/ in the folder holding the bundle (see
// app_install_directory), and the models in Contents/Resources (app_resource_directory). // app_install_directory), and the models in Contents/Resources (app_resource_directory).
@ -241,6 +241,8 @@ finish_main_build :: (build: Pending_Build) {
copied := copy_file(tprint("%/%", build_directory, executable_name), tprint("%/%", executable_folder, executable_name)); copied := copy_file(tprint("%/%", build_directory, executable_name), tprint("%/%", executable_folder, executable_name));
for MACOS_BUNDLE_DYLIBS copied &&= copy_file(tprint("assets/%", it), tprint("%/%", executable_folder, it)); for MACOS_BUNDLE_DYLIBS copied &&= copy_file(tprint("assets/%", it), tprint("%/%", executable_folder, it));
// FFmpeg, linked against, so beside the executable as SDL3 is (@loader_path).
for file_list("modules/ffmpeg/macos") if ends_with(it, ".dylib") copied &&= copy_file(it, tprint("%/%", executable_folder, path_filename(it)));
if !copied { if !copied {
log_error("ERROR: Couldn't copy the executable and its dylibs into %\n", app); log_error("ERROR: Couldn't copy the executable and its dylibs into %\n", app);
return; return;
@ -368,9 +370,12 @@ copy_assets :: () {
log("OCR: the models have not been fetched; run jai fetch_models.jai in modules/OCR for OCR."); log("OCR: the models have not been fetched; run jai fetch_models.jai in modules/OCR for OCR.");
} }
// Videos: FFmpeg's DLLs beside the executable (modules/ffmpeg/windows, in git), the same way. // Videos: FFmpeg's libraries beside the executable (modules/ffmpeg/windows and macos, in git), the
// same way. The executable finds the dylibs there through its @loader_path rpath.
#if OS == .WINDOWS { #if OS == .WINDOWS {
for file_list("modules/ffmpeg/windows") if ends_with(it, ".dll") copy_if_changed(it, tprint("%/%", build_directory, path_filename(it))); for file_list("modules/ffmpeg/windows") if ends_with(it, ".dll") copy_if_changed(it, tprint("%/%", build_directory, path_filename(it)));
} else #if OS == .MACOS {
for file_list("modules/ffmpeg/macos") if ends_with(it, ".dylib") copy_if_changed(it, tprint("%/%", build_directory, path_filename(it)));
} }
// Labels: its models in bin/label-models, the same way. // Labels: its models in bin/label-models, the same way.

View File

@ -9,7 +9,7 @@
// the camera RAW formats that are TIFF inside: image_kind_from_name tells those apart. // 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 // 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. // reads a frame from one with FFmpeg, on Windows and macOS, 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; ImageIO // Thread-safe: every call stands alone, and WIC is set up and released on the calling thread; ImageIO
// needs no setting up. // needs no setting up.
@ -266,7 +266,7 @@ png_size :: (png: [] u8) -> width: s64, height: s64, ok: bool {
#load "video.jai"; #load "video.jai";
#if OS == .WINDOWS #load "wic.jai"; #if OS == .WINDOWS #load "wic.jai";
#if OS == .MACOS #load "imageio.jai"; #if OS == .MACOS #load "imageio.jai";
#if OS == .WINDOWS #load "video_ffmpeg.jai"; #if OS == .WINDOWS || OS == .MACOS #load "video_ffmpeg.jai";
#scope_module #scope_module

View File

@ -24,9 +24,9 @@ Video_Info :: struct {
// start with; the first frame of a clip shorter than a second. // start with; the first frame of a clip shorter than a second.
VIDEO_POSTER_MAX_SECONDS :: 3.0; VIDEO_POSTER_MAX_SECONDS :: 3.0;
#if OS != .WINDOWS { #if OS == .LINUX {
video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string { video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string {
return .{}, .{}, false, "videos are only read on Windows"; return .{}, .{}, false, "videos are only read on Windows and macOS";
} }
} }

View File

@ -1,4 +1,4 @@
// video_poster on Windows, with FFmpeg (modules/ffmpeg). See video.jai. // video_poster on Windows and macOS, with FFmpeg (modules/ffmpeg). See video.jai.
video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string { video_poster :: (path: string) -> Image, Video_Info, ok: bool, error: string {
image: Image; image: Image;

View File

@ -2,6 +2,19 @@
FFmpeg bindings for jai and some examples decoding and play videos. FFmpeg bindings for jai and some examples decoding and play videos.
## In mcap
FFmpeg 6.0 on both systems, so the two sets of bindings match:
- **Windows:** `windows/` holds the DLLs and import libraries of a GPL "full" build from gyan.dev
(`windows/LICENSE-BUILD.txt`), and `windows.jai` the bindings made from its headers (Setup below).
- **macOS:** `macos/` holds `libavcodec`, `libavformat`, `libavutil`, `libswresample` and `libswscale`,
an LGPL build for Apple silicon made from the release's source by `jai build_macos.jai` (about a
minute; `macos/LICENSE-BUILD.txt` has its configure options). It also installs the headers in
`macos-build/` (not in git), from which `jai generate.jai` on a Mac makes `macos.jai`. The libraries
are linked with `no_dll`, so the compiler does not load them, and copied beside the executable,
where its `@loader_path` rpath finds them.
## Setup ## Setup
The FFmpeg libaries and header files are not included in this repo as I didn't want to have to deal with license The FFmpeg libaries and header files are not included in this repo as I didn't want to have to deal with license

View File

@ -0,0 +1,117 @@
// Builds the FFmpeg 6.0 libraries mcap uses on macOS (avformat, avcodec, avutil, swscale and
// swresample) from the release's source, its SHA-256 checked, and puts them in macos/ with the
// headers the bindings are made from (generate.jai) in macos-build/install/include. The same version
// as the Windows DLLs (windows/), so the two sets of bindings match.
//
// cd modules/ffmpeg && jai build_macos.jai
//
// An LGPL build: no GPL parts (the Windows DLLs are a GPL build with libx264), nothing from outside
// macOS (--disable-autodetect, pkg-config pointed nowhere, so nothing from Homebrew is linked), and
// VideoToolbox and AudioToolbox for the hardware codecs. Needs Apple's command line tools: curl,
// tar, make and clang. A few minutes on Apple silicon.
FFMPEG_VERSION :: "6.0";
FFMPEG_ARCHIVE :: "ffmpeg-6.0.tar.xz";
FFMPEG_SHA256 :: "57be87c22d9b49c112b6d24bc67d42508660e6b718b3db89c44e47e289137082";
BUILD_FOLDER :: "macos-build"; // the source, the build and the installed headers; not in git
OUTPUT_FOLDER :: "macos"; // the libraries, in git as windows/ is
// Each library as its major version names it, which is how they find each other (@rpath/...).
LIBRARIES :: string.["libavcodec.60.dylib", "libavformat.60.dylib", "libavutil.58.dylib", "libswresample.4.dylib", "libswscale.7.dylib"];
CONFIGURE_OPTIONS :: string.[
"--install-name-dir=@rpath",
"--enable-shared", "--disable-static",
"--disable-programs", "--disable-doc",
"--disable-avdevice", "--disable-avfilter", "--disable-postproc", "--disable-network",
"--disable-autodetect",
"--enable-videotoolbox", "--enable-audiotoolbox", "--enable-zlib", "--enable-bzlib",
"--extra-cflags=-mmacosx-version-min=14.0", "--extra-ldflags=-mmacosx-version-min=14.0",
];
#run,stallable {
set_build_options_dc(.{do_output = false});
#if OS != .MACOS {
log_error("build_macos.jai builds on a Mac only.");
return;
}
if !build() compiler_set_workspace_status(.FAILED);
}
build :: () -> bool {
here := get_working_directory();
build_folder := tprint("%/%", here, BUILD_FOLDER);
make_directory_if_it_does_not_exist(build_folder);
archive := tprint("%/%", build_folder, FFMPEG_ARCHIVE);
if !file_exists(archive) || sha256_of(archive) != FFMPEG_SHA256 {
url := tprint("https://ffmpeg.org/releases/%", FFMPEG_ARCHIVE);
log("Downloading %...", url);
result := run_command("curl", "-L", "--fail", "--progress-bar", "-o", archive, url);
if result.exit_code != 0 { log_error("curl failed (exit code %).", result.exit_code); return false; }
hash := sha256_of(archive);
if hash != FFMPEG_SHA256 {
log_error("% has SHA-256 %, expected %; it was not used.", archive, hash, FFMPEG_SHA256);
return false;
}
}
source := tprint("%/ffmpeg-%", build_folder, FFMPEG_VERSION);
install := tprint("%/install", build_folder);
run_command("rm", "-rf", source, install);
result := run_command("tar", "xf", archive, "-C", build_folder);
if result.exit_code != 0 { log_error("tar could not unpack %.", archive); return false; }
log("Configuring...");
arguments: [..] string;
array_add(*arguments, "env", "PKG_CONFIG_LIBDIR=", "PKG_CONFIG_PATH=", "./configure", tprint("--prefix=%", install));
array_add(*arguments, ..CONFIGURE_OPTIONS);
result = run_command(..arguments, working_directory = source);
if result.exit_code != 0 { log_error("configure failed; see %/ffbuild/config.log.", source); return false; }
log("Building...");
cpus := get_number_of_processors();
result = run_command("make", tprint("-j%", cpus), working_directory = source);
if result.exit_code != 0 { log_error("make failed."); return false; }
result = run_command("make", "install", working_directory = source);
if result.exit_code != 0 { log_error("make install failed."); return false; }
output := tprint("%/%", here, OUTPUT_FOLDER);
run_command("rm", "-rf", output);
make_directory_if_it_does_not_exist(output);
for LIBRARIES {
// The major-version name is a link to the full one; the file itself is copied under that name.
if !copy_file(tprint("%/lib/%", install, it), tprint("%/%", output, it)) { log_error("Could not copy %.", it); return false; }
run_command("strip", "-x", tprint("%/%", output, it));
}
write_entire_file(tprint("%/LICENSE-BUILD.txt", output), tprint(LICENSE_TEXT, FFMPEG_VERSION, FFMPEG_ARCHIVE, FFMPEG_SHA256, join(..CONFIGURE_OPTIONS, "\n ")));
log("Put % libraries in % and the headers in %/include (FFmpeg %, LGPL).", LIBRARIES.count, output, install, FFMPEG_VERSION);
return true;
}
LICENSE_TEXT :: #string TEXT
FFmpeg % shared libraries for macOS on Apple silicon, built by modules/ffmpeg/build_macos.jai.
Source: https://ffmpeg.org/releases/%
SHA-256: %
License: LGPL version 2.1 or later (no GPL or non-free parts). The source is at the address above;
the libraries are built from it unchanged, with these configure options:
%
TEXT
// Lowercase hex, or "" when it could not be worked out.
sha256_of :: (path: string) -> string {
result, output := run_command("shasum", "-a", "256", path, capture_and_return_output = true);
if result.exit_code != 0 return "";
return to_lower_copy(trim(split(output, " ")[0]));
}
#import "Basic";
#import "Compiler";
#import "File";
#import "File_Utilities";
#import "Process";
#import "String";
#import "System";

View File

@ -46,10 +46,13 @@ FOOTER :: #string DONE
DONE DONE
INCLUDE_PATH :: "src"; // Windows: the headers of the downloaded build, copied to src. macOS: those build_macos.jai installs.
#if OS == .MACOS INCLUDE_PATH :: "macos-build/install/include";
else INCLUDE_PATH :: "src";
generate_bindings :: () -> bool { generate_bindings :: () -> bool {
output_filename := "windows.jai"; #if OS == .MACOS output_filename := "macos.jai";
else output_filename := "windows.jai";
opts: Generate_Bindings_Options; opts: Generate_Bindings_Options;
{ {
@ -67,9 +70,20 @@ generate_bindings :: () -> bool {
// doesn't work, just get a load of __UnknownLib dont you :roll:. // doesn't work, just get a load of __UnknownLib dont you :roll:.
// strip_flags &= ~.SYMBOLS_WITH_UNKNOWN_FOREIGN_LIBS; // strip_flags &= ~.SYMBOLS_WITH_UNKNOWN_FOREIGN_LIBS;
#if OS == .MACOS {
// The five libraries build_macos.jai makes, as their major versions name them; with the
// extension, since the generator takes a name with a dot in it for a whole file name.
array_add(*libpaths, "macos");
library_names :: string.[
"libavcodec.60.dylib",
"libavformat.60.dylib",
"libavutil.58.dylib",
"libswresample.4.dylib",
"libswscale.7.dylib",
];
} else {
array_add(*libpaths, "windows"); array_add(*libpaths, "windows");
library_names :: string.[
libraries := string.[
"avcodec-60", "avcodec-60",
"avdevice-60", "avdevice-60",
"avfilter-9", "avfilter-9",
@ -79,7 +93,8 @@ generate_bindings :: () -> bool {
"swresample-4", "swresample-4",
"swscale-7", "swscale-7",
]; ];
for libraries array_add(*libnames, it); }
for library_names array_add(*libnames, it);
array_add(*include_paths, INCLUDE_PATH); array_add(*include_paths, INCLUDE_PATH);
@ -89,7 +104,7 @@ generate_bindings :: () -> bool {
// pick the headers I want! // pick the headers I want!
#if false { #if false {
dirs_to_visit: [..] string; dirs_to_visit: [..] string;
for libraries array_add(*dirs_to_visit, tprint("%/lib%", INCLUDE_PATH, it)); for library_names array_add(*dirs_to_visit, tprint("%/lib%", INCLUDE_PATH, it));
file_visitor :: (info: *File_Visit_Info, source_files : *[..] string) { file_visitor :: (info: *File_Visit_Info, source_files : *[..] string) {
assert(info != null, "info (%) != null", info); assert(info != null, "info (%) != null", info);
@ -121,7 +136,7 @@ generate_bindings :: () -> bool {
array_add(*source_files, tprint("%/libavformat/avformat.h", INCLUDE_PATH)); array_add(*source_files, tprint("%/libavformat/avformat.h", INCLUDE_PATH));
array_add(*source_files, tprint("%/libswscale/swscale.h", INCLUDE_PATH)); array_add(*source_files, tprint("%/libswscale/swscale.h", INCLUDE_PATH));
array_add(*system_include_paths, GENERATOR_DEFAULT_SYSTEM_INCLUDE_PATH); // system_include_paths left empty: the generator fills in this system's own.
builder: String_Builder; builder: String_Builder;
append(*builder, HEADER); append(*builder, HEADER);
@ -134,6 +149,14 @@ generate_bindings :: () -> bool {
print("Generating ffmpeg bindings...\n"); print("Generating ffmpeg bindings...\n");
result := generate_bindings(opts, output_filename); result := generate_bindings(opts, output_filename);
// The compiler loads each #library to run code at compile time, but there the dylibs cannot find
// each other through @rpath; nothing of FFmpeg's is run then, so none is loaded (no_dll).
#if OS == .MACOS if result {
text, ok := read_entire_file(output_filename);
if !ok return false;
result = write_entire_file(output_filename, replace(text, " :: #library \"macos/", " :: #library,no_dll \"macos/"));
}
return result; return result;
} }
@ -243,7 +266,7 @@ generate_error_codes :: (b: *String_Builder) {
for info_struct.members longest_name_length = max(longest_name_length, it.name.count); for info_struct.members longest_name_length = max(longest_name_length, it.name.count);
for info_struct.members { for info_struct.members {
slot := cast (*u8) *dummy + it.offset_in_bytes; slot := cast (*u8) *dummy + it.offset_in_bytes;
value := << cast(*s32) slot; value := (cast(*s32) slot).*;
append(b, it.name); append(b, it.name);
num_spaces := longest_name_length - it.name.count; num_spaces := longest_name_length - it.name.count;
for 1..num_spaces append(b, " "); for 1..num_spaces append(b, " ");

12947
modules/ffmpeg/macos.jai Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
FFmpeg 6.0 shared libraries for macOS on Apple silicon, built by modules/ffmpeg/build_macos.jai.
Source: https://ffmpeg.org/releases/ffmpeg-6.0.tar.xz
SHA-256: 57be87c22d9b49c112b6d24bc67d42508660e6b718b3db89c44e47e289137082
License: LGPL version 2.1 or later (no GPL or non-free parts). The source is at the address above;
the libraries are built from it unchanged, with these configure options:
--install-name-dir=@rpath
--enable-shared
--disable-static
--disable-programs
--disable-doc
--disable-avdevice
--disable-avfilter
--disable-postproc
--disable-network
--disable-autodetect
--enable-videotoolbox
--enable-audiotoolbox
--enable-zlib
--enable-bzlib
--extra-cflags=-mmacosx-version-min=14.0
--extra-ldflags=-mmacosx-version-min=14.0

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7,7 +7,9 @@ size_t :: u64;
#if OS == .WINDOWS { #if OS == .WINDOWS {
#load "windows.jai"; #load "windows.jai";
} else #if OS == .MACOS {
#load "macos.jai"; // generated from build_macos.jai's headers; its libraries are in macos/
} else { } else {
#assert false, "Only windows has bindings atm, sorry bros!"; #assert false, "FFmpeg has bindings for Windows and macOS only.";
} }

View File

@ -376,7 +376,7 @@ import_file :: (path: string) -> Import_Outcome, text: string, new_path: string
// A video: hashed and copied into its .mcap a few MB at a time, so it can be larger than memory. // A video: hashed and copied into its .mcap a few MB at a time, so it can be larger than memory.
import_video_file :: (path: string, name: string, head: [] u8, size: s64, modified: Apollo_Time) -> Import_Outcome, text: string, new_path: string { import_video_file :: (path: string, name: string, head: [] u8, size: s64, modified: Apollo_Time) -> Import_Outcome, text: string, new_path: string {
if OS != .WINDOWS return .SKIPPED, "video (Windows only)", ""; if OS == .LINUX return .SKIPPED, "video (Windows and macOS only)", "";
hash, hashed := import_hash_file(.{path = path, offset = 0, size = size}); hash, hashed := import_hash_file(.{path = path, offset = 0, size = size});
if !hashed return .FAILED, "cannot be read", ""; if !hashed return .FAILED, "cannot be read", "";

View File

@ -254,7 +254,7 @@ open_file_proc :: (thread: *Thread) -> s64 {
} }
case .VIDEO; case .VIDEO;
#if OS == .WINDOWS { #if OS == .WINDOWS || OS == .MACOS {
image, video, ok, poster_error := video_poster(path); image, video, ok, poster_error := video_poster(path);
if !ok { error = poster_error; } if !ok { error = poster_error; }
else { else {
@ -265,7 +265,7 @@ open_file_proc :: (thread: *Thread) -> s64 {
} }
to_hash = .{path = path, offset = 0, size = open_file.size}; to_hash = .{path = path, offset = 0, size = open_file.size};
} else { } else {
error = "videos are read on Windows only"; error = "videos are read on Windows and macOS only";
} }
case; case;
@ -492,7 +492,7 @@ open_file_library_ui :: (stage: Open_File_Stage) { using ImGui;
return; return;
} }
if open_file.kind == .UNKNOWN { TextColored(app_theme.palette.warning, "mcap does not import this kind of file."); return; } if open_file.kind == .UNKNOWN { TextColored(app_theme.palette.warning, "mcap does not import this kind of file."); return; }
if OS != .WINDOWS && open_file.kind == .VIDEO { TextColored(app_theme.palette.warning, "Videos are imported on Windows only."); return; } if OS == .LINUX && open_file.kind == .VIDEO { TextColored(app_theme.palette.warning, "Videos are imported on Windows and macOS only."); return; }
if open_file.importing { if open_file.importing {
if importer.phase == .INDEXING Text("Indexing the library (once; this finds duplicates): %", ocr_scan_progress_text(importer.index_done, importer.index_total)); if importer.phase == .INDEXING Text("Indexing the library (once; this finds duplicates): %", ocr_scan_progress_text(importer.index_done, importer.index_total));