%% Image payloads live in the `images` table, keyed by the SHA-256 of their
%% base64 text; decoded bytes live in the table, while transcript rows keep
%% only the original base64 hash and image metadata.
%%
%% In memory an image is {image, Mime, Data, Width, Height, Bytes} where Data is
%% {inline_data, Base64} or {stored_data, Hash, Size, Read}, Read being a fun/0
%% that fetches the payload (see types.ImageData). A packed row stores
%% {stored_data, Hash, Size} (no fun), or the legacy bare Base64 binary.
-module(albedo_images).
-export([externalize/2, attach/2, pack/1, pack_image/1, load/2, canonical/1, legacy/1, results/1, hashes/1, migrate/2, ensure_dir/1, backup_exists/1, decode_base64/1, decode_legacy_base64/1, encode_base64/1]).
%% The same data-size limit albedo_image guards with; a guard needs the macro.
-define(MAX_DATA_BYTES, 6990508).
%% Moves the inline images of one input into Blobs [{Hash, Base64}], returning
%% the input with stored references. Read is fun(Hash) -> {ok, Base64} | {error, nil}.
%% Payloads that would need JSON escaping stay inline (a request writes a
%% stored payload verbatim between quotes).
externalize({user_image, Text, Image}, Read) ->
{Stored, Blobs} = store(Image, Read),
{{user_image, Text, Stored}, Blobs};
externalize({tool_output, Id, Text, Images}, Read) ->
Pairs = [store(Image, Read) || Image <- Images],
{{tool_output, Id, Text, [S || {S, _} <- Pairs]}, lists:append([B || {_, B} <- Pairs])};
externalize(Input, _) -> {Input, []}.
store({image, Mime, {inline_data, Data}, W, H, Bytes} = Image, Read) ->
case byte_size(Data) =:= 4 * ((Bytes + 2) div 3) andalso clean(Data) of
true ->
Hash = hash(Data),
{{image, Mime, stored(Hash, byte_size(Data), Read), W, H, Bytes}, [{Hash, Data}]};
false -> {Image, []}
end;
store(Image, _) -> {Image, []}.
stored(Hash, Size, Read) -> {stored_data, Hash, Size, fun() -> Read(Hash) end}.
hash(Data) -> binary:encode_hex(crypto:hash(sha256, Data), lowercase).
%% A decoded row's images with their readers attached; packed refs that fail
%% the shape check make the row invalid.
attach({user_image, Text, Image}, Read) ->
case load(Image, Read) of
{ok, Loaded} -> {ok, {user_image, Text, Loaded}};
error -> error
end;
attach({tool_output, Id, Text, Images}, Read) ->
case results([load(Image, Read) || Image <- Images]) of
{ok, Loaded} -> {ok, {tool_output, Id, Text, Loaded}};
error -> error
end;
attach(Input, _) -> {ok, Input}.
load({image, Mime, {stored_data, Hash, Size}, W, H, Bytes}, Read)
when is_binary(Mime), is_binary(Hash), byte_size(Hash) =:= 64,
is_integer(Size), Size > 0, Size =< ?MAX_DATA_BYTES,
is_integer(W), is_integer(H), is_integer(Bytes), Bytes > 0,
Size =:= 4 * ((Bytes + 2) div 3) ->
%% A stored payload was validated as canonical base64 on insert.
{ok, {image, Mime, stored(Hash, Size, Read), W, H, Bytes}};
load({image, Mime, Data, W, H, Bytes} = Legacy, _) when is_binary(Data) ->
case albedo_image:valid(Legacy) of
true -> {ok, {image, Mime, {inline_data, Data}, W, H, Bytes}};
false -> error
end;
load(_, _) -> error.
%% The row form of an input: readers dropped, inline payloads in the legacy
%% bare-binary shape so rows that were never externalized read as before.
pack({user_image, Text, Image}) -> {user_image, Text, pack_image(Image)};
pack({tool_output, Id, Text, Images}) -> {tool_output, Id, Text, [pack_image(I) || I <- Images]};
pack(Input) -> Input.
pack_image({image, Mime, {inline_data, Data}, W, H, Bytes}) -> {image, Mime, Data, W, H, Bytes};
pack_image({image, Mime, {stored_data, Hash, Size, _}, W, H, Bytes}) ->
{image, Mime, {stored_data, Hash, Size}, W, H, Bytes};
pack_image(Image) -> Image.
%% Any term with every image's payload replaced by its content hash, so equal
%% content fingerprints equally whether it is inline or stored. Terms without
%% images are returned unchanged.
canonical(Term) -> transform(Term, fun hash_payload/1).
hash_payload({image, Mime, Payload, W, H, Bytes}) ->
Hash = case Payload of
{inline_data, Data} -> hash(Data);
{stored_data, Hsh, _, _} -> Hsh
end,
{image, Mime, {sha256, Hash}, W, H, Bytes}.
%% The term as it was before images were stored: payloads inline as bare
%% binaries. Reads every stored payload; {error, nil} if one is missing.
legacy(Term) ->
try {ok, transform(Term, fun inline_payload/1)}
catch throw:missing -> {error, nil}
end.
inline_payload({image, Mime, {inline_data, Data}, W, H, Bytes}) ->
{image, Mime, Data, W, H, Bytes};
inline_payload({image, Mime, {stored_data, _, _, Read}, W, H, Bytes}) ->
case Read() of
{ok, Data} -> {image, Mime, Data, W, H, Bytes};
_ -> throw(missing)
end.
%% Rebuilds a term with every image payload mapped through Replace; everything
%% else keeps its structure. An image tuple whose payload is neither inline
%% nor stored is walked like any other tuple.
transform({image, Mime, {inline_data, _}, _, _, _} = Image, Replace) when is_binary(Mime) -> Replace(Image);
transform({image, Mime, {stored_data, _, _, _}, _, _, _} = Image, Replace) when is_binary(Mime) -> Replace(Image);
transform(T, Replace) when is_tuple(T) -> list_to_tuple(transform(tuple_to_list(T), Replace));
transform([H | T], Replace) -> [transform(H, Replace) | transform(T, Replace)];
transform(M, Replace) when is_map(M) -> maps:from_list(transform(maps:to_list(M), Replace));
transform(Other, _) -> Other.
%% Every attempt's value, or error when one failed.
results(Attempts) ->
case lists:all(fun({ok, _}) -> true; (_) -> false end, Attempts) of
true -> {ok, [Value || {ok, Value} <- Attempts]};
false -> error
end.
%% Hashes a packed row references, without attaching or validating.
hashes(Payload) ->
try binary_to_term(Payload, [safe]) of
{1, {user_image, _, {image, _, {stored_data, Hash, _}, _, _, _}}} -> [Hash];
{1, {tool_output, _, _, Images}} when is_list(Images) ->
[Hash || {image, _, {stored_data, Hash, _}, _, _, _} <- Images];
{1, {image_fit, _, _, {image, _, {stored_data, Hash, _}, _, _, _}}} -> [Hash];
_ -> []
catch _:_ -> []
end.
%% One legacy row, rewritten: {rewrite, Payload, Blobs} when it held inline images
%% that moved to the image table, keep otherwise (including rows that do not
%% decode, which are left for the loader to report).
migrate(Payload, Read) ->
maybe
{ok, Input} ?= albedo_conversation:unpack(Payload, Read),
{Stored, [_ | _] = Blobs} ?= externalize(Input, Read),
{rewrite, albedo_conversation:pack(Stored), Blobs}
else
_ -> keep
end.
%% Canonical base64 needs no JSON escaping; anything else stays inline.
clean(Value) -> albedo_image:charset(Value, true).
ensure_dir(Path) -> _ = filelib:ensure_dir(Path), nil.
backup_exists(Path) -> filelib:is_regular(Path).
%% A clean, canonical inline payload was validated before externalize/2.
decode_base64(Data) -> base64:decode(Data).
%% A damaged legacy row fails migration without discarding its original TEXT.
decode_legacy_base64(Data) ->
try {ok, base64:decode(Data)}
catch error:_ -> {error, nil}
end.
encode_base64(Data) -> base64:encode(Data).