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