Skip to main content

record_sidecar/
lib.rs

1use anyhow::{bail, Context, Result};
2use base64::{engine::general_purpose, Engine as _};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6use std::io::Read;
7
8pub const SIDECAR_MAGIC: &[u8; 4] = b"BTS1";
9pub const SIDECAR_CONTAINER_VERSION: u8 = 1;
10pub const SIDECAR_POINTER_VERSION: u8 = 1;
11pub const SIDECAR_POINTER_LENGTH: usize = 48;
12pub const SIDECAR_POINTER_SCHEME_PAIRSIGN_SAFE_LUMA_V2: u8 = 1;
13pub const SIDECAR_POINTER_CARRIER_LABEL: u8 = 0x01;
14pub const SIDECAR_POINTER_CARRIER_INTERGROOVE: u8 = 0x02;
15pub const SIDECAR_POINTER_CARRIER_LEAD_IN_DEADWAX: u8 = 0x04;
16pub const SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2: &str = "pairsign-safe-luma-v2";
17pub const SIDECAR_DEFAULT_SEED: u32 = 0x4b50_4752;
18pub const SIDECAR_PAIR_SIGN_DELTA: i16 = 4;
19pub const SIDECAR_PAIR_MAGNITUDE_DELTA: i16 = 12;
20pub const SIDECAR_PAIR_MAGNITUDE_THRESHOLD: f64 = 16.0;
21pub const SIDECAR_SAFE_V2_MIN_SCORE: u16 = 20;
22pub const SIDECAR_TYPE_OPAQUE: u8 = 0;
23pub const SIDECAR_TYPE_UTF8_TEXT: u8 = 1;
24pub const SIDECAR_TYPE_IMAGE: u8 = 2;
25pub const SIDECAR_TYPE_JSON: u8 = 3;
26pub const SIDECAR_CODEC_RAW: u8 = 0;
27pub const SIDECAR_CODEC_BROTLI: u8 = 1;
28pub const SIDECAR_CODEC_ZSTD: u8 = 2;
29pub const SIDECAR_CODEC_AVIF: u8 = 3;
30pub const SIDECAR_RAW_LENGTH_ABSENT: u32 = u32::MAX;
31pub const DISPLAY_HEADER_MAGIC: &[u8; 4] = b"BDH1";
32pub const DISPLAY_HEADER_VERSION: u8 = 1;
33pub const DISPLAY_HEADER_LENGTH: usize = 128;
34pub const DISPLAY_HEADER_NAME: &str = "bitneedle-display-header.bin";
35pub const DISPLAY_HEADER_MIME: &str = "application/vnd.bitneedle.display-header";
36pub const PACKAGE_METADATA_ITEM_NAME: &str = "bitneedle-package-metadata.json";
37pub const PACKAGE_METADATA_MIME: &str = "application/vnd.bitneedle.package-metadata+json";
38pub const PACKAGE_PHOTO_MIME: &str = "image/avif";
39pub const PACKAGE_COVER_ITEM_NAME: &str = "album-cover.avif";
40pub const PACKAGE_PATTERN_SIDECAR_ITEM_NAME: &str = "bitneedle-pattern-map";
41pub const PACKAGE_PATTERN_SIDECAR_MIME: &str = "application/vnd.bitneedle.pattern-map";
42const DISPLAY_HEADER_FLAG_COVER_SHOWN: u16 = 1 << 0;
43const DISPLAY_HEADER_FLAG_COVER_EFFECTS: u16 = 1 << 1;
44const DISPLAY_HEADER_FLAG_COVER_RGB_GRAIN: u16 = 1 << 2;
45const DISPLAY_HEADER_FLAG_INNER_SLEEVE_SHOWN: u16 = 1 << 3;
46const DISPLAY_HEADER_FLAG_COVER_EMBEDDED: u16 = 1 << 4;
47const DEFAULT_COVER_POSTERIZE_LEVELS: f64 = 64.0;
48const DEFAULT_COVER_SATURATION: f64 = 2.0;
49const DEFAULT_COVER_CONTRAST: f64 = 1.3;
50const DEFAULT_COVER_RGB_GRAIN_OPACITY: f64 = 0.84;
51const COVER_RGB_GRAIN_OPACITY_MIN: f64 = 0.4;
52const COVER_CROP_ZOOM_MIN: f64 = 1.0;
53const COVER_CROP_ZOOM_MAX: f64 = 6.0;
54const COVER_PREVIEW_SIZE: f64 = 576.0;
55const PACKAGE_PHOTO_MAX_INNER: u32 = 576;
56const PACKAGE_PHOTO_MAX_QUANTIZER: u8 = 63;
57const PACKAGE_PHOTO_DEFAULT_QUANTIZER: u8 = 32;
58const PACKAGE_PHOTO_SEARCH_MIN_QUANTIZER: u8 = 35;
59const PACKAGE_PHOTO_SEARCH_START_QUANTIZER: u8 = 47;
60const PACKAGE_PHOTO_SEARCH_REFINEMENT_RADIUS: u8 = 2;
61const PACKAGE_COVER_MAX_BUDGET_DIVISOR: u64 = 3;
62const PACKAGE_SIDECAR_SCHEME: &str = "pairsign-safe-luma-v2";
63const PACKAGE_SIDECAR_CARRIERS: [&str; 2] = ["label", "intergroove"];
64const PACKAGE_COLOR_MODE_RGB: &str = "rgb";
65const PACKAGE_COLOR_MODE_GRAYSCALE: &str = "grayscale";
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct SidecarRenderSummary {
70    pub container: String,
71    pub scheme: String,
72    pub carriers: Vec<String>,
73    pub seed: u32,
74    pub bts1_bytes: usize,
75    pub sha256: String,
76    pub carrier_pixels: usize,
77    pub carrier_pairs: usize,
78    pub capacity_bytes: usize,
79    pub used_pairs: usize,
80    pub unused_pairs: usize,
81}
82
83#[derive(Debug, Clone, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct SidecarRenderOptions {
86    pub scheme: Option<String>,
87    pub label_tuning: Option<SidecarLabelTuningOptions>,
88    pub seed: Option<u32>,
89    pub carriers: Option<Vec<String>>,
90    pub bts1_base64: Option<String>,
91    pub items: Option<Vec<SidecarItemInput>>,
92}
93
94#[derive(Debug, Clone, Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct SidecarLabelTuningOptions {
97    pub enabled: Option<bool>,
98    pub strength: Option<f64>,
99    pub target_luma: Option<f64>,
100    pub grain: Option<i16>,
101}
102
103#[derive(Debug, Clone)]
104pub struct PreparedSidecarLabelTuning {
105    pub strength: f64,
106    pub target_luma: f64,
107    pub grain: i16,
108}
109
110#[derive(Debug, Clone, Deserialize)]
111#[serde(rename_all = "camelCase")]
112pub struct SidecarItemInput {
113    #[serde(rename = "type", alias = "itemType")]
114    pub item_type: serde_json::Value,
115    pub codec: serde_json::Value,
116    pub name: Option<String>,
117    pub mime: Option<String>,
118    pub data_base64: Option<String>,
119    pub text: Option<String>,
120    pub json: Option<serde_json::Value>,
121    pub raw_byte_length: Option<u32>,
122    #[serde(default)]
123    pub flags: u8,
124}
125
126#[derive(Debug, Clone, Serialize)]
127#[serde(rename_all = "camelCase")]
128pub struct SidecarContainerValidation {
129    pub ok: bool,
130    pub version: u8,
131    pub flags: u8,
132    pub item_count: usize,
133    pub total_length: usize,
134    pub items: Vec<SidecarItemValidation>,
135}
136
137#[derive(Debug, Clone, Serialize)]
138#[serde(rename_all = "camelCase")]
139pub struct SidecarItemValidation {
140    pub item_type: u8,
141    pub item_type_name: String,
142    pub codec: u8,
143    pub codec_name: String,
144    pub flags: u8,
145    pub raw_byte_length: Option<u32>,
146    pub stored_byte_length: u32,
147    pub name: String,
148    pub mime: String,
149}
150
151#[derive(Debug, Clone, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct SidecarDecodedItems {
154    pub ok: bool,
155    pub validation: SidecarContainerValidation,
156    pub items: Vec<SidecarDecodedItem>,
157}
158
159#[derive(Debug, Clone, Serialize)]
160#[serde(rename_all = "camelCase")]
161pub struct SidecarDecodedItem {
162    pub item_type: u8,
163    pub item_type_name: String,
164    pub codec: u8,
165    pub codec_name: String,
166    pub flags: u8,
167    pub raw_byte_length: Option<u32>,
168    pub stored_byte_length: u32,
169    pub decoded_byte_length: usize,
170    pub name: String,
171    pub mime: String,
172    pub stored_data_base64: String,
173    pub data_base64: String,
174    pub text: Option<String>,
175    pub json: Option<serde_json::Value>,
176}
177
178#[derive(Debug, Clone, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct PackageDisplayHeaderInput {
181    pub cover_shown: Option<bool>,
182    pub cover_effects: Option<bool>,
183    pub cover_rgb_grain: Option<bool>,
184    pub inner_sleeve_shown: Option<bool>,
185    pub cover_embedded: Option<bool>,
186    pub design_label: Option<String>,
187    pub design_class_name: Option<String>,
188    pub posterize: Option<f64>,
189    pub saturation: Option<f64>,
190    pub contrast: Option<f64>,
191    pub rgb_grain_opacity: Option<f64>,
192    pub rgb_grain_blend_index: Option<f64>,
193    pub crop: Option<PackageDisplayHeaderCrop>,
194    pub quantizer: Option<f64>,
195    pub inner: Option<f64>,
196    pub sleeve_tone_color: Option<String>,
197    pub sleeve_sparkle_opacity: Option<f64>,
198}
199
200#[derive(Debug, Clone, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct PackageDisplayHeaderCrop {
203    pub x: Option<f64>,
204    pub y: Option<f64>,
205    pub zoom: Option<f64>,
206}
207
208#[derive(Debug, Clone, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct PackageMetadataInput {
211    #[serde(default)]
212    pub photos: Vec<PackageMetadataPhotoInput>,
213    pub cover: Option<PackageMetadataCoverInput>,
214}
215
216#[derive(Debug, Clone, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct PackageMetadataPhotoInput {
219    pub id: Option<String>,
220    pub name: Option<String>,
221    pub status: Option<String>,
222    pub has_avif: Option<bool>,
223    pub credit: Option<String>,
224}
225
226#[derive(Debug, Clone, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct PackageMetadataCoverInput {
229    pub has_file: Option<bool>,
230    pub has_preview: Option<bool>,
231    pub source_photo_id: Option<String>,
232    pub source_width: Option<f64>,
233    pub source_height: Option<f64>,
234    pub width: Option<f64>,
235    pub height: Option<f64>,
236    pub inner: Option<f64>,
237    pub crop: Option<PackageDisplayHeaderCrop>,
238    pub embedded: Option<bool>,
239    pub shown: Option<bool>,
240    pub design_label: Option<String>,
241    pub effects_enabled: Option<bool>,
242    pub name: Option<String>,
243}
244
245#[derive(Debug, Clone, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct PackagePhotoItemInput {
248    pub name: Option<String>,
249}
250
251#[derive(Debug, Clone, Deserialize)]
252#[serde(rename_all = "camelCase")]
253pub struct PackageImageCacheKeyInput {
254    pub kind: Option<String>,
255    pub file_name: Option<String>,
256    pub file_size: Option<serde_json::Value>,
257    pub file_last_modified: Option<serde_json::Value>,
258    pub quantizer: Option<serde_json::Value>,
259    pub inner: Option<serde_json::Value>,
260    pub crop: Option<serde_json::Value>,
261    pub color_mode: Option<String>,
262}
263
264#[derive(Debug, Clone, Serialize)]
265#[serde(rename_all = "camelCase")]
266pub struct PackageImageEncodeCacheKey {
267    pub key: String,
268    pub file_key: String,
269    pub crop_key: String,
270    pub quantizer: u8,
271    pub inner: u32,
272    pub crop: PackageNormalizedCrop,
273    pub color_mode: String,
274    pub monochrome: bool,
275}
276
277#[derive(Debug, Clone, Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct PackageBestFitCacheKeyInput {
280    pub kind: Option<String>,
281    pub file_name: Option<String>,
282    pub file_size: Option<serde_json::Value>,
283    pub file_last_modified: Option<serde_json::Value>,
284    pub budget_bytes: Option<serde_json::Value>,
285    pub inner: Option<serde_json::Value>,
286    pub crop_key: Option<String>,
287    pub color_mode: Option<String>,
288}
289
290#[derive(Debug, Clone, Serialize)]
291#[serde(rename_all = "camelCase")]
292pub struct PackageBestFitCacheKey {
293    pub key: String,
294    pub file_key: String,
295    pub budget_bytes: u64,
296    pub inner: u32,
297    pub crop_key: String,
298    pub color_mode: String,
299}
300
301#[derive(Debug, Clone, Serialize)]
302#[serde(rename_all = "camelCase")]
303pub struct PackageNormalizedCrop {
304    pub x: f64,
305    pub y: f64,
306    pub zoom: f64,
307}
308
309#[derive(Debug, Clone, Deserialize)]
310#[serde(rename_all = "camelCase")]
311pub struct PackageQuantizerSearchPlanInput {
312    pub min_quantizer: Option<serde_json::Value>,
313    pub max_quantizer: Option<serde_json::Value>,
314    pub start_quantizer: Option<serde_json::Value>,
315    pub refinement_radius: Option<serde_json::Value>,
316    #[serde(default)]
317    pub trials: Vec<PackageQuantizerTrialInput>,
318}
319
320#[derive(Debug, Clone, Deserialize)]
321#[serde(rename_all = "camelCase")]
322pub struct PackageQuantizerTrialInput {
323    pub quantizer: Option<serde_json::Value>,
324    pub fits: Option<bool>,
325    pub budget_bytes: Option<serde_json::Value>,
326    pub contribution_bytes: Option<serde_json::Value>,
327    pub bts1_bytes: Option<serde_json::Value>,
328}
329
330#[derive(Debug, Clone, Serialize)]
331#[serde(rename_all = "camelCase")]
332pub struct PackageQuantizerSearchPlan {
333    pub min_quantizer: u8,
334    pub max_quantizer: u8,
335    pub start_quantizer: u8,
336    pub done: bool,
337    pub next_quantizer: Option<u8>,
338    pub note: String,
339    pub best_quantizer: Option<u8>,
340}
341
342#[derive(Debug, Clone, Deserialize)]
343#[serde(rename_all = "camelCase")]
344pub struct PackageFitBudgetInput {
345    pub capacity: Option<serde_json::Value>,
346    pub base_bts1_bytes: Option<serde_json::Value>,
347    pub photo_count: Option<serde_json::Value>,
348}
349
350#[derive(Debug, Clone, Serialize)]
351#[serde(rename_all = "camelCase")]
352pub struct PackageFitBudget {
353    pub capacity: u64,
354    pub base_bts1_bytes: u64,
355    pub available_bytes: u64,
356    pub remaining_bytes: u64,
357    pub cover_budget_bytes: u64,
358    pub photo_count: u64,
359    pub per_photo_budget_bytes: u64,
360}
361
362#[derive(Debug, Clone, Deserialize)]
363#[serde(rename_all = "camelCase")]
364pub struct PackageSidecarRenderOptionsInput {
365    pub seed: Option<serde_json::Value>,
366    pub capacity_bytes: Option<serde_json::Value>,
367    pub payload_bytes: Option<serde_json::Value>,
368    pub bts1_base64: Option<String>,
369    pub items: Option<serde_json::Value>,
370}
371
372#[derive(Debug, Clone, Serialize)]
373#[serde(rename_all = "camelCase")]
374pub struct SidecarDecodeResult {
375    pub ok: bool,
376    pub descriptor: serde_json::Value,
377    pub validation: SidecarContainerValidation,
378    pub bts1_byte_length: usize,
379    pub sha256: String,
380    pub carrier_pixels: usize,
381    pub carrier_pairs: usize,
382    pub capacity_bytes: usize,
383}
384
385#[derive(Debug, Clone, Serialize)]
386#[serde(rename_all = "camelCase")]
387pub struct SidecarCapacity {
388    pub scheme: String,
389    pub carriers: Vec<String>,
390    pub carrier_pixels: usize,
391    pub carrier_pairs: usize,
392    pub capacity_bits: usize,
393    pub capacity_bytes: usize,
394    pub bits_per_pair: f64,
395    pub two_bit_pairs: usize,
396}
397
398#[derive(Debug, Clone)]
399pub struct SidecarHeaderPointer {
400    pub scheme: String,
401    pub carriers: Vec<SidecarCarrier>,
402    pub seed: u32,
403    pub length: usize,
404    pub sha256: String,
405    pub sha256_bytes: [u8; 32],
406}
407
408#[derive(Debug, Clone)]
409pub struct PreparedSidecar {
410    pub bytes: Vec<u8>,
411    pub scheme: String,
412    pub label_tuning: Option<PreparedSidecarLabelTuning>,
413    pub carriers: Vec<SidecarCarrier>,
414    pub seed: u32,
415    pub sha256: String,
416    pub sha256_bytes: [u8; 32],
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub enum SidecarCarrier {
422    Label,
423    Intergroove,
424    LeadInDeadwax,
425}
426
427impl SidecarCarrier {
428    pub fn name(self) -> &'static str {
429        sidecar_carrier_name(self)
430    }
431}
432
433pub fn sha256_digest_bytes(bytes: &[u8]) -> [u8; 32] {
434    let digest = Sha256::digest(bytes);
435    let mut out = [0u8; 32];
436    out.copy_from_slice(&digest);
437    out
438}
439
440pub fn sha256_base64url(bytes: &[u8]) -> String {
441    general_purpose::URL_SAFE_NO_PAD.encode(sha256_digest_bytes(bytes))
442}
443
444pub fn decode_base64_text(value: &str, label: &str) -> Result<Vec<u8>> {
445    let trimmed = value.trim();
446    general_purpose::URL_SAFE_NO_PAD
447        .decode(trimmed)
448        .or_else(|_| general_purpose::URL_SAFE.decode(trimmed))
449        .or_else(|_| general_purpose::STANDARD.decode(trimmed))
450        .with_context(|| format!("{label} is not valid base64"))
451}
452
453pub fn sidecar_type_name(item_type: u8) -> String {
454    match item_type {
455        SIDECAR_TYPE_OPAQUE => "opaque".to_string(),
456        SIDECAR_TYPE_UTF8_TEXT => "utf8Text".to_string(),
457        SIDECAR_TYPE_IMAGE => "image".to_string(),
458        SIDECAR_TYPE_JSON => "json".to_string(),
459        value => format!("private:{value}"),
460    }
461}
462
463pub fn sidecar_codec_name(codec: u8) -> String {
464    match codec {
465        SIDECAR_CODEC_RAW => "raw".to_string(),
466        SIDECAR_CODEC_BROTLI => "brotli".to_string(),
467        SIDECAR_CODEC_ZSTD => "zstd".to_string(),
468        SIDECAR_CODEC_AVIF => "avif".to_string(),
469        value => format!("private:{value}"),
470    }
471}
472
473fn parse_sidecar_registry_value(
474    value: &serde_json::Value,
475    label: &str,
476    names: &[(&str, u8)],
477) -> Result<u8> {
478    if let Some(number) = value.as_u64() {
479        return u8::try_from(number).with_context(|| format!("{label} exceeds u8 range"));
480    }
481    let Some(raw) = value.as_str() else {
482        bail!("{label} must be a string or integer");
483    };
484    let normalized = raw.trim().to_ascii_lowercase().replace([' ', '-', '_'], "");
485    for (name, code) in names {
486        if normalized == *name {
487            return Ok(*code);
488        }
489    }
490    bail!("unknown {label}: {raw}");
491}
492
493pub fn parse_sidecar_item_type(value: &serde_json::Value) -> Result<u8> {
494    parse_sidecar_registry_value(
495        value,
496        "sidecar item type",
497        &[
498            ("opaque", SIDECAR_TYPE_OPAQUE),
499            ("bytes", SIDECAR_TYPE_OPAQUE),
500            ("binary", SIDECAR_TYPE_OPAQUE),
501            ("utf8text", SIDECAR_TYPE_UTF8_TEXT),
502            ("text", SIDECAR_TYPE_UTF8_TEXT),
503            ("utf8", SIDECAR_TYPE_UTF8_TEXT),
504            ("image", SIDECAR_TYPE_IMAGE),
505            ("photo", SIDECAR_TYPE_IMAGE),
506            ("json", SIDECAR_TYPE_JSON),
507        ],
508    )
509}
510
511pub fn parse_sidecar_codec(value: &serde_json::Value) -> Result<u8> {
512    parse_sidecar_registry_value(
513        value,
514        "sidecar codec",
515        &[
516            ("raw", SIDECAR_CODEC_RAW),
517            ("none", SIDECAR_CODEC_RAW),
518            ("brotli", SIDECAR_CODEC_BROTLI),
519            ("br", SIDECAR_CODEC_BROTLI),
520            ("zstd", SIDECAR_CODEC_ZSTD),
521            ("zstandard", SIDECAR_CODEC_ZSTD),
522            ("avif", SIDECAR_CODEC_AVIF),
523        ],
524    )
525}
526
527pub fn validate_sidecar_registry_ranges(item_type: u8, codec: u8) -> Result<()> {
528    if (4..=31).contains(&item_type) {
529        bail!("sidecar item type {item_type} is reserved");
530    }
531    if (4..=31).contains(&codec) {
532        bail!("sidecar codec {codec} is reserved");
533    }
534    Ok(())
535}
536
537pub fn validate_sidecar_name(value: &str, label: &str) -> Result<()> {
538    if value.chars().any(|ch| {
539        let code = ch as u32;
540        code <= 0x1f || code == 0x7f
541    }) {
542        bail!("sidecar {label} must not contain control characters");
543    }
544    Ok(())
545}
546
547pub fn looks_like_avif(bytes: &[u8]) -> bool {
548    if bytes.len() < 16 || &bytes[4..8] != b"ftyp" {
549        return false;
550    }
551    bytes[8..]
552        .chunks(4)
553        .any(|chunk| chunk == b"avif" || chunk == b"avis")
554}
555
556pub fn validate_sidecar_item_payload(
557    item_type: u8,
558    codec: u8,
559    raw_byte_length: u32,
560    stored: &[u8],
561    mime: &str,
562) -> Result<()> {
563    validate_sidecar_registry_ranges(item_type, codec)?;
564    if codec == SIDECAR_CODEC_AVIF && item_type != SIDECAR_TYPE_IMAGE {
565        bail!("AVIF sidecar codec is only valid for image items");
566    }
567    if item_type == SIDECAR_TYPE_IMAGE && codec != SIDECAR_CODEC_AVIF {
568        bail!("image sidecar items must use AVIF in this version");
569    }
570    if matches!(item_type, SIDECAR_TYPE_UTF8_TEXT | SIDECAR_TYPE_JSON)
571        && codec == SIDECAR_CODEC_AVIF
572    {
573        bail!("text and JSON sidecar items cannot use AVIF");
574    }
575    if codec == SIDECAR_CODEC_RAW && raw_byte_length != stored.len() as u32 {
576        bail!("raw sidecar item rawByteLength must equal stored length");
577    }
578    if item_type == SIDECAR_TYPE_UTF8_TEXT && codec == SIDECAR_CODEC_RAW {
579        std::str::from_utf8(stored).context("raw UTF-8 text sidecar item is not valid UTF-8")?;
580    }
581    if item_type == SIDECAR_TYPE_JSON && codec == SIDECAR_CODEC_RAW {
582        serde_json::from_slice::<serde_json::Value>(stored)
583            .context("raw JSON sidecar item is not valid JSON")?;
584    }
585    if item_type == SIDECAR_TYPE_IMAGE && codec == SIDECAR_CODEC_AVIF {
586        if !looks_like_avif(stored) {
587            bail!("AVIF sidecar image does not look like an AVIF file");
588        }
589        if !mime.is_empty() && !mime.eq_ignore_ascii_case("image/avif") {
590            bail!("AVIF sidecar image MIME type must be image/avif");
591        }
592    }
593    Ok(())
594}
595
596pub fn default_sidecar_mime(item_type: u8, codec: u8) -> &'static str {
597    match (item_type, codec) {
598        (SIDECAR_TYPE_UTF8_TEXT, _) => "text/plain;charset=utf-8",
599        (SIDECAR_TYPE_JSON, _) => "application/json",
600        (SIDECAR_TYPE_IMAGE, SIDECAR_CODEC_AVIF) => "image/avif",
601        _ => "",
602    }
603}
604
605fn write_ascii_padded(target: &mut [u8], offset: usize, length: usize, value: &str) {
606    let end = offset.saturating_add(length).min(target.len());
607    target[offset..end].fill(0);
608    let clean: String = value
609        .chars()
610        .map(|ch| {
611            let code = ch as u32;
612            if (0x20..=0x7e).contains(&code) {
613                ch
614            } else {
615                ' '
616            }
617        })
618        .collect();
619    for (index, byte) in clean
620        .as_bytes()
621        .iter()
622        .copied()
623        .take(end - offset)
624        .enumerate()
625    {
626        target[offset + index] = byte;
627    }
628}
629
630fn write_u16be(target: &mut [u8], offset: usize, value: u16) {
631    target[offset] = (value >> 8) as u8;
632    target[offset + 1] = value as u8;
633}
634
635fn write_u32be(target: &mut [u8], offset: usize, value: u32) {
636    target[offset] = (value >> 24) as u8;
637    target[offset + 1] = (value >> 16) as u8;
638    target[offset + 2] = (value >> 8) as u8;
639    target[offset + 3] = value as u8;
640}
641
642fn clamp_range(value: Option<f64>, min: f64, max: f64, fallback: f64) -> f64 {
643    let Some(number) = value else {
644        return fallback;
645    };
646    if !number.is_finite() {
647        return fallback;
648    }
649    number.clamp(min, max)
650}
651
652fn unit_to_u16(value: Option<f64>, fallback: f64) -> u16 {
653    (clamp_range(value, 0.0, 1.0, fallback) * u16::MAX as f64).round() as u16
654}
655
656fn ratio_to_permille(value: Option<f64>, min: f64, max: f64, fallback: f64) -> u16 {
657    (clamp_range(value, min, max, fallback) * 1000.0)
658        .round()
659        .clamp(0.0, u16::MAX as f64) as u16
660}
661
662fn display_header_flags(input: &PackageDisplayHeaderInput) -> u16 {
663    let mut flags = 0;
664    if input.cover_shown.unwrap_or(false) {
665        flags |= DISPLAY_HEADER_FLAG_COVER_SHOWN;
666    }
667    if input.cover_effects.unwrap_or(false) {
668        flags |= DISPLAY_HEADER_FLAG_COVER_EFFECTS;
669    }
670    if input.cover_rgb_grain.unwrap_or(false) {
671        flags |= DISPLAY_HEADER_FLAG_COVER_RGB_GRAIN;
672    }
673    if input.inner_sleeve_shown.unwrap_or(false) {
674        flags |= DISPLAY_HEADER_FLAG_INNER_SLEEVE_SHOWN;
675    }
676    if input.cover_embedded.unwrap_or(false) {
677        flags |= DISPLAY_HEADER_FLAG_COVER_EMBEDDED;
678    }
679    flags
680}
681
682fn normalized_hex_color(value: Option<&str>) -> String {
683    let raw = value.unwrap_or("").trim().trim_start_matches('#');
684    let expanded = if raw.len() == 3 {
685        raw.chars().flat_map(|ch| [ch, ch]).collect::<String>()
686    } else {
687        raw.to_string()
688    };
689    let parsed = u32::from_str_radix(&expanded, 16).unwrap_or(0xff_ffff);
690    format!(
691        "#{:02X}{:02X}{:02X}",
692        (parsed >> 16) & 0xff,
693        (parsed >> 8) & 0xff,
694        parsed & 0xff
695    )
696}
697
698pub fn build_package_display_header_bytes(input: &PackageDisplayHeaderInput) -> Vec<u8> {
699    let mut bytes = vec![0u8; DISPLAY_HEADER_LENGTH];
700    let crop = input.crop.as_ref();
701    write_ascii_padded(
702        &mut bytes,
703        0,
704        4,
705        &String::from_utf8_lossy(DISPLAY_HEADER_MAGIC),
706    );
707    bytes[4] = DISPLAY_HEADER_VERSION;
708    bytes[5] = DISPLAY_HEADER_LENGTH as u8;
709    write_u16be(&mut bytes, 6, display_header_flags(input));
710    write_ascii_padded(
711        &mut bytes,
712        16,
713        40,
714        input.design_label.as_deref().unwrap_or(""),
715    );
716    write_ascii_padded(
717        &mut bytes,
718        56,
719        8,
720        input.design_class_name.as_deref().unwrap_or(""),
721    );
722    bytes[64] =
723        clamp_range(input.posterize, 2.0, 64.0, DEFAULT_COVER_POSTERIZE_LEVELS).round() as u8;
724    write_u16be(
725        &mut bytes,
726        65,
727        ratio_to_permille(input.saturation, 0.0, 2.0, DEFAULT_COVER_SATURATION),
728    );
729    write_u16be(
730        &mut bytes,
731        67,
732        ratio_to_permille(input.contrast, 0.0, 2.0, DEFAULT_COVER_CONTRAST),
733    );
734    write_u16be(
735        &mut bytes,
736        69,
737        ratio_to_permille(
738            input.rgb_grain_opacity,
739            COVER_RGB_GRAIN_OPACITY_MIN,
740            1.0,
741            DEFAULT_COVER_RGB_GRAIN_OPACITY,
742        ),
743    );
744    bytes[71] = clamp_range(input.rgb_grain_blend_index, 0.0, u8::MAX as f64, 0.0).round() as u8;
745    write_u16be(
746        &mut bytes,
747        72,
748        unit_to_u16(crop.and_then(|item| item.x), 0.5),
749    );
750    write_u16be(
751        &mut bytes,
752        74,
753        unit_to_u16(crop.and_then(|item| item.y), 0.5),
754    );
755    write_u16be(
756        &mut bytes,
757        76,
758        ratio_to_permille(
759            crop.and_then(|item| item.zoom),
760            COVER_CROP_ZOOM_MIN,
761            COVER_CROP_ZOOM_MAX,
762            1.0,
763        ),
764    );
765    bytes[78] = input
766        .quantizer
767        .map(|value| clamp_range(Some(value), 0.0, 63.0, 32.0).round() as u8)
768        .unwrap_or(255);
769    bytes[79] = 0;
770    write_u16be(
771        &mut bytes,
772        80,
773        input
774            .inner
775            .map(|value| clamp_range(Some(value), 1.0, 576.0, 576.0).round() as u16)
776            .unwrap_or(0),
777    );
778    write_ascii_padded(
779        &mut bytes,
780        82,
781        7,
782        &normalized_hex_color(input.sleeve_tone_color.as_deref()),
783    );
784    write_u16be(
785        &mut bytes,
786        89,
787        ratio_to_permille(input.sleeve_sparkle_opacity, 0.0, 1.0, 1.0),
788    );
789
790    let payload_crc = record_core::crc32_ieee(&bytes[16..]);
791    write_u32be(&mut bytes, 8, payload_crc);
792    let mut header_for_crc = bytes.clone();
793    header_for_crc[12..16].fill(0);
794    let header_crc = record_core::crc32_ieee(&header_for_crc);
795    write_u32be(&mut bytes, 12, header_crc);
796    bytes
797}
798
799pub fn build_package_display_header_bytes_from_json(raw: &str) -> Result<Vec<u8>> {
800    let input: PackageDisplayHeaderInput =
801        serde_json::from_str(raw).context("display header options JSON is invalid")?;
802    Ok(build_package_display_header_bytes(&input))
803}
804
805pub fn build_package_display_header_item_json_from_input(
806    input: &PackageDisplayHeaderInput,
807) -> serde_json::Value {
808    let bytes = build_package_display_header_bytes(input);
809    serde_json::json!({
810        "type": "opaque",
811        "codec": "raw",
812        "name": DISPLAY_HEADER_NAME,
813        "mime": DISPLAY_HEADER_MIME,
814        "rawByteLength": bytes.len(),
815        "dataBase64": general_purpose::STANDARD.encode(bytes),
816    })
817}
818
819pub fn build_package_display_header_item_json(raw: &str) -> Result<String> {
820    let input: PackageDisplayHeaderInput =
821        serde_json::from_str(raw).context("display header options JSON is invalid")?;
822    Ok(build_package_display_header_item_json_from_input(&input).to_string())
823}
824
825fn clean_package_text(value: Option<&str>) -> String {
826    value
827        .unwrap_or("")
828        .split_whitespace()
829        .collect::<Vec<_>>()
830        .join(" ")
831}
832
833fn package_photo_item_name(value: Option<&str>) -> String {
834    let raw = value.unwrap_or("photo").trim();
835    let stem = raw
836        .rsplit_once('.')
837        .map(|(prefix, _)| prefix)
838        .unwrap_or(raw)
839        .trim();
840    format!("{}.avif", if stem.is_empty() { "photo" } else { stem })
841}
842
843fn package_photo_is_ready(photo: &PackageMetadataPhotoInput) -> bool {
844    matches!(photo.status.as_deref(), Some("ready") | Some("too-large"))
845        && photo.has_avif.unwrap_or(false)
846}
847
848fn rounded_positive_dimension(primary: Option<f64>, fallback: Option<f64>) -> f64 {
849    let value = primary.or(fallback).unwrap_or(COVER_PREVIEW_SIZE);
850    if value.is_finite() {
851        value.round().max(1.0)
852    } else {
853        COVER_PREVIEW_SIZE
854    }
855}
856
857fn cover_crop_metadata_json(cover: &PackageMetadataCoverInput) -> serde_json::Value {
858    let crop = cover.crop.as_ref();
859    let x = clamp_range(crop.and_then(|item| item.x), 0.0, 1.0, 0.5);
860    let y = clamp_range(crop.and_then(|item| item.y), 0.0, 1.0, 0.5);
861    let zoom = clamp_range(
862        crop.and_then(|item| item.zoom),
863        COVER_CROP_ZOOM_MIN,
864        COVER_CROP_ZOOM_MAX,
865        1.0,
866    );
867    let source_width = rounded_positive_dimension(cover.source_width, cover.width);
868    let source_height = rounded_positive_dimension(cover.source_height, cover.height);
869    let output_size = clamp_range(cover.inner, 1.0, 576.0, COVER_PREVIEW_SIZE).round();
870    let scale = (output_size / source_width).max(output_size / source_height) * zoom;
871    let sw = source_width.min(output_size / scale);
872    let sh = source_height.min(output_size / scale);
873    let max_sx = (source_width - sw).max(0.0);
874    let max_sy = (source_height - sh).max(0.0);
875    serde_json::json!({
876        "normalized": {
877            "x": (x * 1_000_000.0).round() / 1_000_000.0,
878            "y": (y * 1_000_000.0).round() / 1_000_000.0,
879            "zoom": (zoom * 1_000_000.0).round() / 1_000_000.0,
880        },
881        "source": {
882            "width": source_width as u32,
883            "height": source_height as u32,
884        },
885        "rectangle": {
886            "x": (max_sx * x).round() as u32,
887            "y": (max_sy * y).round() as u32,
888            "width": sw.round() as u32,
889            "height": sh.round() as u32,
890        },
891        "output": {
892            "width": output_size as u32,
893            "height": output_size as u32,
894        },
895    })
896}
897
898fn package_cover_metadata_json(input: &PackageMetadataInput) -> Option<serde_json::Value> {
899    let cover = input.cover.as_ref()?;
900    if !cover.has_file.unwrap_or(false) || !cover.has_preview.unwrap_or(false) {
901        return None;
902    }
903
904    let source_photo_id = clean_package_text(cover.source_photo_id.as_deref());
905    let source_photo = if source_photo_id.is_empty() {
906        None
907    } else {
908        input
909            .photos
910            .iter()
911            .enumerate()
912            .find(|(_, photo)| photo.id.as_deref() == Some(source_photo_id.as_str()))
913    };
914    let source = if let Some((index, photo)) = source_photo {
915        serde_json::json!({
916            "type": "photoInsert",
917            "position": index + 1,
918            "sourceName": photo.name.as_deref().unwrap_or(""),
919            "itemName": package_photo_item_name(photo.name.as_deref()),
920        })
921    } else {
922        serde_json::json!({
923            "type": if cover.embedded.unwrap_or(false) { "embeddedCover" } else { "coverUpload" },
924            "sourceName": cover.name.as_deref().unwrap_or(""),
925            "itemName": if cover.embedded.unwrap_or(false) { "album-cover.avif" } else { "" },
926        })
927    };
928
929    Some(serde_json::json!({
930        "role": "cover",
931        "source": source,
932        "crop": cover_crop_metadata_json(cover),
933        "display": {
934            "shown": cover.shown.unwrap_or(false),
935            "design": cover.design_label.as_deref().unwrap_or(""),
936            "effectsEnabled": cover.effects_enabled.unwrap_or(false),
937        },
938    }))
939}
940
941pub fn build_package_metadata_items_json_from_input(
942    input: &PackageMetadataInput,
943) -> serde_json::Value {
944    let photo_credits = input
945        .photos
946        .iter()
947        .filter(|photo| package_photo_is_ready(photo))
948        .enumerate()
949        .filter_map(|(index, photo)| {
950            let credit = clean_package_text(photo.credit.as_deref());
951            if credit.is_empty() {
952                return None;
953            }
954            Some(serde_json::json!({
955                "position": index + 1,
956                "sourceName": photo.name.as_deref().unwrap_or(""),
957                "itemName": package_photo_item_name(photo.name.as_deref()),
958                "credit": credit,
959            }))
960        })
961        .collect::<Vec<_>>();
962    let cover = package_cover_metadata_json(input);
963    if photo_credits.is_empty() && cover.is_none() {
964        return serde_json::Value::Array(Vec::new());
965    }
966
967    let mut metadata = serde_json::json!({
968        "kind": "bitneedle.packageMetadata",
969        "version": 1,
970    });
971    if let Some(cover) = cover {
972        metadata["cover"] = cover;
973    }
974    if !photo_credits.is_empty() {
975        metadata["photoCredits"] = serde_json::Value::Array(photo_credits);
976    }
977
978    serde_json::json!([{
979        "type": "json",
980        "codec": "raw",
981        "name": PACKAGE_METADATA_ITEM_NAME,
982        "mime": PACKAGE_METADATA_MIME,
983        "json": metadata,
984    }])
985}
986
987pub fn build_package_metadata_items_json(raw: &str) -> Result<String> {
988    let input: PackageMetadataInput =
989        serde_json::from_str(raw).context("package metadata options JSON is invalid")?;
990    Ok(build_package_metadata_items_json_from_input(&input).to_string())
991}
992
993pub fn build_package_photo_item_json_from_input(
994    input: &PackagePhotoItemInput,
995    avif_bytes: &[u8],
996) -> serde_json::Value {
997    serde_json::json!({
998        "type": "image",
999        "codec": "avif",
1000        "name": package_photo_item_name(input.name.as_deref()),
1001        "mime": PACKAGE_PHOTO_MIME,
1002        "dataBase64": general_purpose::STANDARD.encode(avif_bytes),
1003    })
1004}
1005
1006pub fn build_package_photo_item_json(options_json: &str, avif_bytes: &[u8]) -> Result<String> {
1007    let input: PackagePhotoItemInput =
1008        serde_json::from_str(options_json).context("package photo item options JSON is invalid")?;
1009    Ok(build_package_photo_item_json_from_input(&input, avif_bytes).to_string())
1010}
1011
1012pub fn build_package_cover_item_json(avif_bytes: &[u8]) -> String {
1013    serde_json::json!({
1014        "type": "image",
1015        "codec": "avif",
1016        "name": PACKAGE_COVER_ITEM_NAME,
1017        "mime": PACKAGE_PHOTO_MIME,
1018        "dataBase64": general_purpose::STANDARD.encode(avif_bytes),
1019    })
1020    .to_string()
1021}
1022
1023pub fn resolve_package_image_encode_cache_key_json(options_json: &str) -> Result<String> {
1024    let input: PackageImageCacheKeyInput = serde_json::from_str(options_json)
1025        .context("package image cache key options are invalid")?;
1026    let key = resolve_package_image_encode_cache_key(&input)?;
1027    serde_json::to_string(&key).context("failed to serialize package image cache key")
1028}
1029
1030pub fn resolve_package_image_encode_cache_key(
1031    input: &PackageImageCacheKeyInput,
1032) -> Result<PackageImageEncodeCacheKey> {
1033    let kind = input
1034        .kind
1035        .as_deref()
1036        .map(str::trim)
1037        .unwrap_or("photo")
1038        .to_ascii_lowercase();
1039    let file_key = package_file_cache_key(input);
1040    let quantizer = package_photo_quantizer(input.quantizer.as_ref());
1041    let inner = package_photo_inner(input.inner.as_ref());
1042    let crop = normalize_package_crop(input.crop.as_ref());
1043    let crop_key = package_crop_cache_key(&crop);
1044    let color_mode = normalize_package_color_mode(input.color_mode.as_deref());
1045    let key = if kind == "cover" {
1046        format!("{file_key}:cover:{quantizer}:{inner}:{crop_key}:{color_mode}")
1047    } else {
1048        format!("{file_key}:{quantizer}:{inner}:{crop_key}:{color_mode}")
1049    };
1050
1051    Ok(PackageImageEncodeCacheKey {
1052        key,
1053        file_key,
1054        crop_key,
1055        quantizer,
1056        inner,
1057        crop,
1058        monochrome: package_color_mode_is_monochrome(&color_mode),
1059        color_mode,
1060    })
1061}
1062
1063pub fn resolve_package_best_fit_cache_key_json(options_json: &str) -> Result<String> {
1064    let input: PackageBestFitCacheKeyInput = serde_json::from_str(options_json)
1065        .context("package best-fit cache key options are invalid")?;
1066    let key = resolve_package_best_fit_cache_key(&input)?;
1067    serde_json::to_string(&key).context("failed to serialize package best-fit cache key")
1068}
1069
1070pub fn resolve_package_best_fit_cache_key(
1071    input: &PackageBestFitCacheKeyInput,
1072) -> Result<PackageBestFitCacheKey> {
1073    let kind = input
1074        .kind
1075        .as_deref()
1076        .map(str::trim)
1077        .filter(|value| !value.is_empty())
1078        .unwrap_or("photo")
1079        .to_string();
1080    let file_key = package_file_cache_key_from_parts(
1081        input.file_name.as_deref(),
1082        input.file_size.as_ref(),
1083        input.file_last_modified.as_ref(),
1084    );
1085    let budget_bytes = package_u64(input.budget_bytes.as_ref());
1086    let inner = package_photo_inner(input.inner.as_ref());
1087    let crop_key = input
1088        .crop_key
1089        .as_deref()
1090        .filter(|value| !value.is_empty())
1091        .unwrap_or("fit")
1092        .to_string();
1093    let color_mode = normalize_package_color_mode(input.color_mode.as_deref());
1094    let key = [
1095        kind.as_str(),
1096        &format!(
1097            "fit-v7-square-crop-qbest{}-{}-{}",
1098            PACKAGE_PHOTO_SEARCH_START_QUANTIZER,
1099            PACKAGE_PHOTO_SEARCH_MIN_QUANTIZER,
1100            PACKAGE_PHOTO_MAX_QUANTIZER
1101        ),
1102        file_key.as_str(),
1103        &budget_bytes.to_string(),
1104        &inner.to_string(),
1105        crop_key.as_str(),
1106        color_mode.as_str(),
1107    ]
1108    .join(":");
1109
1110    Ok(PackageBestFitCacheKey {
1111        key,
1112        file_key,
1113        budget_bytes,
1114        inner,
1115        crop_key,
1116        color_mode,
1117    })
1118}
1119
1120pub fn package_quantizer_search_plan_json(options_json: &str) -> Result<String> {
1121    let input: PackageQuantizerSearchPlanInput = serde_json::from_str(options_json)
1122        .context("package quantizer search options are invalid")?;
1123    let plan = package_quantizer_search_plan(&input);
1124    serde_json::to_string(&plan).context("failed to serialize package quantizer search plan")
1125}
1126
1127pub fn package_quantizer_search_plan(
1128    input: &PackageQuantizerSearchPlanInput,
1129) -> PackageQuantizerSearchPlan {
1130    let min_q = package_effective_min_quantizer(input.min_quantizer.as_ref());
1131    let max_q = package_photo_quantizer(input.max_quantizer.as_ref()).max(min_q);
1132    let start_q = package_photo_quantizer(input.start_quantizer.as_ref()).clamp(min_q, max_q);
1133    let radius = package_photo_quantizer(input.refinement_radius.as_ref())
1134        .min(PACKAGE_PHOTO_MAX_QUANTIZER)
1135        .max(0);
1136    let radius = if input.refinement_radius.is_some() {
1137        radius
1138    } else {
1139        PACKAGE_PHOTO_SEARCH_REFINEMENT_RADIUS
1140    };
1141
1142    let trials = package_quantizer_trials_by_quantizer(input, min_q, max_q);
1143    let best_q = best_package_quantizer_fit(&trials);
1144    let done = |best_q: Option<u8>| PackageQuantizerSearchPlan {
1145        min_quantizer: min_q,
1146        max_quantizer: max_q,
1147        start_quantizer: start_q,
1148        done: true,
1149        next_quantizer: None,
1150        note: String::new(),
1151        best_quantizer: best_q,
1152    };
1153    let next = |quantizer: u8, note: &str, best_q: Option<u8>| PackageQuantizerSearchPlan {
1154        min_quantizer: min_q,
1155        max_quantizer: max_q,
1156        start_quantizer: start_q,
1157        done: false,
1158        next_quantizer: Some(quantizer),
1159        note: note.to_string(),
1160        best_quantizer: best_q,
1161    };
1162
1163    if !trials.contains_key(&start_q) {
1164        return next(start_q, "start", best_q);
1165    }
1166    let first_fits = trials
1167        .get(&start_q)
1168        .is_some_and(|trial| trial.fits.unwrap_or(false));
1169    if first_fits {
1170        if start_q != min_q && !trials.contains_key(&min_q) {
1171            return next(min_q, "floor", best_q);
1172        }
1173        if start_q != min_q
1174            && !trials
1175                .get(&min_q)
1176                .is_some_and(|trial| trial.fits.unwrap_or(false))
1177        {
1178            if let Some(mid) = next_missing_binary_quantizer(min_q, start_q, &trials) {
1179                return next(mid, "bisect", best_q);
1180            }
1181        }
1182        if let Some(refine_q) = next_missing_refinement_quantizer(best_q, min_q, radius, &trials) {
1183            return next(refine_q, "refine", best_q);
1184        }
1185        return done(best_q);
1186    }
1187
1188    if start_q == max_q {
1189        return done(best_q);
1190    }
1191    if !trials.contains_key(&max_q) {
1192        return next(max_q, "ceiling", best_q);
1193    }
1194    if !trials
1195        .get(&max_q)
1196        .is_some_and(|trial| trial.fits.unwrap_or(false))
1197    {
1198        return done(best_q);
1199    }
1200    if let Some(mid) = next_missing_binary_quantizer(start_q, max_q, &trials) {
1201        return next(mid, "bisect", best_q);
1202    }
1203    if let Some(refine_q) = next_missing_refinement_quantizer(best_q, min_q, radius, &trials) {
1204        return next(refine_q, "refine", best_q);
1205    }
1206    done(best_q)
1207}
1208
1209pub fn package_fit_budget_json(options_json: &str) -> Result<String> {
1210    let input: PackageFitBudgetInput =
1211        serde_json::from_str(options_json).context("package fit budget options are invalid")?;
1212    let budget = package_fit_budget(&input);
1213    serde_json::to_string(&budget).context("failed to serialize package fit budget")
1214}
1215
1216pub fn package_fit_budget(input: &PackageFitBudgetInput) -> PackageFitBudget {
1217    let capacity = package_u64(input.capacity.as_ref());
1218    let base_bts1_bytes = package_u64(input.base_bts1_bytes.as_ref());
1219    let photo_count = package_u64(input.photo_count.as_ref());
1220    let available_bytes = capacity.saturating_sub(base_bts1_bytes);
1221    let cover_budget_bytes = if capacity > 0 {
1222        available_bytes / PACKAGE_COVER_MAX_BUDGET_DIVISOR
1223    } else {
1224        0
1225    };
1226    let remaining_bytes = capacity.saturating_sub(base_bts1_bytes);
1227    let per_photo_budget_bytes = if capacity > 0 && photo_count > 0 {
1228        remaining_bytes / photo_count
1229    } else {
1230        0
1231    };
1232    PackageFitBudget {
1233        capacity,
1234        base_bts1_bytes,
1235        available_bytes,
1236        remaining_bytes,
1237        cover_budget_bytes,
1238        photo_count,
1239        per_photo_budget_bytes,
1240    }
1241}
1242
1243pub fn build_package_sidecar_render_options_json(options_json: &str) -> Result<String> {
1244    let input: PackageSidecarRenderOptionsInput =
1245        serde_json::from_str(options_json).context("package sidecar render options are invalid")?;
1246    Ok(build_package_sidecar_render_options(&input).to_string())
1247}
1248
1249pub fn build_package_sidecar_render_options(
1250    input: &PackageSidecarRenderOptionsInput,
1251) -> serde_json::Value {
1252    let payload_bytes = package_u64(input.payload_bytes.as_ref());
1253    let capacity_bytes = package_u64(input.capacity_bytes.as_ref());
1254    let seed = package_u64(input.seed.as_ref());
1255    let utilization = if capacity_bytes > 0 && payload_bytes > 0 {
1256        ((payload_bytes as f64) / (capacity_bytes as f64)).clamp(0.0, 1.0)
1257    } else {
1258        0.0
1259    };
1260    let strength = if capacity_bytes > 0 {
1261        0.12 + utilization * 0.36
1262    } else {
1263        0.22
1264    };
1265    let strength = ((strength.clamp(0.0, 0.5) * 100.0).round()) / 100.0;
1266    let grain = if utilization > 0.85 {
1267        5
1268    } else if utilization > 0.55 {
1269        4
1270    } else {
1271        3
1272    };
1273
1274    let mut sidecar = serde_json::Map::new();
1275    sidecar.insert("seed".to_string(), serde_json::json!(seed));
1276    sidecar.insert(
1277        "carriers".to_string(),
1278        serde_json::json!(PACKAGE_SIDECAR_CARRIERS.to_vec()),
1279    );
1280    sidecar.insert(
1281        "scheme".to_string(),
1282        serde_json::json!(PACKAGE_SIDECAR_SCHEME),
1283    );
1284    sidecar.insert(
1285        "labelTuning".to_string(),
1286        serde_json::json!({
1287            "enabled": true,
1288            "targetLuma": 128,
1289            "strength": strength,
1290            "grain": grain,
1291        }),
1292    );
1293
1294    if let Some(bts1_base64) = input
1295        .bts1_base64
1296        .as_deref()
1297        .map(str::trim)
1298        .filter(|value| !value.is_empty())
1299    {
1300        sidecar.insert("bts1Base64".to_string(), serde_json::json!(bts1_base64));
1301    } else {
1302        sidecar.insert(
1303            "items".to_string(),
1304            input
1305                .items
1306                .clone()
1307                .unwrap_or_else(|| serde_json::Value::Array(Vec::new())),
1308        );
1309    }
1310
1311    serde_json::json!({ "sidecar": serde_json::Value::Object(sidecar) })
1312}
1313
1314pub fn package_preserved_pattern_items_json(decoded_json: &str) -> Result<String> {
1315    let decoded: serde_json::Value =
1316        serde_json::from_str(decoded_json).context("decoded sidecar JSON is invalid")?;
1317    let items = package_preserved_pattern_items(&decoded)?;
1318    serde_json::to_string(&items).context("failed to serialize preserved pattern items")
1319}
1320
1321pub fn package_preserved_pattern_items(
1322    decoded: &serde_json::Value,
1323) -> Result<Vec<serde_json::Value>> {
1324    let Some(items) = decoded.get("items").and_then(serde_json::Value::as_array) else {
1325        return Ok(Vec::new());
1326    };
1327    let mut preserved = Vec::new();
1328    for item in items {
1329        if package_decoded_item_is_pattern_map(item) {
1330            preserved.push(package_preserved_pattern_item(item)?);
1331        }
1332    }
1333    Ok(preserved)
1334}
1335
1336fn package_decoded_item_is_pattern_map(item: &serde_json::Value) -> bool {
1337    decoded_item_text_field(item, "name")
1338        .is_some_and(|value| value == PACKAGE_PATTERN_SIDECAR_ITEM_NAME)
1339        || decoded_item_text_field(item, "mime")
1340            .is_some_and(|value| value == PACKAGE_PATTERN_SIDECAR_MIME)
1341}
1342
1343fn package_preserved_pattern_item(item: &serde_json::Value) -> Result<serde_json::Value> {
1344    let item_type = decoded_item_u8_field(item, "itemType")?;
1345    let codec = decoded_item_u8_field(item, "codec")?;
1346    let mut preserved = serde_json::Map::new();
1347    preserved.insert(
1348        "type".to_string(),
1349        package_sidecar_input_type_value(item_type),
1350    );
1351    preserved.insert(
1352        "codec".to_string(),
1353        package_sidecar_input_codec_value(codec),
1354    );
1355    preserved.insert(
1356        "name".to_string(),
1357        serde_json::Value::String(
1358            decoded_item_text_field(item, "name")
1359                .filter(|value| !value.is_empty())
1360                .unwrap_or(PACKAGE_PATTERN_SIDECAR_ITEM_NAME)
1361                .to_string(),
1362        ),
1363    );
1364    preserved.insert(
1365        "mime".to_string(),
1366        serde_json::Value::String(
1367            decoded_item_text_field(item, "mime")
1368                .filter(|value| !value.is_empty())
1369                .unwrap_or(PACKAGE_PATTERN_SIDECAR_MIME)
1370                .to_string(),
1371        ),
1372    );
1373    preserved.insert(
1374        "dataBase64".to_string(),
1375        serde_json::Value::String(
1376            decoded_item_text_field(item, "storedDataBase64")
1377                .context("pattern sidecar item is missing storedDataBase64")?
1378                .to_string(),
1379        ),
1380    );
1381    if let Some(raw_byte_length) = decoded_item_optional_u64_field(item, "rawByteLength") {
1382        preserved.insert(
1383            "rawByteLength".to_string(),
1384            serde_json::json!(raw_byte_length),
1385        );
1386    }
1387    if let Some(flags) = decoded_item_optional_u64_field(item, "flags").filter(|value| *value != 0)
1388    {
1389        preserved.insert("flags".to_string(), serde_json::json!(flags));
1390    }
1391    Ok(serde_json::Value::Object(preserved))
1392}
1393
1394fn package_sidecar_input_type_value(item_type: u8) -> serde_json::Value {
1395    match item_type {
1396        SIDECAR_TYPE_OPAQUE => serde_json::json!("opaque"),
1397        SIDECAR_TYPE_UTF8_TEXT => serde_json::json!("text"),
1398        SIDECAR_TYPE_IMAGE => serde_json::json!("image"),
1399        SIDECAR_TYPE_JSON => serde_json::json!("json"),
1400        value => serde_json::json!(value),
1401    }
1402}
1403
1404fn package_sidecar_input_codec_value(codec: u8) -> serde_json::Value {
1405    match codec {
1406        SIDECAR_CODEC_RAW => serde_json::json!("raw"),
1407        SIDECAR_CODEC_BROTLI => serde_json::json!("brotli"),
1408        SIDECAR_CODEC_ZSTD => serde_json::json!("zstd"),
1409        SIDECAR_CODEC_AVIF => serde_json::json!("avif"),
1410        value => serde_json::json!(value),
1411    }
1412}
1413
1414fn decoded_item_u8_field(item: &serde_json::Value, field: &str) -> Result<u8> {
1415    let value = item
1416        .get(field)
1417        .and_then(serde_json::Value::as_u64)
1418        .with_context(|| format!("decoded sidecar item is missing numeric {field}"))?;
1419    u8::try_from(value).with_context(|| format!("decoded sidecar item {field} exceeds u8 range"))
1420}
1421
1422fn decoded_item_optional_u64_field(item: &serde_json::Value, field: &str) -> Option<u64> {
1423    item.get(field).and_then(serde_json::Value::as_u64)
1424}
1425
1426fn decoded_item_text_field<'a>(item: &'a serde_json::Value, field: &str) -> Option<&'a str> {
1427    item.get(field)
1428        .and_then(serde_json::Value::as_str)
1429        .map(str::trim)
1430        .filter(|value| !value.is_empty())
1431}
1432
1433fn package_quantizer_trials_by_quantizer(
1434    input: &PackageQuantizerSearchPlanInput,
1435    min_q: u8,
1436    max_q: u8,
1437) -> BTreeMap<u8, PackageQuantizerTrialInput> {
1438    let mut trials = BTreeMap::new();
1439    for trial in &input.trials {
1440        let q = package_photo_quantizer(trial.quantizer.as_ref()).clamp(min_q, max_q);
1441        trials.insert(q, trial.clone());
1442    }
1443    trials
1444}
1445
1446fn best_package_quantizer_fit(trials: &BTreeMap<u8, PackageQuantizerTrialInput>) -> Option<u8> {
1447    let mut best = None;
1448    for (q, trial) in trials {
1449        if !trial.fits.unwrap_or(false) {
1450            continue;
1451        }
1452        let Some(current_q) = best else {
1453            best = Some(*q);
1454            continue;
1455        };
1456        if *q < current_q
1457            || (*q == current_q
1458                && package_trial_budget_ratio(trial)
1459                    > trials
1460                        .get(&current_q)
1461                        .map(package_trial_budget_ratio)
1462                        .unwrap_or(1.0))
1463        {
1464            best = Some(*q);
1465        }
1466    }
1467    best
1468}
1469
1470fn next_missing_binary_quantizer(
1471    mut low: u8,
1472    mut high: u8,
1473    trials: &BTreeMap<u8, PackageQuantizerTrialInput>,
1474) -> Option<u8> {
1475    while high.saturating_sub(low) > 1 {
1476        let mid = low + (high - low) / 2;
1477        let Some(trial) = trials.get(&mid) else {
1478            return Some(mid);
1479        };
1480        if trial.fits.unwrap_or(false) {
1481            high = mid;
1482        } else {
1483            low = mid;
1484        }
1485    }
1486    None
1487}
1488
1489fn next_missing_refinement_quantizer(
1490    best_q: Option<u8>,
1491    min_q: u8,
1492    radius: u8,
1493    trials: &BTreeMap<u8, PackageQuantizerTrialInput>,
1494) -> Option<u8> {
1495    let best_q = best_q?;
1496    let lower = best_q.saturating_sub(radius).max(min_q);
1497    (lower..best_q).find(|q| !trials.contains_key(q))
1498}
1499
1500fn package_trial_budget_ratio(trial: &PackageQuantizerTrialInput) -> f64 {
1501    let budget = package_number(trial.budget_bytes.as_ref()).unwrap_or(0.0);
1502    let contribution = package_number(trial.contribution_bytes.as_ref())
1503        .or_else(|| package_number(trial.bts1_bytes.as_ref()))
1504        .unwrap_or(0.0);
1505    if budget > 0.0 {
1506        contribution / budget
1507    } else {
1508        1.0
1509    }
1510}
1511
1512fn package_file_cache_key(input: &PackageImageCacheKeyInput) -> String {
1513    package_file_cache_key_from_parts(
1514        input.file_name.as_deref(),
1515        input.file_size.as_ref(),
1516        input.file_last_modified.as_ref(),
1517    )
1518}
1519
1520fn package_file_cache_key_from_parts(
1521    file_name: Option<&str>,
1522    file_size: Option<&serde_json::Value>,
1523    file_last_modified: Option<&serde_json::Value>,
1524) -> String {
1525    format!(
1526        "{}:{}:{}",
1527        file_name.unwrap_or(""),
1528        package_u64(file_size),
1529        package_u64(file_last_modified)
1530    )
1531}
1532
1533fn package_photo_quantizer(value: Option<&serde_json::Value>) -> u8 {
1534    let Some(number) = package_number(value) else {
1535        return PACKAGE_PHOTO_DEFAULT_QUANTIZER;
1536    };
1537    number
1538        .round()
1539        .clamp(0.0, f64::from(PACKAGE_PHOTO_MAX_QUANTIZER)) as u8
1540}
1541
1542fn package_effective_min_quantizer(value: Option<&serde_json::Value>) -> u8 {
1543    package_photo_quantizer(value).max(PACKAGE_PHOTO_SEARCH_MIN_QUANTIZER)
1544}
1545
1546fn package_photo_inner(value: Option<&serde_json::Value>) -> u32 {
1547    let Some(number) = package_number(value) else {
1548        return PACKAGE_PHOTO_MAX_INNER;
1549    };
1550    number
1551        .round()
1552        .clamp(1.0, f64::from(PACKAGE_PHOTO_MAX_INNER)) as u32
1553}
1554
1555fn normalize_package_crop(value: Option<&serde_json::Value>) -> PackageNormalizedCrop {
1556    let object = value.and_then(serde_json::Value::as_object);
1557    PackageNormalizedCrop {
1558        x: package_range(object.and_then(|object| object.get("x")), 0.0, 1.0, 0.5),
1559        y: package_range(object.and_then(|object| object.get("y")), 0.0, 1.0, 0.5),
1560        zoom: package_range(
1561            object.and_then(|object| object.get("zoom")),
1562            COVER_CROP_ZOOM_MIN,
1563            COVER_CROP_ZOOM_MAX,
1564            1.0,
1565        ),
1566    }
1567}
1568
1569fn package_crop_cache_key(crop: &PackageNormalizedCrop) -> String {
1570    format!("crop-{:.4}-{:.4}-{:.4}", crop.x, crop.y, crop.zoom)
1571}
1572
1573fn normalize_package_color_mode(value: Option<&str>) -> String {
1574    match value.unwrap_or("").to_ascii_lowercase().as_str() {
1575        PACKAGE_COLOR_MODE_RGB => PACKAGE_COLOR_MODE_RGB.to_string(),
1576        PACKAGE_COLOR_MODE_GRAYSCALE => PACKAGE_COLOR_MODE_GRAYSCALE.to_string(),
1577        _ => PACKAGE_COLOR_MODE_RGB.to_string(),
1578    }
1579}
1580
1581fn package_color_mode_is_monochrome(value: &str) -> bool {
1582    value == PACKAGE_COLOR_MODE_GRAYSCALE
1583}
1584
1585fn package_range(value: Option<&serde_json::Value>, min: f64, max: f64, fallback: f64) -> f64 {
1586    package_number(value)
1587        .filter(|number| number.is_finite())
1588        .unwrap_or(fallback)
1589        .clamp(min, max)
1590}
1591
1592fn package_u64(value: Option<&serde_json::Value>) -> u64 {
1593    match value {
1594        Some(serde_json::Value::Number(number)) => number.as_u64().unwrap_or_else(|| {
1595            number
1596                .as_f64()
1597                .filter(|number| number.is_finite() && *number > 0.0)
1598                .map(|number| number.floor() as u64)
1599                .unwrap_or(0)
1600        }),
1601        Some(serde_json::Value::String(text)) => text
1602            .trim()
1603            .parse::<f64>()
1604            .ok()
1605            .filter(|number| number.is_finite() && *number > 0.0)
1606            .map(|number| number.floor() as u64)
1607            .unwrap_or(0),
1608        _ => 0,
1609    }
1610}
1611
1612fn package_number(value: Option<&serde_json::Value>) -> Option<f64> {
1613    match value {
1614        Some(serde_json::Value::Number(number)) => number.as_f64(),
1615        Some(serde_json::Value::String(text)) => text.trim().parse::<f64>().ok(),
1616        _ => None,
1617    }
1618    .filter(|number| number.is_finite())
1619}
1620
1621fn sidecar_item_stored_bytes(
1622    input: &SidecarItemInput,
1623    item_type: u8,
1624    codec: u8,
1625) -> Result<Vec<u8>> {
1626    if let Some(data_base64) = input.data_base64.as_deref() {
1627        return decode_base64_text(data_base64, "sidecar item dataBase64");
1628    }
1629    if codec == SIDECAR_CODEC_RAW && item_type == SIDECAR_TYPE_UTF8_TEXT {
1630        if let Some(text) = input.text.as_deref() {
1631            return Ok(text.as_bytes().to_vec());
1632        }
1633    }
1634    if codec == SIDECAR_CODEC_RAW && item_type == SIDECAR_TYPE_JSON {
1635        if let Some(json) = input.json.as_ref() {
1636            return serde_json::to_vec(json).context("failed to serialize sidecar JSON item");
1637        }
1638    }
1639    bail!("sidecar item requires dataBase64, except raw text/json items may use text/json")
1640}
1641
1642pub fn build_sidecar_container_from_items(items: &[SidecarItemInput]) -> Result<Vec<u8>> {
1643    if items.len() > u16::MAX as usize {
1644        bail!("sidecar item count exceeds u16 limit");
1645    }
1646    let mut item_bytes = Vec::new();
1647    for input in items {
1648        let item_type = parse_sidecar_item_type(&input.item_type)?;
1649        let codec = parse_sidecar_codec(&input.codec)?;
1650        if input.flags != 0 {
1651            bail!("sidecar item flags must be 0 in this version");
1652        }
1653        let stored = sidecar_item_stored_bytes(input, item_type, codec)?;
1654        if stored.len() > u32::MAX as usize {
1655            bail!("sidecar item stored data exceeds u32 length limit");
1656        }
1657        let raw_byte_length = if codec == SIDECAR_CODEC_RAW {
1658            u32::try_from(stored.len()).context("sidecar raw item exceeds u32 length limit")?
1659        } else {
1660            input.raw_byte_length.unwrap_or(SIDECAR_RAW_LENGTH_ABSENT)
1661        };
1662        let name = input.name.as_deref().unwrap_or("");
1663        let mime = input
1664            .mime
1665            .as_deref()
1666            .unwrap_or(default_sidecar_mime(item_type, codec));
1667        validate_sidecar_name(name, "item name")?;
1668        validate_sidecar_name(mime, "MIME type")?;
1669        validate_sidecar_item_payload(item_type, codec, raw_byte_length, &stored, mime)?;
1670        let name_bytes = name.as_bytes();
1671        let mime_bytes = mime.as_bytes();
1672        if name_bytes.len() > u16::MAX as usize {
1673            bail!("sidecar item name exceeds u16 length limit");
1674        }
1675        if mime_bytes.len() > u16::MAX as usize {
1676            bail!("sidecar item MIME type exceeds u16 length limit");
1677        }
1678        item_bytes.push(item_type);
1679        item_bytes.push(codec);
1680        item_bytes.push(input.flags);
1681        item_bytes.push(0);
1682        item_bytes.extend_from_slice(&raw_byte_length.to_be_bytes());
1683        item_bytes.extend_from_slice(&(stored.len() as u32).to_be_bytes());
1684        item_bytes.extend_from_slice(&(name_bytes.len() as u16).to_be_bytes());
1685        item_bytes.extend_from_slice(&(mime_bytes.len() as u16).to_be_bytes());
1686        item_bytes.extend_from_slice(name_bytes);
1687        item_bytes.extend_from_slice(mime_bytes);
1688        item_bytes.extend_from_slice(&stored);
1689    }
1690
1691    let total_length = 12usize
1692        .checked_add(item_bytes.len())
1693        .context("sidecar container length overflow")?;
1694    if total_length > u32::MAX as usize {
1695        bail!("sidecar container exceeds u32 length limit");
1696    }
1697    let mut out = Vec::with_capacity(total_length);
1698    out.extend_from_slice(SIDECAR_MAGIC);
1699    out.push(SIDECAR_CONTAINER_VERSION);
1700    out.push(0);
1701    out.extend_from_slice(&(items.len() as u16).to_be_bytes());
1702    out.extend_from_slice(&(total_length as u32).to_be_bytes());
1703    out.extend_from_slice(&item_bytes);
1704    validate_sidecar_container(&out)?;
1705    Ok(out)
1706}
1707
1708pub fn build_sidecar_container_from_items_json(raw: &str) -> Result<Vec<u8>> {
1709    #[derive(Deserialize)]
1710    #[serde(rename_all = "camelCase")]
1711    struct SidecarItemsWrapper {
1712        items: Vec<SidecarItemInput>,
1713    }
1714
1715    let trimmed = raw.trim();
1716    if trimmed.is_empty() {
1717        bail!("sidecar items JSON is empty");
1718    }
1719    let items = if trimmed.starts_with('[') {
1720        serde_json::from_str::<Vec<SidecarItemInput>>(trimmed)
1721            .context("sidecar items JSON must be an item array")?
1722    } else {
1723        serde_json::from_str::<SidecarItemsWrapper>(trimmed)
1724            .context("sidecar items JSON must be an item array or {\"items\": [...] }")?
1725            .items
1726    };
1727    build_sidecar_container_from_items(&items)
1728}
1729
1730fn read_u16be(bytes: &[u8], offset: usize, label: &str) -> Result<u16> {
1731    if offset + 2 > bytes.len() {
1732        bail!("{label} is truncated");
1733    }
1734    Ok(u16::from_be_bytes(
1735        bytes[offset..offset + 2].try_into().expect("slice length"),
1736    ))
1737}
1738
1739fn read_u32be(bytes: &[u8], offset: usize, label: &str) -> Result<u32> {
1740    if offset + 4 > bytes.len() {
1741        bail!("{label} is truncated");
1742    }
1743    Ok(u32::from_be_bytes(
1744        bytes[offset..offset + 4].try_into().expect("slice length"),
1745    ))
1746}
1747
1748pub fn validate_sidecar_container(bytes: &[u8]) -> Result<SidecarContainerValidation> {
1749    if bytes.len() < 12 {
1750        bail!("sidecar container is too short");
1751    }
1752    if &bytes[..4] != SIDECAR_MAGIC {
1753        bail!("sidecar container magic is unsupported");
1754    }
1755    let version = bytes[4];
1756    if version != SIDECAR_CONTAINER_VERSION {
1757        bail!("unsupported sidecar container version {version}");
1758    }
1759    let flags = bytes[5];
1760    if flags != 0 {
1761        bail!("sidecar container flags must be 0 in this version");
1762    }
1763    let item_count = read_u16be(bytes, 6, "sidecar item count")? as usize;
1764    let total_length = read_u32be(bytes, 8, "sidecar total length")? as usize;
1765    if total_length != bytes.len() {
1766        bail!("sidecar total length does not match byte stream length");
1767    }
1768
1769    let mut offset = 12usize;
1770    let mut items = Vec::with_capacity(item_count);
1771    for _ in 0..item_count {
1772        if offset + 16 > bytes.len() {
1773            bail!("sidecar item header is truncated");
1774        }
1775        let item_type = bytes[offset];
1776        let codec = bytes[offset + 1];
1777        let item_flags = bytes[offset + 2];
1778        let reserved = bytes[offset + 3];
1779        if item_flags != 0 {
1780            bail!("sidecar item flags must be 0 in this version");
1781        }
1782        if reserved != 0 {
1783            bail!("sidecar item reserved byte must be 0");
1784        }
1785        let raw_byte_length = read_u32be(bytes, offset + 4, "sidecar item raw length")?;
1786        let stored_byte_length = read_u32be(bytes, offset + 8, "sidecar item stored length")?;
1787        let name_len = read_u16be(bytes, offset + 12, "sidecar item name length")? as usize;
1788        let mime_len = read_u16be(bytes, offset + 14, "sidecar item MIME length")? as usize;
1789        offset += 16;
1790        let item_end = offset
1791            .checked_add(name_len)
1792            .and_then(|value| value.checked_add(mime_len))
1793            .and_then(|value| value.checked_add(stored_byte_length as usize))
1794            .context("sidecar item length overflow")?;
1795        if item_end > bytes.len() {
1796            bail!("sidecar item payload is truncated");
1797        }
1798        let name = std::str::from_utf8(&bytes[offset..offset + name_len])
1799            .context("sidecar item name is not valid UTF-8")?
1800            .to_string();
1801        offset += name_len;
1802        let mime = std::str::from_utf8(&bytes[offset..offset + mime_len])
1803            .context("sidecar item MIME type is not valid ASCII/UTF-8")?
1804            .to_string();
1805        offset += mime_len;
1806        let stored = &bytes[offset..offset + stored_byte_length as usize];
1807        offset += stored_byte_length as usize;
1808
1809        validate_sidecar_name(&name, "item name")?;
1810        validate_sidecar_name(&mime, "MIME type")?;
1811        validate_sidecar_item_payload(item_type, codec, raw_byte_length, stored, &mime)?;
1812        items.push(SidecarItemValidation {
1813            item_type,
1814            item_type_name: sidecar_type_name(item_type),
1815            codec,
1816            codec_name: sidecar_codec_name(codec),
1817            flags: item_flags,
1818            raw_byte_length: (raw_byte_length != SIDECAR_RAW_LENGTH_ABSENT)
1819                .then_some(raw_byte_length),
1820            stored_byte_length,
1821            name,
1822            mime,
1823        });
1824    }
1825    if offset != bytes.len() {
1826        bail!("sidecar container has trailing bytes");
1827    }
1828    Ok(SidecarContainerValidation {
1829        ok: true,
1830        version,
1831        flags,
1832        item_count,
1833        total_length,
1834        items,
1835    })
1836}
1837
1838fn decode_sidecar_item_payload(
1839    item_type: u8,
1840    codec: u8,
1841    raw_byte_length: u32,
1842    stored: &[u8],
1843) -> Result<Vec<u8>> {
1844    let decoded = match codec {
1845        SIDECAR_CODEC_RAW | SIDECAR_CODEC_AVIF => stored.to_vec(),
1846        SIDECAR_CODEC_BROTLI => {
1847            let mut reader = brotli::Decompressor::new(stored, 4096);
1848            let mut out = Vec::new();
1849            reader
1850                .read_to_end(&mut out)
1851                .context("failed to decompress Brotli sidecar item")?;
1852            out
1853        }
1854        SIDECAR_CODEC_ZSTD => bail!("zstd sidecar item extraction is not implemented"),
1855        _ => stored.to_vec(),
1856    };
1857    if raw_byte_length != SIDECAR_RAW_LENGTH_ABSENT && decoded.len() != raw_byte_length as usize {
1858        bail!(
1859            "decoded sidecar item length {} does not match declared raw length {}",
1860            decoded.len(),
1861            raw_byte_length
1862        );
1863    }
1864    if item_type == SIDECAR_TYPE_UTF8_TEXT {
1865        std::str::from_utf8(&decoded)
1866            .context("decoded UTF-8 text sidecar item is not valid UTF-8")?;
1867    }
1868    if item_type == SIDECAR_TYPE_JSON {
1869        serde_json::from_slice::<serde_json::Value>(&decoded)
1870            .context("decoded JSON sidecar item is not valid JSON")?;
1871    }
1872    Ok(decoded)
1873}
1874
1875pub fn decode_sidecar_container_items(bytes: &[u8]) -> Result<SidecarDecodedItems> {
1876    let validation = validate_sidecar_container(bytes)?;
1877    let item_count = read_u16be(bytes, 6, "sidecar item count")? as usize;
1878    let mut offset = 12usize;
1879    let mut items = Vec::with_capacity(item_count);
1880    for _ in 0..item_count {
1881        if offset + 16 > bytes.len() {
1882            bail!("sidecar item header is truncated");
1883        }
1884        let item_type = bytes[offset];
1885        let codec = bytes[offset + 1];
1886        let flags = bytes[offset + 2];
1887        let raw_byte_length = read_u32be(bytes, offset + 4, "sidecar item raw length")?;
1888        let stored_byte_length = read_u32be(bytes, offset + 8, "sidecar item stored length")?;
1889        let name_len = read_u16be(bytes, offset + 12, "sidecar item name length")? as usize;
1890        let mime_len = read_u16be(bytes, offset + 14, "sidecar item MIME length")? as usize;
1891        offset += 16;
1892        let item_end = offset
1893            .checked_add(name_len)
1894            .and_then(|value| value.checked_add(mime_len))
1895            .and_then(|value| value.checked_add(stored_byte_length as usize))
1896            .context("sidecar item length overflow")?;
1897        if item_end > bytes.len() {
1898            bail!("sidecar item payload is truncated");
1899        }
1900        let name = std::str::from_utf8(&bytes[offset..offset + name_len])
1901            .context("sidecar item name is not valid UTF-8")?
1902            .to_string();
1903        offset += name_len;
1904        let mime = std::str::from_utf8(&bytes[offset..offset + mime_len])
1905            .context("sidecar item MIME type is not valid ASCII/UTF-8")?
1906            .to_string();
1907        offset += mime_len;
1908        let stored = &bytes[offset..offset + stored_byte_length as usize];
1909        offset += stored_byte_length as usize;
1910        let decoded = decode_sidecar_item_payload(item_type, codec, raw_byte_length, stored)?;
1911        let text = if item_type == SIDECAR_TYPE_UTF8_TEXT {
1912            Some(
1913                std::str::from_utf8(&decoded)
1914                    .context("decoded UTF-8 text sidecar item is not valid UTF-8")?
1915                    .to_string(),
1916            )
1917        } else {
1918            None
1919        };
1920        let json = if item_type == SIDECAR_TYPE_JSON {
1921            Some(
1922                serde_json::from_slice::<serde_json::Value>(&decoded)
1923                    .context("decoded JSON sidecar item is not valid JSON")?,
1924            )
1925        } else {
1926            None
1927        };
1928        items.push(SidecarDecodedItem {
1929            item_type,
1930            item_type_name: sidecar_type_name(item_type),
1931            codec,
1932            codec_name: sidecar_codec_name(codec),
1933            flags,
1934            raw_byte_length: (raw_byte_length != SIDECAR_RAW_LENGTH_ABSENT)
1935                .then_some(raw_byte_length),
1936            stored_byte_length,
1937            decoded_byte_length: decoded.len(),
1938            name,
1939            mime,
1940            stored_data_base64: general_purpose::STANDARD.encode(stored),
1941            data_base64: general_purpose::STANDARD.encode(&decoded),
1942            text,
1943            json,
1944        });
1945    }
1946    if offset != bytes.len() {
1947        bail!("sidecar container has trailing bytes");
1948    }
1949    Ok(SidecarDecodedItems {
1950        ok: true,
1951        validation,
1952        items,
1953    })
1954}
1955
1956pub fn parse_sidecar_carrier(raw: &str) -> Result<SidecarCarrier> {
1957    match raw
1958        .trim()
1959        .to_ascii_lowercase()
1960        .replace([' ', '-', '_'], "")
1961        .as_str()
1962    {
1963        "label" => Ok(SidecarCarrier::Label),
1964        "intergroove" | "intragroove" | "groove" => Ok(SidecarCarrier::Intergroove),
1965        "leadindeadwax" | "leaddeadwax" | "leadin" | "leadout" | "deadwax" | "runout" => {
1966            Ok(SidecarCarrier::LeadInDeadwax)
1967        }
1968        _ => bail!("unknown sidecar carrier: {raw}"),
1969    }
1970}
1971
1972pub fn sidecar_carrier_name(carrier: SidecarCarrier) -> &'static str {
1973    match carrier {
1974        SidecarCarrier::Label => "label",
1975        SidecarCarrier::Intergroove => "intergroove",
1976        SidecarCarrier::LeadInDeadwax => "leadInDeadwax",
1977    }
1978}
1979
1980pub fn normalize_sidecar_carriers(raw: Option<&[String]>) -> Result<Vec<SidecarCarrier>> {
1981    let mut carriers = Vec::new();
1982    if let Some(raw) = raw {
1983        for value in raw {
1984            let carrier = parse_sidecar_carrier(value)?;
1985            if !carriers.contains(&carrier) {
1986                carriers.push(carrier);
1987            }
1988        }
1989    } else {
1990        carriers.push(SidecarCarrier::Label);
1991        carriers.push(SidecarCarrier::Intergroove);
1992    }
1993    if carriers.is_empty() {
1994        bail!("sidecar carriers must not be empty");
1995    }
1996    Ok(carriers)
1997}
1998
1999pub fn default_sidecar_carriers() -> Vec<SidecarCarrier> {
2000    vec![SidecarCarrier::Label, SidecarCarrier::Intergroove]
2001}
2002
2003pub fn normalize_sidecar_scheme(raw: Option<&str>) -> Result<String> {
2004    let Some(raw) = raw else {
2005        return Ok(SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2.to_string());
2006    };
2007    let normalized = raw.trim().to_ascii_lowercase();
2008    match normalized.as_str() {
2009        SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2 => Ok(normalized),
2010        _ => bail!("unsupported sidecar carrier scheme {raw}"),
2011    }
2012}
2013
2014pub fn sidecar_pointer_scheme_id(scheme: &str) -> Result<u8> {
2015    match normalize_sidecar_scheme(Some(scheme))?.as_str() {
2016        SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2 => Ok(SIDECAR_POINTER_SCHEME_PAIRSIGN_SAFE_LUMA_V2),
2017        _ => unreachable!("scheme normalized"),
2018    }
2019}
2020
2021pub fn sidecar_pointer_scheme_name(scheme_id: u8) -> Result<String> {
2022    match scheme_id {
2023        SIDECAR_POINTER_SCHEME_PAIRSIGN_SAFE_LUMA_V2 => {
2024            Ok(SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2.to_string())
2025        }
2026        _ => bail!("unsupported sidecar pointer scheme id {scheme_id}"),
2027    }
2028}
2029
2030pub fn sidecar_pointer_carrier_flags(carriers: &[SidecarCarrier]) -> u8 {
2031    let mut flags = 0u8;
2032    if carriers.contains(&SidecarCarrier::Label) {
2033        flags |= SIDECAR_POINTER_CARRIER_LABEL;
2034    }
2035    if carriers.contains(&SidecarCarrier::Intergroove) {
2036        flags |= SIDECAR_POINTER_CARRIER_INTERGROOVE;
2037    }
2038    if carriers.contains(&SidecarCarrier::LeadInDeadwax) {
2039        flags |= SIDECAR_POINTER_CARRIER_LEAD_IN_DEADWAX;
2040    }
2041    flags
2042}
2043
2044pub fn sidecar_pointer_carriers(flags: u8) -> Result<Vec<SidecarCarrier>> {
2045    if flags
2046        & !(SIDECAR_POINTER_CARRIER_LABEL
2047            | SIDECAR_POINTER_CARRIER_INTERGROOVE
2048            | SIDECAR_POINTER_CARRIER_LEAD_IN_DEADWAX)
2049        != 0
2050    {
2051        bail!("sidecar pointer has unsupported carrier flags {flags:#04x}");
2052    }
2053    let mut carriers = Vec::new();
2054    if flags & SIDECAR_POINTER_CARRIER_LABEL != 0 {
2055        carriers.push(SidecarCarrier::Label);
2056    }
2057    if flags & SIDECAR_POINTER_CARRIER_INTERGROOVE != 0 {
2058        carriers.push(SidecarCarrier::Intergroove);
2059    }
2060    if flags & SIDECAR_POINTER_CARRIER_LEAD_IN_DEADWAX != 0 {
2061        carriers.push(SidecarCarrier::LeadInDeadwax);
2062    }
2063    if carriers.is_empty() {
2064        bail!("sidecar pointer has no carriers");
2065    }
2066    Ok(carriers)
2067}
2068
2069pub fn encode_sidecar_header_pointer(pointer: &SidecarHeaderPointer) -> Result<Vec<u8>> {
2070    let length = u32::try_from(pointer.length).context("sidecar length exceeds u32")?;
2071    let mut out = Vec::with_capacity(SIDECAR_POINTER_LENGTH);
2072    out.extend_from_slice(SIDECAR_MAGIC);
2073    out.push(SIDECAR_POINTER_VERSION);
2074    out.push(sidecar_pointer_scheme_id(&pointer.scheme)?);
2075    out.push(sidecar_pointer_carrier_flags(&pointer.carriers));
2076    out.push(0);
2077    out.extend_from_slice(&pointer.seed.to_be_bytes());
2078    out.extend_from_slice(&length.to_be_bytes());
2079    out.extend_from_slice(&pointer.sha256_bytes);
2080    debug_assert_eq!(out.len(), SIDECAR_POINTER_LENGTH);
2081    Ok(out)
2082}
2083
2084pub fn decode_sidecar_header_pointer(payload: &[u8]) -> Result<SidecarHeaderPointer> {
2085    if payload.len() != SIDECAR_POINTER_LENGTH {
2086        bail!("sidecar pointer has invalid length {}", payload.len());
2087    }
2088    if &payload[..4] != SIDECAR_MAGIC {
2089        bail!("sidecar pointer magic is unsupported");
2090    }
2091    let version = payload[4];
2092    if version != SIDECAR_POINTER_VERSION {
2093        bail!("unsupported sidecar pointer version {version}");
2094    }
2095    let scheme = sidecar_pointer_scheme_name(payload[5])?;
2096    let carriers = sidecar_pointer_carriers(payload[6])?;
2097    if payload[7] != 0 {
2098        bail!("sidecar pointer reserved byte must be 0");
2099    }
2100    let seed = u32::from_be_bytes(payload[8..12].try_into().expect("slice length"));
2101    if seed == 0 {
2102        bail!("sidecar seed must be nonzero");
2103    }
2104    let length = u32::from_be_bytes(payload[12..16].try_into().expect("slice length")) as usize;
2105    if length == 0 {
2106        bail!("sidecar length must be nonzero");
2107    }
2108    let mut sha256_bytes = [0u8; 32];
2109    sha256_bytes.copy_from_slice(&payload[16..48]);
2110    let sha256 = general_purpose::URL_SAFE_NO_PAD.encode(sha256_bytes);
2111    Ok(SidecarHeaderPointer {
2112        scheme,
2113        carriers,
2114        seed,
2115        length,
2116        sha256,
2117        sha256_bytes,
2118    })
2119}
2120
2121pub fn sidecar_header_pointer_json(pointer: &SidecarHeaderPointer) -> serde_json::Value {
2122    serde_json::json!({
2123        "v": SIDECAR_POINTER_VERSION,
2124        "c": "BTS1",
2125        "s": pointer.scheme.as_str(),
2126        "r": pointer.carriers.iter().map(|carrier| sidecar_carrier_name(*carrier)).collect::<Vec<_>>(),
2127        "n": pointer.seed,
2128        "l": pointer.length,
2129        "h": pointer.sha256.as_str(),
2130    })
2131}
2132
2133pub fn sidecar_header_pointer_from_prepared(sidecar: &PreparedSidecar) -> SidecarHeaderPointer {
2134    SidecarHeaderPointer {
2135        scheme: sidecar.scheme.clone(),
2136        carriers: sidecar.carriers.clone(),
2137        seed: sidecar.seed,
2138        length: sidecar.bytes.len(),
2139        sha256: sidecar.sha256.clone(),
2140        sha256_bytes: sidecar.sha256_bytes,
2141    }
2142}
2143
2144pub fn prepare_sidecar_label_tuning(
2145    raw: Option<&SidecarLabelTuningOptions>,
2146) -> Option<PreparedSidecarLabelTuning> {
2147    let raw = raw?;
2148    if !raw.enabled.unwrap_or(true) {
2149        return None;
2150    }
2151    Some(PreparedSidecarLabelTuning {
2152        strength: raw.strength.unwrap_or(0.22).clamp(0.0, 1.0),
2153        target_luma: raw.target_luma.unwrap_or(128.0).clamp(0.0, 255.0),
2154        grain: raw.grain.unwrap_or(3).clamp(0, 31),
2155    })
2156}
2157
2158pub fn prepare_sidecar_render(
2159    options: Option<&SidecarRenderOptions>,
2160) -> Result<Option<PreparedSidecar>> {
2161    let Some(options) = options else {
2162        return Ok(None);
2163    };
2164    let has_raw = options
2165        .bts1_base64
2166        .as_deref()
2167        .map(|value| !value.trim().is_empty())
2168        .unwrap_or(false);
2169    let has_items = options
2170        .items
2171        .as_ref()
2172        .map(|items| !items.is_empty())
2173        .unwrap_or(false);
2174    if has_raw == has_items {
2175        bail!("sidecar requires exactly one of bts1Base64 or non-empty items");
2176    }
2177    let bytes = if has_raw {
2178        let raw = options.bts1_base64.as_deref().expect("checked has_raw");
2179        let bytes = decode_base64_text(raw, "sidecar bts1Base64")?;
2180        validate_sidecar_container(&bytes)?;
2181        bytes
2182    } else {
2183        build_sidecar_container_from_items(options.items.as_deref().expect("checked has_items"))?
2184    };
2185    let carriers = normalize_sidecar_carriers(options.carriers.as_deref())?;
2186    let scheme = normalize_sidecar_scheme(options.scheme.as_deref())?;
2187    let label_tuning = prepare_sidecar_label_tuning(options.label_tuning.as_ref());
2188    let seed = options.seed.unwrap_or(SIDECAR_DEFAULT_SEED);
2189    if seed == 0 {
2190        bail!("sidecar seed must be nonzero");
2191    }
2192    if scheme != SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2 {
2193        bail!("sidecar stream uses a fixed carrier scheme in this version");
2194    }
2195    let sha256_bytes = sha256_digest_bytes(&bytes);
2196    let sha256 = general_purpose::URL_SAFE_NO_PAD.encode(sha256_bytes);
2197    Ok(Some(PreparedSidecar {
2198        bytes,
2199        scheme,
2200        label_tuning,
2201        carriers,
2202        seed,
2203        sha256,
2204        sha256_bytes,
2205    }))
2206}
2207
2208pub fn mulberry32_next(state: &mut u32) -> u32 {
2209    *state = state.wrapping_add(0x6d2b_79f5);
2210    let mut t = *state;
2211    t = (t ^ (t >> 15)).wrapping_mul(t | 1);
2212    t ^= t.wrapping_add((t ^ (t >> 7)).wrapping_mul(t | 61));
2213    t ^ (t >> 14)
2214}
2215
2216pub fn shuffle_pairs_mulberry32(pairs: &mut [(usize, usize)], seed: u32) {
2217    let mut state = seed;
2218    for i in (1..pairs.len()).rev() {
2219        let j = (mulberry32_next(&mut state) as usize) % (i + 1);
2220        pairs.swap(i, j);
2221    }
2222}
2223
2224pub fn sidecar_luma_rec709(data: &[u8], pixel_index: usize) -> f64 {
2225    let offset = pixel_index * 4;
2226    0.2126 * data[offset] as f64
2227        + 0.7152 * data[offset + 1] as f64
2228        + 0.0722 * data[offset + 2] as f64
2229}
2230
2231pub fn metadata_dither(pixel_index: usize, sequence_index: usize, salt: usize) -> u8 {
2232    let mut value = pixel_index as u64;
2233    value ^= (sequence_index as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
2234    value ^= (salt as u64).wrapping_mul(0xbf58_476d_1ce4_e5b9);
2235    value ^= value >> 30;
2236    value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9);
2237    value ^= value >> 27;
2238    value = value.wrapping_mul(0x94d0_49bb_1331_11eb);
2239    value ^= value >> 31;
2240    (value & 0xff) as u8
2241}
2242
2243fn sidecar_transparent_pair_fallback_base(first_pixel: usize, second_pixel: usize) -> i16 {
2244    128i16 + (metadata_dither(first_pixel, second_pixel, 181) % 31) as i16 - 15i16
2245}
2246
2247pub fn sidecar_capacity_bytes_for_scheme(scheme: &str, carrier_pairs: usize) -> Result<usize> {
2248    match normalize_sidecar_scheme(Some(scheme))?.as_str() {
2249        SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2 => Ok(carrier_pairs / 4),
2250        _ => unreachable!("scheme normalized"),
2251    }
2252}
2253
2254pub fn sidecar_pair_bit_width_for_scheme(
2255    scheme: &str,
2256    _rgba: &[u8],
2257    _first_pixel: usize,
2258    _second_pixel: usize,
2259) -> Result<usize> {
2260    match normalize_sidecar_scheme(Some(scheme))?.as_str() {
2261        SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2 => Ok(2),
2262        _ => unreachable!("scheme normalized"),
2263    }
2264}
2265
2266pub fn sidecar_bit_capacity_for_pairs(
2267    scheme: &str,
2268    pairs: &[(usize, usize)],
2269    rgba: &[u8],
2270) -> Result<usize> {
2271    let mut bits = 0usize;
2272    for &(first, second) in pairs {
2273        bits = bits
2274            .checked_add(sidecar_pair_bit_width_for_scheme(
2275                scheme, rgba, first, second,
2276            )?)
2277            .context("sidecar pair bit capacity overflow")?;
2278    }
2279    Ok(bits)
2280}
2281
2282pub fn sidecar_capacity_for_pairs(
2283    scheme: &str,
2284    carriers: &[SidecarCarrier],
2285    pairs: &[(usize, usize)],
2286    rgba: &[u8],
2287) -> Result<SidecarCapacity> {
2288    let bit_capacity = sidecar_bit_capacity_for_pairs(scheme, pairs, rgba)?;
2289    let two_bit_pairs = pairs
2290        .iter()
2291        .filter_map(|&(first, second)| {
2292            sidecar_pair_bit_width_for_scheme(scheme, rgba, first, second).ok()
2293        })
2294        .filter(|width| *width == 2)
2295        .count();
2296    Ok(SidecarCapacity {
2297        scheme: normalize_sidecar_scheme(Some(scheme))?,
2298        carriers: carriers
2299            .iter()
2300            .map(|carrier| sidecar_carrier_name(*carrier).to_string())
2301            .collect(),
2302        carrier_pixels: pairs.len() * 2,
2303        carrier_pairs: pairs.len(),
2304        capacity_bits: bit_capacity,
2305        capacity_bytes: bit_capacity / 8,
2306        bits_per_pair: if pairs.is_empty() {
2307            0.0
2308        } else {
2309            bit_capacity as f64 / pairs.len() as f64
2310        },
2311        two_bit_pairs,
2312    })
2313}
2314
2315fn clamp_sidecar_byte(value: i16) -> u8 {
2316    value.clamp(0, 255) as u8
2317}
2318
2319pub fn tune_sidecar_pixels(
2320    data: &mut [u8],
2321    pixel_indices: &[usize],
2322    tuning: &PreparedSidecarLabelTuning,
2323) {
2324    for &pixel in pixel_indices {
2325        let offset = pixel * 4;
2326        if offset + 3 >= data.len() || data[offset + 3] == 0 {
2327            continue;
2328        }
2329        let grain = if tuning.grain > 0 {
2330            let span = (tuning.grain as u8).saturating_mul(2).saturating_add(1);
2331            (metadata_dither(pixel, pixel, 223) % span) as i16 - tuning.grain
2332        } else {
2333            0
2334        };
2335        let target = (tuning.target_luma + grain as f64).clamp(0.0, 255.0);
2336        let luma = sidecar_luma_rec709(data, pixel);
2337        let delta = ((target - luma) * tuning.strength).round() as i16;
2338        for channel in 0..3 {
2339            data[offset + channel] = clamp_sidecar_byte(data[offset + channel] as i16 + delta);
2340        }
2341        data[offset + 3] = 255;
2342    }
2343}
2344
2345fn sidecar_payload_bit(bytes: &[u8], bit_index: usize) -> u8 {
2346    let byte = bytes[bit_index / 8];
2347    let shift = 7 - (bit_index % 8);
2348    (byte >> shift) & 0x01
2349}
2350
2351fn push_sidecar_decoded_bit(out: &mut [u8], bit_index: usize, bit: u8) {
2352    if bit != 0 {
2353        let byte_index = bit_index / 8;
2354        let shift = 7 - (bit_index % 8);
2355        out[byte_index] |= 1 << shift;
2356    }
2357}
2358
2359fn paint_sidecar_pair_bit(
2360    data: &mut [u8],
2361    first_pixel: usize,
2362    second_pixel: usize,
2363    bit: u8,
2364    magnitude_bit: Option<u8>,
2365) {
2366    let first_offset = first_pixel * 4;
2367    let second_offset = second_pixel * 4;
2368    let delta = if magnitude_bit == Some(1) {
2369        SIDECAR_PAIR_MAGNITUDE_DELTA
2370    } else {
2371        SIDECAR_PAIR_SIGN_DELTA
2372    };
2373    let first_delta = if bit == 1 { delta } else { -delta };
2374    let second_delta = -first_delta;
2375    let transparent = data[first_offset + 3] == 0 || data[second_offset + 3] == 0;
2376    let fallback_base = sidecar_transparent_pair_fallback_base(first_pixel, second_pixel);
2377    for channel in 0..3 {
2378        let average = if transparent {
2379            fallback_base
2380        } else {
2381            (data[first_offset + channel] as i16 + data[second_offset + channel] as i16 + 1) / 2
2382        }
2383        .clamp(delta, 255 - delta);
2384        data[first_offset + channel] = clamp_sidecar_byte(average + first_delta);
2385        data[second_offset + channel] = clamp_sidecar_byte(average + second_delta);
2386    }
2387    if data[first_offset + 3] == 0 {
2388        data[first_offset + 3] = 255;
2389    }
2390    if data[second_offset + 3] == 0 {
2391        data[second_offset + 3] = 255;
2392    }
2393}
2394
2395pub fn decode_pairsign_sidecar_bytes_from_pairs(
2396    rgba: &[u8],
2397    pairs: &[(usize, usize)],
2398    scheme: &str,
2399    byte_length: usize,
2400) -> Result<Vec<u8>> {
2401    let bit_capacity = sidecar_bit_capacity_for_pairs(scheme, pairs, rgba)?;
2402    if byte_length.saturating_mul(8) > bit_capacity {
2403        bail!(
2404            "sidecar descriptor length {} exceeds pair-sign carrier capacity {}",
2405            byte_length,
2406            bit_capacity / 8
2407        );
2408    }
2409    let target_bits = byte_length
2410        .checked_mul(8)
2411        .context("sidecar decode bit length overflow")?;
2412    let mut out = vec![0u8; byte_length];
2413    let mut bit_index = 0usize;
2414    for &(first, second) in pairs {
2415        if bit_index >= target_bits {
2416            break;
2417        }
2418        let first_luma = sidecar_luma_rec709(rgba, first);
2419        let second_luma = sidecar_luma_rec709(rgba, second);
2420        let sign_bit = if first_luma > second_luma { 1u8 } else { 0u8 };
2421        push_sidecar_decoded_bit(&mut out, bit_index, sign_bit);
2422        bit_index += 1;
2423        if bit_index < target_bits
2424            && sidecar_pair_bit_width_for_scheme(scheme, rgba, first, second)? == 2
2425        {
2426            let magnitude_bit =
2427                if (first_luma - second_luma).abs() >= SIDECAR_PAIR_MAGNITUDE_THRESHOLD {
2428                    1u8
2429                } else {
2430                    0u8
2431                };
2432            push_sidecar_decoded_bit(&mut out, bit_index, magnitude_bit);
2433            bit_index += 1;
2434        }
2435    }
2436    if bit_index < target_bits {
2437        bail!("sidecar carrier did not provide enough bits to decode descriptor length");
2438    }
2439    Ok(out)
2440}
2441
2442pub fn paint_sidecar_bytes_into_pairs(
2443    data: &mut [u8],
2444    pairs: &[(usize, usize)],
2445    sidecar: &PreparedSidecar,
2446) -> Result<SidecarRenderSummary> {
2447    let carrier_pair_count = pairs.len();
2448    let bit_capacity = sidecar_bit_capacity_for_pairs(&sidecar.scheme, pairs, data)?;
2449    let capacity_bytes = bit_capacity / 8;
2450    let payload_bits = sidecar
2451        .bytes
2452        .len()
2453        .checked_mul(8)
2454        .context("sidecar payload bit length overflow")?;
2455    if payload_bits > bit_capacity {
2456        bail!(
2457            "sidecar stream is {} bytes but selected carriers only fit {} bytes",
2458            sidecar.bytes.len(),
2459            capacity_bytes
2460        );
2461    }
2462
2463    let mut pair_index = 0usize;
2464    let mut bit_index = 0usize;
2465    while bit_index < payload_bits {
2466        let (first_pixel, second_pixel) = pairs[pair_index];
2467        let sign_bit = sidecar_payload_bit(&sidecar.bytes, bit_index);
2468        bit_index += 1;
2469        let pair_bit_width =
2470            sidecar_pair_bit_width_for_scheme(&sidecar.scheme, data, first_pixel, second_pixel)?;
2471        let magnitude_bit = if pair_bit_width == 2 {
2472            let bit = if bit_index < payload_bits {
2473                let bit = sidecar_payload_bit(&sidecar.bytes, bit_index);
2474                bit_index += 1;
2475                bit
2476            } else {
2477                0
2478            };
2479            Some(bit)
2480        } else {
2481            None
2482        };
2483        paint_sidecar_pair_bit(data, first_pixel, second_pixel, sign_bit, magnitude_bit);
2484        pair_index += 1;
2485    }
2486    let carriers = sidecar
2487        .carriers
2488        .iter()
2489        .map(|carrier| sidecar_carrier_name(*carrier).to_string())
2490        .collect::<Vec<_>>();
2491    Ok(SidecarRenderSummary {
2492        container: "BTS1".to_string(),
2493        scheme: sidecar.scheme.clone(),
2494        carriers,
2495        seed: sidecar.seed,
2496        bts1_bytes: sidecar.bytes.len(),
2497        sha256: sidecar.sha256.clone(),
2498        carrier_pixels: carrier_pair_count * 2,
2499        carrier_pairs: carrier_pair_count,
2500        capacity_bytes,
2501        used_pairs: pair_index,
2502        unused_pairs: carrier_pair_count.saturating_sub(pair_index),
2503    })
2504}
2505
2506pub fn decode_sidecar_from_pairs(
2507    rgba: &[u8],
2508    pairs: &[(usize, usize)],
2509    scheme: &str,
2510    byte_length: usize,
2511) -> Result<(Vec<u8>, SidecarDecodeResult)> {
2512    let bts1 = decode_pairsign_sidecar_bytes_from_pairs(rgba, pairs, scheme, byte_length)?;
2513    let validation = validate_sidecar_container(&bts1)?;
2514    let sha256 = sha256_base64url(&bts1);
2515    let capacity = sidecar_capacity_bytes_for_scheme(scheme, pairs.len())?;
2516    let descriptor = serde_json::json!({
2517        "container": "BTS1",
2518        "scheme": normalize_sidecar_scheme(Some(scheme))?,
2519        "length": byte_length,
2520    });
2521    let result = SidecarDecodeResult {
2522        ok: true,
2523        descriptor,
2524        validation,
2525        bts1_byte_length: bts1.len(),
2526        sha256,
2527        carrier_pixels: pairs.len() * 2,
2528        carrier_pairs: pairs.len(),
2529        capacity_bytes: capacity,
2530    };
2531    Ok((bts1, result))
2532}
2533
2534#[cfg(test)]
2535mod tests {
2536    use super::*;
2537
2538    #[test]
2539    fn raw_text_container_round_trips() {
2540        let json = r#"[{"type":"text","codec":"raw","name":"liner.txt","text":"hello"}]"#;
2541        let bts1 = build_sidecar_container_from_items_json(json).unwrap();
2542        let validation = validate_sidecar_container(&bts1).unwrap();
2543        assert_eq!(validation.item_count, 1);
2544        let decoded = decode_sidecar_container_items(&bts1).unwrap();
2545        assert_eq!(decoded.items[0].text.as_deref(), Some("hello"));
2546    }
2547
2548    #[test]
2549    fn pointer_round_trips() {
2550        let options: SidecarRenderOptions = serde_json::from_str(
2551            r#"{"seed":324508639,"items":[{"type":"text","codec":"raw","text":"hello"}]}"#,
2552        )
2553        .unwrap();
2554        let sidecar = prepare_sidecar_render(Some(&options)).unwrap().unwrap();
2555        let pointer = sidecar_header_pointer_from_prepared(&sidecar);
2556        let encoded = encode_sidecar_header_pointer(&pointer).unwrap();
2557        let decoded = decode_sidecar_header_pointer(&encoded).unwrap();
2558        assert_eq!(decoded.seed, 324508639);
2559        assert_eq!(decoded.length, sidecar.bytes.len());
2560        assert_eq!(decoded.sha256, sidecar.sha256);
2561    }
2562
2563    #[test]
2564    fn pointer_round_trips_expanded_carriers() {
2565        let options: SidecarRenderOptions = serde_json::from_str(
2566            r#"{"carriers":["label","intergroove","leadInDeadwax"],"items":[{"type":"text","codec":"raw","text":"hello"}]}"#,
2567        )
2568        .unwrap();
2569        let sidecar = prepare_sidecar_render(Some(&options)).unwrap().unwrap();
2570        let pointer = sidecar_header_pointer_from_prepared(&sidecar);
2571        let encoded = encode_sidecar_header_pointer(&pointer).unwrap();
2572        let decoded = decode_sidecar_header_pointer(&encoded).unwrap();
2573        assert_eq!(
2574            decoded.carriers,
2575            vec![
2576                SidecarCarrier::Label,
2577                SidecarCarrier::Intergroove,
2578                SidecarCarrier::LeadInDeadwax,
2579            ]
2580        );
2581    }
2582
2583    #[test]
2584    fn package_display_header_builder_owns_layout_and_checksums() {
2585        let json = r##"{
2586            "coverShown": true,
2587            "coverEffects": true,
2588            "coverRgbGrain": true,
2589            "innerSleeveShown": true,
2590            "coverEmbedded": true,
2591            "designLabel": "Design Ω",
2592            "designClassName": "posterized",
2593            "posterize": 18,
2594            "saturation": 0.72,
2595            "contrast": 0.64,
2596            "rgbGrainOpacity": 0.86,
2597            "rgbGrainBlendIndex": 3,
2598            "crop": { "x": 0.5, "y": 0.25, "zoom": 1.25 },
2599            "quantizer": null,
2600            "inner": null,
2601            "sleeveToneColor": "#abc",
2602            "sleeveSparkleOpacity": 0.5
2603        }"##;
2604        let bytes = build_package_display_header_bytes_from_json(json).unwrap();
2605        assert_eq!(bytes.len(), DISPLAY_HEADER_LENGTH);
2606        assert_eq!(&bytes[0..4], DISPLAY_HEADER_MAGIC);
2607        assert_eq!(bytes[4], DISPLAY_HEADER_VERSION);
2608        assert_eq!(bytes[5], DISPLAY_HEADER_LENGTH as u8);
2609        assert_eq!(read_u16be(&bytes, 6, "display header flags").unwrap(), 0x1f);
2610        assert_eq!(String::from_utf8_lossy(&bytes[16..22]), "Design");
2611        assert_eq!(bytes[22], b' ');
2612        assert_eq!(String::from_utf8_lossy(&bytes[56..64]), "posteriz");
2613        assert_eq!(bytes[64], 18);
2614        assert_eq!(read_u16be(&bytes, 65, "saturation").unwrap(), 720);
2615        assert_eq!(read_u16be(&bytes, 67, "contrast").unwrap(), 640);
2616        assert_eq!(read_u16be(&bytes, 69, "grain").unwrap(), 860);
2617        assert_eq!(bytes[71], 3);
2618        assert_eq!(read_u16be(&bytes, 72, "crop x").unwrap(), 32768);
2619        assert_eq!(read_u16be(&bytes, 74, "crop y").unwrap(), 16384);
2620        assert_eq!(read_u16be(&bytes, 76, "crop zoom").unwrap(), 1250);
2621        assert_eq!(bytes[78], 255);
2622        assert_eq!(read_u16be(&bytes, 80, "inner").unwrap(), 0);
2623        assert_eq!(String::from_utf8_lossy(&bytes[82..89]), "#AABBCC");
2624        assert_eq!(read_u16be(&bytes, 89, "sparkle opacity").unwrap(), 500);
2625
2626        let payload_crc = read_u32be(&bytes, 8, "payload CRC").unwrap();
2627        assert_eq!(payload_crc, record_core::crc32_ieee(&bytes[16..]));
2628        let mut header_for_crc = bytes.clone();
2629        header_for_crc[12..16].fill(0);
2630        let header_crc = read_u32be(&bytes, 12, "header CRC").unwrap();
2631        assert_eq!(header_crc, record_core::crc32_ieee(&header_for_crc));
2632
2633        let item: serde_json::Value =
2634            serde_json::from_str(&build_package_display_header_item_json(json).unwrap()).unwrap();
2635        assert_eq!(item["type"], "opaque");
2636        assert_eq!(item["codec"], "raw");
2637        assert_eq!(item["name"], DISPLAY_HEADER_NAME);
2638        assert_eq!(item["mime"], DISPLAY_HEADER_MIME);
2639        assert_eq!(item["rawByteLength"], DISPLAY_HEADER_LENGTH);
2640        let item_bytes = general_purpose::STANDARD
2641            .decode(item["dataBase64"].as_str().unwrap())
2642            .unwrap();
2643        assert_eq!(item_bytes, bytes);
2644    }
2645
2646    #[test]
2647    fn package_metadata_item_builder_owns_cover_and_credit_metadata() {
2648        let json = r#"{
2649            "photos": [
2650                {
2651                    "id": "insert-1",
2652                    "name": "Insert One.jpg",
2653                    "status": "ready",
2654                    "hasAvif": true,
2655                    "credit": "  Alice   B.  "
2656                },
2657                {
2658                    "id": "insert-2",
2659                    "name": "Insert Two.png",
2660                    "status": "ready",
2661                    "hasAvif": true,
2662                    "credit": ""
2663                },
2664                {
2665                    "id": "insert-3",
2666                    "name": "Draft.png",
2667                    "status": "queued",
2668                    "hasAvif": false,
2669                    "credit": "Skipped"
2670                }
2671            ],
2672            "cover": {
2673                "hasFile": true,
2674                "hasPreview": true,
2675                "sourcePhotoId": "insert-1",
2676                "sourceWidth": 1200,
2677                "sourceHeight": 800,
2678                "inner": 576,
2679                "crop": { "x": 0.25, "y": 0.5, "zoom": 2 },
2680                "embedded": false,
2681                "shown": true,
2682                "designLabel": "Poster",
2683                "effectsEnabled": true,
2684                "name": "cover.png"
2685            }
2686        }"#;
2687        let items = build_package_metadata_items_json_from_input(
2688            &serde_json::from_str::<PackageMetadataInput>(json).unwrap(),
2689        );
2690        let item = items.as_array().unwrap().first().unwrap();
2691        assert_eq!(item["type"], "json");
2692        assert_eq!(item["codec"], "raw");
2693        assert_eq!(item["name"], PACKAGE_METADATA_ITEM_NAME);
2694        assert_eq!(item["mime"], PACKAGE_METADATA_MIME);
2695
2696        let metadata = &item["json"];
2697        assert_eq!(metadata["kind"], "bitneedle.packageMetadata");
2698        assert_eq!(metadata["version"], 1);
2699        assert_eq!(metadata["photoCredits"].as_array().unwrap().len(), 1);
2700        assert_eq!(metadata["photoCredits"][0]["position"], 1);
2701        assert_eq!(metadata["photoCredits"][0]["sourceName"], "Insert One.jpg");
2702        assert_eq!(metadata["photoCredits"][0]["itemName"], "Insert One.avif");
2703        assert_eq!(metadata["photoCredits"][0]["credit"], "Alice B.");
2704
2705        assert_eq!(metadata["cover"]["role"], "cover");
2706        assert_eq!(metadata["cover"]["source"]["type"], "photoInsert");
2707        assert_eq!(metadata["cover"]["source"]["position"], 1);
2708        assert_eq!(metadata["cover"]["source"]["itemName"], "Insert One.avif");
2709        assert_eq!(metadata["cover"]["display"]["shown"], true);
2710        assert_eq!(metadata["cover"]["display"]["design"], "Poster");
2711        assert_eq!(metadata["cover"]["display"]["effectsEnabled"], true);
2712        assert_eq!(metadata["cover"]["crop"]["normalized"]["x"], 0.25);
2713        assert_eq!(metadata["cover"]["crop"]["normalized"]["y"], 0.5);
2714        assert_eq!(metadata["cover"]["crop"]["normalized"]["zoom"], 2.0);
2715        assert_eq!(metadata["cover"]["crop"]["source"]["width"], 1200);
2716        assert_eq!(metadata["cover"]["crop"]["source"]["height"], 800);
2717        assert_eq!(metadata["cover"]["crop"]["rectangle"]["x"], 200);
2718        assert_eq!(metadata["cover"]["crop"]["rectangle"]["y"], 200);
2719        assert_eq!(metadata["cover"]["crop"]["rectangle"]["width"], 400);
2720        assert_eq!(metadata["cover"]["crop"]["rectangle"]["height"], 400);
2721        assert_eq!(metadata["cover"]["crop"]["output"]["width"], 576);
2722        assert_eq!(metadata["cover"]["crop"]["output"]["height"], 576);
2723    }
2724
2725    #[test]
2726    fn package_metadata_item_builder_omits_empty_metadata() {
2727        let items = build_package_metadata_items_json(r#"{"photos":[]}"#).unwrap();
2728        assert_eq!(items, "[]");
2729    }
2730
2731    #[test]
2732    fn package_image_item_builders_own_names_and_payload_shape() {
2733        let photo_item = build_package_photo_item_json(
2734            r#"{"name":" Insert One.final.png "}"#,
2735            &[0xde, 0xad, 0xbe, 0xef],
2736        )
2737        .unwrap();
2738        let photo: serde_json::Value = serde_json::from_str(&photo_item).unwrap();
2739        assert_eq!(photo["type"], "image");
2740        assert_eq!(photo["codec"], "avif");
2741        assert_eq!(photo["name"], "Insert One.final.avif");
2742        assert_eq!(photo["mime"], PACKAGE_PHOTO_MIME);
2743        assert_eq!(photo["dataBase64"], "3q2+7w==");
2744
2745        let cover_item = build_package_cover_item_json(&[1, 2, 3]);
2746        let cover: serde_json::Value = serde_json::from_str(&cover_item).unwrap();
2747        assert_eq!(cover["type"], "image");
2748        assert_eq!(cover["codec"], "avif");
2749        assert_eq!(cover["name"], PACKAGE_COVER_ITEM_NAME);
2750        assert_eq!(cover["mime"], PACKAGE_PHOTO_MIME);
2751        assert_eq!(cover["dataBase64"], "AQID");
2752    }
2753
2754    #[test]
2755    fn package_encode_cache_key_resolver_owns_normalization() {
2756        let key = resolve_package_image_encode_cache_key_json(
2757            r#"{
2758                "kind": "cover",
2759                "fileName": "cover.png",
2760                "fileSize": 12,
2761                "fileLastModified": "34",
2762                "quantizer": 47.4,
2763                "inner": 999,
2764                "crop": { "x": 1.5, "y": -1, "zoom": 9 },
2765                "colorMode": "sepia"
2766            }"#,
2767        )
2768        .unwrap();
2769        let parsed: serde_json::Value = serde_json::from_str(&key).unwrap();
2770        assert_eq!(
2771            parsed["key"],
2772            "cover.png:12:34:cover:47:576:crop-1.0000-0.0000-6.0000:rgb"
2773        );
2774        assert_eq!(parsed["fileKey"], "cover.png:12:34");
2775        assert_eq!(parsed["cropKey"], "crop-1.0000-0.0000-6.0000");
2776        assert_eq!(parsed["quantizer"], 47);
2777        assert_eq!(parsed["inner"], 576);
2778        assert_eq!(parsed["crop"]["x"], 1.0);
2779        assert_eq!(parsed["crop"]["y"], 0.0);
2780        assert_eq!(parsed["crop"]["zoom"], 6.0);
2781        assert_eq!(parsed["colorMode"], "rgb");
2782        assert_eq!(parsed["monochrome"], false);
2783
2784        let grayscale_key = resolve_package_image_encode_cache_key_json(
2785            r#"{
2786                "kind": "photo",
2787                "fileName": "insert.jpg",
2788                "colorMode": "grayscale"
2789            }"#,
2790        )
2791        .unwrap();
2792        let grayscale: serde_json::Value = serde_json::from_str(&grayscale_key).unwrap();
2793        assert_eq!(grayscale["colorMode"], "grayscale");
2794        assert_eq!(grayscale["monochrome"], true);
2795    }
2796
2797    #[test]
2798    fn package_best_fit_cache_key_resolver_owns_cache_protocol() {
2799        let key = resolve_package_best_fit_cache_key_json(
2800            r#"{
2801                "kind": "photo",
2802                "fileName": "insert.jpg",
2803                "fileSize": 100,
2804                "fileLastModified": 200,
2805                "budgetBytes": "12.9",
2806                "inner": 128,
2807                "cropKey": "crop-0.2500-0.5000-2.0000",
2808                "colorMode": "grayscale"
2809            }"#,
2810        )
2811        .unwrap();
2812        let parsed: serde_json::Value = serde_json::from_str(&key).unwrap();
2813        assert_eq!(
2814            parsed["key"],
2815            "photo:fit-v7-square-crop-qbest47-35-63:insert.jpg:100:200:12:128:crop-0.2500-0.5000-2.0000:grayscale"
2816        );
2817        assert_eq!(parsed["fileKey"], "insert.jpg:100:200");
2818        assert_eq!(parsed["budgetBytes"], 12);
2819        assert_eq!(parsed["inner"], 128);
2820        assert_eq!(parsed["cropKey"], "crop-0.2500-0.5000-2.0000");
2821        assert_eq!(parsed["colorMode"], "grayscale");
2822    }
2823
2824    #[test]
2825    fn package_quantizer_search_planner_drives_floor_and_binary_search() {
2826        let plan = package_quantizer_search_plan(
2827            &serde_json::from_str::<PackageQuantizerSearchPlanInput>(
2828                r#"{"minQuantizer":0,"maxQuantizer":63,"startQuantizer":47,"trials":[]}"#,
2829            )
2830            .unwrap(),
2831        );
2832        assert_eq!(plan.next_quantizer, Some(47));
2833        assert_eq!(plan.note, "start");
2834        assert!(!plan.done);
2835
2836        let plan = package_quantizer_search_plan(
2837            &serde_json::from_str::<PackageQuantizerSearchPlanInput>(
2838                r#"{
2839                    "minQuantizer":0,
2840                    "maxQuantizer":63,
2841                    "startQuantizer":47,
2842                    "trials":[{"quantizer":47,"fits":true}]
2843                }"#,
2844            )
2845            .unwrap(),
2846        );
2847        assert_eq!(plan.next_quantizer, Some(35));
2848        assert_eq!(plan.note, "floor");
2849
2850        let plan = package_quantizer_search_plan(
2851            &serde_json::from_str::<PackageQuantizerSearchPlanInput>(
2852                r#"{
2853                    "minQuantizer":0,
2854                    "maxQuantizer":63,
2855                    "startQuantizer":47,
2856                    "trials":[
2857                        {"quantizer":47,"fits":true},
2858                        {"quantizer":35,"fits":false}
2859                    ]
2860                }"#,
2861            )
2862            .unwrap(),
2863        );
2864        assert_eq!(plan.next_quantizer, Some(41));
2865        assert_eq!(plan.note, "bisect");
2866        assert_eq!(plan.best_quantizer, Some(47));
2867    }
2868
2869    #[test]
2870    fn package_quantizer_search_planner_drives_ceiling_and_refinement() {
2871        let plan = package_quantizer_search_plan(
2872            &serde_json::from_str::<PackageQuantizerSearchPlanInput>(
2873                r#"{
2874                    "minQuantizer":0,
2875                    "maxQuantizer":63,
2876                    "startQuantizer":47,
2877                    "trials":[{"quantizer":47,"fits":false}]
2878                }"#,
2879            )
2880            .unwrap(),
2881        );
2882        assert_eq!(plan.next_quantizer, Some(63));
2883        assert_eq!(plan.note, "ceiling");
2884        assert_eq!(plan.best_quantizer, None);
2885
2886        let plan = package_quantizer_search_plan(
2887            &serde_json::from_str::<PackageQuantizerSearchPlanInput>(
2888                r#"{
2889                    "minQuantizer":0,
2890                    "maxQuantizer":63,
2891                    "startQuantizer":47,
2892                    "trials":[
2893                        {"quantizer":47,"fits":false},
2894                        {"quantizer":63,"fits":true}
2895                    ]
2896                }"#,
2897            )
2898            .unwrap(),
2899        );
2900        assert_eq!(plan.next_quantizer, Some(55));
2901        assert_eq!(plan.note, "bisect");
2902
2903        let plan = package_quantizer_search_plan(
2904            &serde_json::from_str::<PackageQuantizerSearchPlanInput>(
2905                r#"{
2906                    "minQuantizer":0,
2907                    "maxQuantizer":63,
2908                    "startQuantizer":47,
2909                    "trials":[
2910                        {"quantizer":47,"fits":false},
2911                        {"quantizer":63,"fits":true},
2912                        {"quantizer":55,"fits":true},
2913                        {"quantizer":51,"fits":true},
2914                        {"quantizer":49,"fits":true},
2915                        {"quantizer":48,"fits":true}
2916                    ]
2917                }"#,
2918            )
2919            .unwrap(),
2920        );
2921        assert_eq!(plan.next_quantizer, Some(46));
2922        assert_eq!(plan.note, "refine");
2923        assert_eq!(plan.best_quantizer, Some(48));
2924    }
2925
2926    #[test]
2927    fn package_fit_budget_owns_cover_and_photo_budget_policy() {
2928        let budget = package_fit_budget(
2929            &serde_json::from_str::<PackageFitBudgetInput>(
2930                r#"{"capacity":9000,"baseBts1Bytes":3000,"photoCount":4}"#,
2931            )
2932            .unwrap(),
2933        );
2934        assert_eq!(budget.capacity, 9000);
2935        assert_eq!(budget.base_bts1_bytes, 3000);
2936        assert_eq!(budget.available_bytes, 6000);
2937        assert_eq!(budget.cover_budget_bytes, 2000);
2938        assert_eq!(budget.remaining_bytes, 6000);
2939        assert_eq!(budget.photo_count, 4);
2940        assert_eq!(budget.per_photo_budget_bytes, 1500);
2941
2942        let over_budget = package_fit_budget(
2943            &serde_json::from_str::<PackageFitBudgetInput>(
2944                r#"{"capacity":100,"baseBts1Bytes":250,"photoCount":2}"#,
2945            )
2946            .unwrap(),
2947        );
2948        assert_eq!(over_budget.available_bytes, 0);
2949        assert_eq!(over_budget.cover_budget_bytes, 0);
2950        assert_eq!(over_budget.remaining_bytes, 0);
2951        assert_eq!(over_budget.per_photo_budget_bytes, 0);
2952    }
2953
2954    #[test]
2955    fn package_sidecar_render_options_own_protocol_and_label_tuning() {
2956        let options = build_package_sidecar_render_options(
2957            &serde_json::from_str::<PackageSidecarRenderOptionsInput>(
2958                r#"{"seed":42,"capacityBytes":1000,"payloadBytes":600,"items":[{"type":"text"}]}"#,
2959            )
2960            .unwrap(),
2961        );
2962        assert_eq!(options["sidecar"]["seed"], 42);
2963        assert_eq!(
2964            options["sidecar"]["carriers"],
2965            serde_json::json!(["label", "intergroove"])
2966        );
2967        assert_eq!(options["sidecar"]["scheme"], "pairsign-safe-luma-v2");
2968        assert_eq!(options["sidecar"]["labelTuning"]["enabled"], true);
2969        assert_eq!(options["sidecar"]["labelTuning"]["targetLuma"], 128);
2970        assert_eq!(options["sidecar"]["labelTuning"]["strength"], 0.34);
2971        assert_eq!(options["sidecar"]["labelTuning"]["grain"], 4);
2972        assert_eq!(options["sidecar"]["items"][0]["type"], "text");
2973        assert!(options["sidecar"]["bts1Base64"].is_null());
2974
2975        let bts1_options = build_package_sidecar_render_options(
2976            &serde_json::from_str::<PackageSidecarRenderOptionsInput>(
2977                r#"{"seed":7,"capacityBytes":0,"payloadBytes":1200,"bts1Base64":"AAAA"}"#,
2978            )
2979            .unwrap(),
2980        );
2981        assert_eq!(bts1_options["sidecar"]["labelTuning"]["strength"], 0.22);
2982        assert_eq!(bts1_options["sidecar"]["labelTuning"]["grain"], 3);
2983        assert_eq!(bts1_options["sidecar"]["bts1Base64"], "AAAA");
2984        assert!(bts1_options["sidecar"]["items"].is_null());
2985    }
2986
2987    #[test]
2988    fn package_pattern_preservation_owns_decoded_item_protocol_mapping() {
2989        let decoded = serde_json::json!({
2990            "items": [
2991                {
2992                    "itemType": 3,
2993                    "codec": 0,
2994                    "flags": 2,
2995                    "rawByteLength": 123,
2996                    "name": PACKAGE_PATTERN_SIDECAR_ITEM_NAME,
2997                    "mime": PACKAGE_PATTERN_SIDECAR_MIME,
2998                    "storedDataBase64": "stored-bytes",
2999                    "dataBase64": "decoded-bytes"
3000                },
3001                {
3002                    "itemType": 2,
3003                    "codec": 3,
3004                    "name": "album-cover.avif",
3005                    "mime": "image/avif",
3006                    "storedDataBase64": "cover"
3007                }
3008            ]
3009        });
3010        let items = package_preserved_pattern_items(&decoded).unwrap();
3011        assert_eq!(items.len(), 1);
3012        assert_eq!(items[0]["type"], "json");
3013        assert_eq!(items[0]["codec"], "raw");
3014        assert_eq!(items[0]["name"], PACKAGE_PATTERN_SIDECAR_ITEM_NAME);
3015        assert_eq!(items[0]["mime"], PACKAGE_PATTERN_SIDECAR_MIME);
3016        assert_eq!(items[0]["dataBase64"], "stored-bytes");
3017        assert_eq!(items[0]["rawByteLength"], 123);
3018        assert_eq!(items[0]["flags"], 2);
3019    }
3020
3021    #[test]
3022    fn pair_sign_round_trips_bytes() {
3023        let options: SidecarRenderOptions =
3024            serde_json::from_str(r#"{"items":[{"type":"text","codec":"raw","text":"hello"}]}"#)
3025                .unwrap();
3026        let sidecar = prepare_sidecar_render(Some(&options)).unwrap().unwrap();
3027        let mut rgba = vec![128u8; 512 * 4];
3028        for pixel in 0..512 {
3029            rgba[pixel * 4 + 3] = 255;
3030        }
3031        let pairs = (0..256)
3032            .map(|index| (index * 2, index * 2 + 1))
3033            .collect::<Vec<_>>();
3034        paint_sidecar_bytes_into_pairs(&mut rgba, &pairs, &sidecar).unwrap();
3035        let decoded = decode_pairsign_sidecar_bytes_from_pairs(
3036            &rgba,
3037            &pairs,
3038            SIDECAR_SCHEME_PAIRSIGN_SAFE_LUMA_V2,
3039            sidecar.bytes.len(),
3040        )
3041        .unwrap();
3042        assert_eq!(decoded, sidecar.bytes);
3043    }
3044}