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(¤t_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}