cavs-cli 0.8.0

Package, verify and deliver deduplicated game content as .cavs files.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Packing (conversion into .cavs).
//!
//! Video mode: each input is segmented with ffmpeg into CMAF/fMP4
//! (init.mp4 + seg_%05d.m4s + media.m3u8), and those artifacts are chunked,
//! deduplicated and stored. Byte-identical content across segments and across
//! inputs (shared intros, repeated stills, same episode at two cuts...) is
//! stored exactly once.
//!
//! Raw mode: arbitrary files are chunked with FastCDC and stored as data
//! tracks; unpacking reproduces them byte-for-byte.

use crate::profile::{self, ChunkProfile, CostWeights};
use crate::{classify, ffmpeg, report, ChunkModeArg};
use anyhow::{bail, Context, Result};
use cavs_chunker::ChunkMode;
use cavs_format::{SegmentRecord, TrackKind, TrackRecord, Writer, SEGMENT_FLAG_RANDOM_ACCESS};
use std::collections::HashSet;
use std::path::{Path, PathBuf};

/// Track-id namespace: playlist/data companion tracks of video track `n`
/// live at `PLAYLIST_TRACK_BASE + n`.
pub const PLAYLIST_TRACK_BASE: u32 = 1000;

pub struct PackOptions {
    pub segment_time: f64,
    pub mode: Option<ChunkModeArg>,
    pub chunk_size: Option<usize>,
    /// `auto`, or a fixed profile label (see [`ChunkProfile`]). Wins over
    /// `mode`/`chunk_size` when set.
    pub profile: Option<String>,
    /// Previous version of the (single) input for update-aware auto profile.
    pub prev: Option<PathBuf>,
    /// Also emit `<output>.bootstrap.zst` (raw mode, single input).
    pub bootstrap: bool,
    pub compress: bool,
    pub zstd_level: i32,
    pub force_transcode: bool,
    pub sign_key: Option<PathBuf>,
    /// Report how much of the new payload a client holding the signed old
    /// version could reuse via hybrid reconstruction (v0.6.0).
    pub against_signature: Option<PathBuf>,
}

/// Load an Ed25519 secret key (hex file from `cavs keygen`) and attach it.
fn apply_signing(w: &mut Writer, opts: &PackOptions) -> Result<()> {
    let Some(path) = &opts.sign_key else {
        return Ok(());
    };
    sign_writer(w, path)
}

/// Attach a signing key to any writer (shared with `pack-dir`).
pub fn sign_writer(w: &mut Writer, path: &Path) -> Result<()> {
    let hex = std::fs::read_to_string(path)
        .with_context(|| format!("cannot read signing key {}", path.display()))?;
    let hex = hex.trim();
    if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
        bail!("{} is not a 64-hex-char Ed25519 secret key", path.display());
    }
    let mut secret = [0u8; 32];
    for (i, byte) in secret.iter_mut().enumerate() {
        *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).unwrap();
    }
    w.sign_with(&secret);
    eprintln!("[pack] content will be signed (Ed25519)");
    Ok(())
}

impl PackOptions {
    fn media_mode(&self) -> ChunkMode {
        match self.mode {
            Some(m) => m.to_mode(self.chunk_size),
            None => ChunkMode::media_default(),
        }
    }

    fn asset_mode(&self) -> ChunkMode {
        match self.mode {
            Some(m) => m.to_mode(self.chunk_size),
            None => ChunkMode::asset_default(),
        }
    }
}

/// Resolve the chunk mode for one raw input. With `--profile auto` the
/// payload is classified and the recommended candidate profiles are measured
/// on the real bytes (against `--prev` when given); a forced profile label
/// maps directly; otherwise the legacy `--mode`/default applies.
/// Returns the mode plus the profile label to record in metadata.
fn resolve_raw_mode(
    input: &Path,
    data: &[u8],
    opts: &PackOptions,
) -> Result<(ChunkMode, Option<&'static str>, Option<&'static str>)> {
    let Some(profile_arg) = opts.profile.as_deref() else {
        return Ok((opts.asset_mode(), None, None));
    };
    if profile_arg != "auto" {
        let p = ChunkProfile::parse(profile_arg)?;
        return Ok((p.to_mode(), Some(p.label()), None));
    }

    let payload = classify::classify(input, data);
    let prev = match &opts.prev {
        Some(p) => Some(profile::load_prev(p)?),
        None => None,
    };

    // Update-heavy payload, first version, bootstrap covers the cold path:
    // there is nothing to measure updates against yet, so pick the
    // benchmark-validated update profile instead of letting the cold-egress
    // sweep lock the whole version stream into large chunks. Subsequent
    // versions (--prev) measure real reuse and keep continuity.
    if payload.likely_update_heavy && prev.is_none() && opts.bootstrap {
        let p = ChunkProfile::FastCdc64K;
        eprintln!(
            "[pack] {} classified as {} -> profile {} (update-heavy; cold path served by bootstrap)",
            input.display(),
            payload.kind.label(),
            p.label(),
        );
        return Ok((p.to_mode(), Some(p.label()), Some(payload.kind.label())));
    }

    let weights = if prev.is_some() {
        CostWeights::live_updates()
    } else {
        CostWeights::cold_install()
    };
    let estimates: Vec<_> = payload
        .recommended_profiles
        .iter()
        .map(|&p| profile::estimate(data, prev.as_ref(), p, opts.zstd_level))
        .collect();
    let best = profile::choose_best(&estimates, &weights);
    eprintln!(
        "[pack] {} classified as {} (entropy {:.2}, zstd probe {:.3}) -> profile {}",
        input.display(),
        payload.kind.label(),
        payload.entropy_score,
        payload.zstd_sample_ratio,
        best.label(),
    );
    Ok((
        best.to_mode(),
        Some(best.label()),
        Some(payload.kind.label()),
    ))
}

/// Write the full bootstrap artifact next to the `.cavs`: the whole input
/// zstd-compressed at a high level, so a cache-less client can install at
/// full-artifact cost and seed its chunk cache locally (P0-1 dual route).
/// Level 19 spends pack-time CPU once to minimise every cold install.
const BOOTSTRAP_ZSTD_LEVEL: i32 = 19;

fn write_bootstrap(output: &Path, name: &str, data: &[u8], w: &mut Writer) -> Result<()> {
    let path = PathBuf::from(format!("{}.bootstrap.zst", output.display()));
    let compressed = zstd::bulk::compress(data, BOOTSTRAP_ZSTD_LEVEL)
        .context("compressing bootstrap artifact")?;
    std::fs::write(&path, &compressed)
        .with_context(|| format!("cannot write {}", path.display()))?;
    let blake3_hex = cavs_hash::to_hex(&cavs_hash::hash_chunk(&compressed));
    w.set_meta("bootstrap.name", name);
    w.set_meta("bootstrap.size", &compressed.len().to_string());
    w.set_meta("bootstrap.blake3", &blake3_hex);
    eprintln!(
        "[pack] bootstrap artifact: {} ({} bytes, zstd-{BOOTSTRAP_ZSTD_LEVEL})",
        path.display(),
        compressed.len()
    );
    Ok(())
}

/// Add `data` as chunks and return the chunk indices.
fn add_chunked(w: &mut Writer, data: &[u8], mode: ChunkMode) -> Result<Vec<u32>> {
    let mut idxs = Vec::new();
    for range in cavs_chunker::split(data, mode) {
        idxs.push(w.add_chunk(&data[range])?);
    }
    Ok(idxs)
}

/// Derive a unique, filesystem-safe logical name from an input path.
fn unique_stem(path: &Path, used: &mut HashSet<String>) -> String {
    let base = path
        .file_stem()
        .map(|s| s.to_string_lossy().to_string())
        .unwrap_or_else(|| "input".to_string())
        .replace(['/', '\\'], "_");
    let mut name = base.clone();
    let mut n = 1;
    while !used.insert(name.clone()) {
        n += 1;
        name = format!("{base}-{n}");
    }
    name
}

pub fn pack_video(inputs: &[PathBuf], output: &Path, opts: &PackOptions) -> Result<()> {
    ffmpeg::require("ffmpeg")?;

    let uuid = *uuid::Uuid::new_v4().as_bytes();
    let mut w = Writer::create(output, uuid, 1000, opts.compress)
        .with_context(|| format!("cannot create {}", output.display()))?;
    w.set_zstd_level(opts.zstd_level);
    apply_signing(&mut w, opts)?;
    w.set_meta("packer", concat!("cavs-cli ", env!("CARGO_PKG_VERSION")));
    w.set_meta("payload", "cmaf-hls");
    w.set_meta("segment_time", &format!("{}", opts.segment_time));

    let mut used_names = HashSet::new();
    let mut segment_id = 0u64;

    for (i, input) in inputs.iter().enumerate() {
        let track_id = i as u32 + 1;
        let stem = unique_stem(input, &mut used_names);
        eprintln!("[pack] segmenting {} with ffmpeg...", input.display());

        let workdir = tempfile::tempdir().context("cannot create temp dir")?;
        let copied = ffmpeg::segment_to_cmaf(
            input,
            workdir.path(),
            opts.segment_time,
            opts.force_transcode,
        )?;
        eprintln!(
            "[pack]   mode: {}",
            if copied {
                "stream copy"
            } else {
                "transcode h264+aac"
            }
        );

        // Init segment: pinned into the global dictionary (bootstrap payload).
        let init_bytes = std::fs::read(workdir.path().join("init.mp4"))
            .context("ffmpeg did not produce init.mp4")?;
        let init_chunks = add_chunked(&mut w, &init_bytes, opts.media_mode())?;
        for &c in &init_chunks {
            w.pin_dict(c)?;
        }

        let playlist_text = std::fs::read_to_string(workdir.path().join("media.m3u8"))
            .context("ffmpeg did not produce media.m3u8")?;
        let playlist_segments = ffmpeg::parse_playlist(&playlist_text);
        if playlist_segments.is_empty() {
            bail!("playlist has no media segments");
        }

        w.add_track(TrackRecord {
            track_id,
            kind: TrackKind::Video,
            flags: 0,
            codec: ffmpeg::probe_codecs(input),
            name: stem.clone(),
            timescale: 1000,
            init_chunks,
        })?;

        // Every fMP4 segment starts at a random-access point (keyframe
        // bundle boundary), so each CAVS segment is self-sufficient given
        // the init chunks.
        let mut pts_ms = 0u64;
        for (uri, dur_secs) in &playlist_segments {
            let seg_bytes = std::fs::read(workdir.path().join(uri))
                .with_context(|| format!("missing media segment {uri}"))?;
            let chunks = add_chunked(&mut w, &seg_bytes, opts.media_mode())?;
            let dur_ms = (dur_secs * 1000.0).round() as u32;
            w.add_segment(SegmentRecord {
                segment_id,
                track_id,
                pts_start: pts_ms,
                duration: dur_ms,
                flags: SEGMENT_FLAG_RANDOM_ACCESS,
                chunks,
            })?;
            segment_id += 1;
            pts_ms += dur_ms as u64;
        }

        // The playlist itself, as a companion data track, so unpacking can
        // emit a directly playable HLS layout.
        let playlist_chunks = add_chunked(&mut w, playlist_text.as_bytes(), opts.asset_mode())?;
        w.add_track(TrackRecord {
            track_id: PLAYLIST_TRACK_BASE + track_id,
            kind: TrackKind::Data,
            flags: 0,
            codec: "m3u8".to_string(),
            name: format!("{stem}/media.m3u8"),
            timescale: 1000,
            init_chunks: Vec::new(),
        })?;
        w.add_segment(SegmentRecord {
            segment_id,
            track_id: PLAYLIST_TRACK_BASE + track_id,
            pts_start: 0,
            duration: 0,
            flags: SEGMENT_FLAG_RANDOM_ACCESS,
            chunks: playlist_chunks,
        })?;
        segment_id += 1;

        eprintln!(
            "[pack]   {} media segments, total {:.1}s",
            playlist_segments.len(),
            pts_ms as f64 / 1000.0
        );
    }

    let stats = w.finish()?;
    report::print_pack_stats(output, &stats);
    Ok(())
}

pub fn pack_raw(inputs: &[PathBuf], output: &Path, opts: &PackOptions) -> Result<()> {
    let uuid = *uuid::Uuid::new_v4().as_bytes();
    let mut w = Writer::create(output, uuid, 1000, opts.compress)
        .with_context(|| format!("cannot create {}", output.display()))?;
    w.set_zstd_level(opts.zstd_level);
    apply_signing(&mut w, opts)?;
    w.set_meta("packer", concat!("cavs-cli ", env!("CARGO_PKG_VERSION")));
    w.set_meta("payload", "raw");

    let mut used_names = HashSet::new();
    for (i, input) in inputs.iter().enumerate() {
        let name = {
            let file_name = input
                .file_name()
                .map(|s| s.to_string_lossy().to_string())
                .unwrap_or_else(|| format!("file-{i}"));
            let mut candidate = file_name.clone();
            let mut n = 1;
            while !used_names.insert(candidate.clone()) {
                n += 1;
                candidate = format!("{n}-{file_name}");
            }
            candidate
        };
        let data =
            std::fs::read(input).with_context(|| format!("cannot read {}", input.display()))?;
        eprintln!(
            "[pack] {} ({} bytes) as raw asset track",
            input.display(),
            data.len()
        );
        // Per-file SHA-256 in meta: lets thin clients (e.g. the Godot
        // GDScript runtime, which has no BLAKE3) verify reconstruction
        // end-to-end with their built-in hasher.
        {
            use sha2::{Digest, Sha256};
            let digest = Sha256::digest(&data);
            let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
            w.set_meta(&format!("sha256:{name}"), &hex);
        }
        let (mode, profile_label, kind_label) = resolve_raw_mode(input, &data, opts)?;
        if let Some(label) = profile_label {
            w.set_meta(&format!("profile:{name}"), label);
        }
        if let Some(kind) = kind_label {
            w.set_meta(&format!("payload_kind:{name}"), kind);
        }
        if opts.bootstrap {
            if inputs.len() == 1 {
                write_bootstrap(output, &name, &data, &mut w)?;
            } else if i == 0 {
                eprintln!("[pack] --bootstrap ignored: it requires a single input");
            }
        }
        // v0.6.0: estimate hybrid reuse against a previous version known
        // only through its signature (no old bytes required).
        if let Some(sig_path) = &opts.against_signature {
            let sig_bytes = std::fs::read(sig_path)
                .with_context(|| format!("cannot read {}", sig_path.display()))?;
            let sig = cavs_signature::CavsSignature::decode(&sig_bytes)
                .map_err(|e| anyhow::anyhow!("bad signature {}: {e}", sig_path.display()))?;
            let idx = cavs_signature::diff::WeakHashIndex::build(&sig);
            let plan = cavs_signature::diff::diff_bytes(&idx, &data, Some(&name));
            eprintln!(
                "[pack] vs {}: {:.1}% reusable from the previous install ({} fresh across {} ops)",
                sig.source_label,
                plan.reused_bytes as f64 * 100.0 / data.len().max(1) as f64,
                plan.inline_bytes,
                plan.ops.len(),
            );
        }
        let chunks = add_chunked(&mut w, &data, mode)?;
        let track_id = i as u32 + 1;
        w.add_track(TrackRecord {
            track_id,
            kind: TrackKind::Data,
            flags: 0,
            codec: "raw".to_string(),
            name,
            timescale: 1000,
            init_chunks: Vec::new(),
        })?;
        w.add_segment(SegmentRecord {
            segment_id: i as u64,
            track_id,
            pts_start: 0,
            duration: 0,
            flags: SEGMENT_FLAG_RANDOM_ACCESS,
            chunks,
        })?;
    }

    let stats = w.finish()?;
    report::print_pack_stats(output, &stats);
    Ok(())
}