Skip to main content

oxihuman_cli/commands/
pack.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Pack/bundle subcommands: pack-build, zip-pack, quantize, morph-export,
5//! asset-bundle, validate-pack, sign-pack, verify-sign.
6
7use anyhow::{bail, Context, Result};
8use std::path::PathBuf;
9
10use oxihuman_core::policy::{Policy, PolicyProfile};
11use oxihuman_core::{
12    read_signature_file, sign_pack_dir, verify_pack_signature, write_signature_file,
13};
14use oxihuman_export::asset_bundle::{bundle_from_dir, export_bundle, AssetBundle as OxbBundle};
15use oxihuman_export::pack::{build_pack, PackBuilderConfig};
16use oxihuman_export::{
17    from_target_files, morph_delta_stats, pack_mesh_assets, quantize_mesh, quantize_stats,
18    write_morph_delta_bin, write_quantized_bin,
19};
20use oxihuman_mesh::MeshBuffers;
21
22// ── pack-build ────────────────────────────────────────────────────────────────
23
24pub fn cmd_pack_build(args: &[String]) -> Result<()> {
25    let mut targets_dir: Option<PathBuf> = None;
26    let mut output: Option<PathBuf> = None;
27    let mut max_targets: Option<usize> = None;
28    let mut strict = false;
29
30    let mut i = 0;
31    while i < args.len() {
32        match args[i].as_str() {
33            "--targets" => {
34                i += 1;
35                targets_dir = Some(PathBuf::from(&args[i]));
36            }
37            "--output" => {
38                i += 1;
39                output = Some(PathBuf::from(&args[i]));
40            }
41            "--max-targets" => {
42                i += 1;
43                max_targets = Some(args[i].parse()?);
44            }
45            "--strict" => {
46                strict = true;
47            }
48            other => bail!("unknown option: {}", other),
49        }
50        i += 1;
51    }
52
53    let targets_dir = targets_dir.context("--targets is required for pack-build")?;
54    if !targets_dir.exists() {
55        bail!("targets directory not found: {}", targets_dir.display());
56    }
57
58    let policy = if strict {
59        Policy::new(PolicyProfile::Strict)
60    } else {
61        Policy::new(PolicyProfile::Standard)
62    };
63
64    eprintln!("OxiHuman: scanning {}...", targets_dir.display());
65    let manifest = build_pack(PackBuilderConfig {
66        targets_dir,
67        policy,
68        max_files: max_targets,
69    })?;
70
71    eprintln!(
72        "OxiHuman: {} files ({} allowed, {} blocked), {} deltas, ~{} KB",
73        manifest.stats.total_files,
74        manifest.stats.allowed_files,
75        manifest.stats.blocked_files,
76        manifest.stats.total_deltas,
77        manifest.stats.estimated_memory_bytes / 1024,
78    );
79
80    if let Some(out) = output {
81        manifest.write_to(&out)?;
82        eprintln!("OxiHuman: manifest -> {}", out.display());
83    } else {
84        println!("{}", manifest.to_toml()?);
85    }
86
87    Ok(())
88}
89
90// ── quantize ──────────────────────────────────────────────────────────────────
91
92pub fn cmd_quantize(args: &[String]) -> Result<()> {
93    use oxihuman_core::parser::obj::parse_obj;
94
95    let mut base: Option<PathBuf> = None;
96    let mut output: Option<PathBuf> = None;
97    let mut stats = false;
98
99    let mut i = 0;
100    while i < args.len() {
101        match args[i].as_str() {
102            "--base" => {
103                i += 1;
104                base = Some(PathBuf::from(&args[i]));
105            }
106            "--output" => {
107                i += 1;
108                output = Some(PathBuf::from(&args[i]));
109            }
110            "--stats" => {
111                stats = true;
112            }
113            other => bail!("unknown option: {}", other),
114        }
115        i += 1;
116    }
117
118    let base = base.context("--base is required for quantize")?;
119    let output = output.context("--output is required for quantize")?;
120
121    if !base.exists() {
122        bail!("base mesh not found: {}", base.display());
123    }
124
125    let src = std::fs::read_to_string(&base)
126        .with_context(|| format!("reading OBJ: {}", base.display()))?;
127    let obj = parse_obj(&src).context("parsing OBJ")?;
128    let morph_buf = oxihuman_morph::engine::MeshBuffers {
129        positions: obj.positions,
130        normals: obj.normals,
131        uvs: obj.uvs,
132        indices: obj.indices,
133        has_suit: false,
134    };
135    let mesh = MeshBuffers::from_morph(morph_buf);
136
137    let qmesh = quantize_mesh(&mesh);
138
139    if stats {
140        let qs = quantize_stats(&mesh, &qmesh);
141        println!("Quantize stats:");
142        println!("  position_error_rms: {:.6}", qs.position_error_rms);
143        println!("  normal_error_rms:   {:.6}", qs.normal_error_rms);
144        println!("  uv_error_rms:       {:.6}", qs.uv_error_rms);
145        println!("  compression_ratio:  {:.3}x", qs.compression_ratio);
146    }
147
148    let bytes = write_quantized_bin(&qmesh, &output)
149        .with_context(|| format!("writing QMSH to {}", output.display()))?;
150
151    println!(
152        "Written QMSH: {} vertices, {} indices → {} bytes",
153        qmesh.positions.len(),
154        qmesh.indices.len(),
155        bytes
156    );
157
158    Ok(())
159}
160
161// ── morph-export ───────────────────────────────────────────────────────────────
162
163pub fn cmd_morph_export(args: &[String]) -> Result<()> {
164    use oxihuman_core::parser::obj::parse_obj;
165    use oxihuman_core::parser::target::parse_target;
166
167    let mut base: Option<PathBuf> = None;
168    let mut targets_dir: Option<PathBuf> = None;
169    let mut output: Option<PathBuf> = None;
170    let mut max_targets: Option<usize> = None;
171
172    let mut i = 0;
173    while i < args.len() {
174        match args[i].as_str() {
175            "--base" => {
176                i += 1;
177                base = Some(PathBuf::from(&args[i]));
178            }
179            "--targets" => {
180                i += 1;
181                targets_dir = Some(PathBuf::from(&args[i]));
182            }
183            "--output" => {
184                i += 1;
185                output = Some(PathBuf::from(&args[i]));
186            }
187            "--max-targets" => {
188                i += 1;
189                max_targets = Some(args[i].parse()?);
190            }
191            other => bail!("unknown option: {}", other),
192        }
193        i += 1;
194    }
195
196    let base = base.context("--base is required for morph-export")?;
197    let targets_dir = targets_dir.context("--targets is required for morph-export")?;
198    let output = output.context("--output is required for morph-export")?;
199
200    if !base.exists() {
201        bail!("base mesh not found: {}", base.display());
202    }
203    if !targets_dir.exists() {
204        bail!("targets directory not found: {}", targets_dir.display());
205    }
206
207    let src = std::fs::read_to_string(&base)
208        .with_context(|| format!("reading OBJ: {}", base.display()))?;
209    let obj = parse_obj(&src).context("parsing OBJ")?;
210    let vertex_count = obj.positions.len() as u32;
211
212    // Collect .target files from directory
213    let mut entries = std::fs::read_dir(&targets_dir)
214        .with_context(|| format!("reading targets dir: {}", targets_dir.display()))?
215        .flatten()
216        .filter(|e| e.path().extension().map(|x| x == "target").unwrap_or(false))
217        .collect::<Vec<_>>();
218    entries.sort_by_key(|e| e.path());
219
220    if let Some(max) = max_targets {
221        entries.truncate(max);
222    }
223
224    let mut target_pairs: Vec<(String, oxihuman_core::parser::target::TargetFile)> = Vec::new();
225    for entry in &entries {
226        let path = entry.path();
227        let name = path
228            .file_stem()
229            .and_then(|s| s.to_str())
230            .unwrap_or_default()
231            .to_string();
232        let text = std::fs::read_to_string(&path)
233            .with_context(|| format!("reading target: {}", path.display()))?;
234        let tf = parse_target(&name, &text)
235            .with_context(|| format!("parsing target: {}", path.display()))?;
236        target_pairs.push((name, tf));
237    }
238
239    let ref_pairs: Vec<(String, &oxihuman_core::parser::target::TargetFile)> =
240        target_pairs.iter().map(|(n, t)| (n.clone(), t)).collect();
241
242    let bin = from_target_files(&ref_pairs, vertex_count);
243    let stats = morph_delta_stats(&bin);
244
245    write_morph_delta_bin(&bin, &output)
246        .with_context(|| format!("writing OXMD to {}", output.display()))?;
247
248    let file_size = std::fs::metadata(&output)?.len() as usize;
249
250    println!(
251        "Written OXMD: {} targets, {} total deltas → {} bytes",
252        bin.targets.len(),
253        stats.total_deltas,
254        file_size
255    );
256
257    Ok(())
258}
259
260// ── zip-pack ───────────────────────────────────────────────────────────────────
261
262pub fn cmd_zip_pack(args: &[String]) -> Result<()> {
263    use oxihuman_core::parser::obj::parse_obj;
264
265    let mut base: Option<PathBuf> = None;
266    let mut targets_dir: Option<PathBuf> = None;
267    let mut output: Option<PathBuf> = None;
268
269    let mut i = 0;
270    while i < args.len() {
271        match args[i].as_str() {
272            "--base" => {
273                i += 1;
274                base = Some(PathBuf::from(&args[i]));
275            }
276            "--targets" => {
277                i += 1;
278                targets_dir = Some(PathBuf::from(&args[i]));
279            }
280            "--output" => {
281                i += 1;
282                output = Some(PathBuf::from(&args[i]));
283            }
284            other => bail!("unknown option: {}", other),
285        }
286        i += 1;
287    }
288
289    let base = base.context("--base is required for zip-pack")?;
290    let targets_dir = targets_dir.context("--targets is required for zip-pack")?;
291    let output = output.context("--output is required for zip-pack")?;
292
293    if !base.exists() {
294        bail!("base mesh not found: {}", base.display());
295    }
296    if !targets_dir.exists() {
297        bail!("targets directory not found: {}", targets_dir.display());
298    }
299
300    // Build GLB from base OBJ
301    let src = std::fs::read_to_string(&base)
302        .with_context(|| format!("reading OBJ: {}", base.display()))?;
303    let obj = parse_obj(&src).context("parsing OBJ")?;
304    let morph_buf = oxihuman_morph::engine::MeshBuffers {
305        positions: obj.positions,
306        normals: obj.normals,
307        uvs: obj.uvs,
308        indices: obj.indices,
309        has_suit: false,
310    };
311    let mut mesh = MeshBuffers::from_morph(morph_buf);
312
313    // Apply suit flag and normals so GLB export accepts the mesh
314    oxihuman_mesh::normals::compute_normals(&mut mesh);
315    oxihuman_mesh::suit::apply_suit_flag(&mut mesh);
316
317    // Export mesh to GLB bytes
318    let tmp_glb = output.with_extension("_tmp.glb");
319    oxihuman_export::glb::export_glb(&mesh, &tmp_glb)
320        .with_context(|| format!("exporting GLB to {}", tmp_glb.display()))?;
321    let glb_bytes = std::fs::read(&tmp_glb)?;
322    let _ = std::fs::remove_file(&tmp_glb);
323
324    // Build params JSON
325    let params_json = serde_json::to_vec(&serde_json::json!({
326        "base": base.display().to_string(),
327        "targets": targets_dir.display().to_string(),
328    }))?;
329
330    // Build manifest JSON (list of .target file names)
331    let target_names: Vec<String> = std::fs::read_dir(&targets_dir)
332        .with_context(|| format!("reading targets dir: {}", targets_dir.display()))?
333        .flatten()
334        .filter(|e| e.path().extension().map(|x| x == "target").unwrap_or(false))
335        .map(|e| e.file_name().to_string_lossy().into_owned())
336        .collect();
337    let manifest_json = serde_json::to_vec(&serde_json::json!({ "targets": target_names }))?;
338
339    let result = pack_mesh_assets(&glb_bytes, &params_json, &manifest_json, &output)
340        .with_context(|| format!("writing ZIP to {}", output.display()))?;
341
342    println!(
343        "Written ZIP pack: {} entries → {}",
344        result.entry_count,
345        output.display()
346    );
347
348    Ok(())
349}
350
351// ── asset-bundle ──────────────────────────────────────────────────────────────
352
353#[allow(dead_code)]
354pub fn cmd_asset_bundle(args: &[String]) -> Result<()> {
355    use oxihuman_core::parser::obj::parse_obj;
356
357    let mut base: Option<PathBuf> = None;
358    let mut targets_dir: Option<PathBuf> = None;
359    let mut output: Option<PathBuf> = None;
360    let mut manifest: Option<PathBuf> = None;
361
362    let mut i = 0;
363    while i < args.len() {
364        match args[i].as_str() {
365            "--base" => {
366                i += 1;
367                base = Some(PathBuf::from(&args[i]));
368            }
369            "--targets" => {
370                i += 1;
371                targets_dir = Some(PathBuf::from(&args[i]));
372            }
373            "--output" => {
374                i += 1;
375                output = Some(PathBuf::from(&args[i]));
376            }
377            "--manifest" => {
378                i += 1;
379                manifest = Some(PathBuf::from(&args[i]));
380            }
381            other => bail!("unknown option: {}", other),
382        }
383        i += 1;
384    }
385
386    let base = base.context("--base is required for asset-bundle")?;
387    let targets_dir = targets_dir.context("--targets is required for asset-bundle")?;
388    let output = output.context("--output is required for asset-bundle")?;
389
390    if !base.exists() {
391        bail!("base mesh not found: {}", base.display());
392    }
393    if !targets_dir.exists() {
394        bail!("targets directory not found: {}", targets_dir.display());
395    }
396
397    // Load the OBJ file bytes.
398    let obj_bytes =
399        std::fs::read(&base).with_context(|| format!("reading OBJ: {}", base.display()))?;
400
401    // Build a bundle starting with the base OBJ.
402    let mut bundle = OxbBundle::new();
403    let base_name = base
404        .file_name()
405        .and_then(|n| n.to_str())
406        .unwrap_or("base.obj")
407        .to_string();
408    bundle
409        .add_bytes(&base_name, obj_bytes)
410        .with_context(|| format!("adding base OBJ '{}' to bundle", base_name))?;
411
412    // Also scan the OBJ for a quick stat check.
413    let obj_src = std::fs::read_to_string(&base)
414        .with_context(|| format!("reading OBJ: {}", base.display()))?;
415    let _obj = parse_obj(&obj_src).context("parsing OBJ")?;
416
417    // Scan the targets directory and add each .target file.
418    let target_bundle = bundle_from_dir(&targets_dir)
419        .with_context(|| format!("scanning targets dir: {}", targets_dir.display()))?;
420    let target_count = target_bundle.entry_count();
421    for name in target_bundle.entry_names() {
422        if let Some(entry) = target_bundle.get(name) {
423            let entry_data = entry.data.clone();
424            // Avoid name collisions with base OBJ by prefixing with "targets/".
425            let bundle_name = format!("targets/{}", name);
426            bundle.add_bytes(bundle_name, entry_data).ok(); // skip duplicates silently
427        }
428    }
429
430    // Optionally include manifest bytes.
431    if let Some(ref mp) = manifest {
432        if !mp.exists() {
433            bail!("manifest file not found: {}", mp.display());
434        }
435        let manifest_bytes =
436            std::fs::read(mp).with_context(|| format!("reading manifest: {}", mp.display()))?;
437        bundle.add_bytes("manifest.toml", manifest_bytes).ok();
438    }
439
440    let total_assets = bundle.entry_count();
441    export_bundle(&bundle, &output)
442        .with_context(|| format!("writing bundle: {}", output.display()))?;
443
444    println!(
445        "Written bundle: {} assets → {}",
446        total_assets,
447        output.display()
448    );
449    let _ = target_count; // suppress unused warning if targets dir was empty
450    Ok(())
451}
452
453// ── validate-pack ─────────────────────────────────────────────────────────────
454
455#[allow(dead_code)]
456pub fn cmd_validate_pack(args: &[String]) -> Result<()> {
457    use oxihuman_core::{scan_pack, verify_manifest_present, verify_pack};
458
459    let mut pack_dir: Option<PathBuf> = None;
460
461    let mut i = 0;
462    while i < args.len() {
463        match args[i].as_str() {
464            "--pack-dir" => {
465                i += 1;
466                pack_dir = Some(PathBuf::from(&args[i]));
467            }
468            other => bail!("unknown option: {}", other),
469        }
470        i += 1;
471    }
472
473    let pack_dir = pack_dir.context("--pack-dir is required for validate-pack")?;
474
475    if !pack_dir.exists() {
476        bail!("pack directory not found: {}", pack_dir.display());
477    }
478
479    // Check for manifest file.
480    match verify_manifest_present(&pack_dir) {
481        Ok(()) => println!("Manifest: present"),
482        Err(e) => println!("Manifest: MISSING — {}", e),
483    }
484
485    // Scan and verify all files.
486    let records =
487        scan_pack(&pack_dir).with_context(|| format!("scanning pack: {}", pack_dir.display()))?;
488    println!("Scanned {} file(s)", records.len());
489
490    let report = verify_pack(&pack_dir, &records);
491    println!("{}", report.summary());
492
493    if !report.is_valid {
494        if !report.failed_files.is_empty() {
495            println!("Failed files:");
496            for f in &report.failed_files {
497                println!("  FAIL: {}", f);
498            }
499        }
500        if !report.missing_files.is_empty() {
501            println!("Missing files:");
502            for f in &report.missing_files {
503                println!("  MISS: {}", f);
504            }
505        }
506        bail!("pack validation failed");
507    }
508    Ok(())
509}
510
511// ── sign-pack ─────────────────────────────────────────────────────────────────
512
513#[allow(dead_code)]
514pub fn cmd_sign_pack(args: &[String]) -> Result<()> {
515    let mut pack_dir: Option<PathBuf> = None;
516    let mut key_str: Option<String> = None;
517    let mut signer_id = String::from("oxihuman-cli");
518    let mut output: Option<PathBuf> = None;
519
520    let mut i = 0;
521    while i < args.len() {
522        match args[i].as_str() {
523            "--pack-dir" => {
524                i += 1;
525                pack_dir = Some(PathBuf::from(&args[i]));
526            }
527            "--key" => {
528                i += 1;
529                key_str = Some(args[i].clone());
530            }
531            "--signer-id" => {
532                i += 1;
533                signer_id = args[i].clone();
534            }
535            "--output" => {
536                i += 1;
537                output = Some(PathBuf::from(&args[i]));
538            }
539            other => bail!("sign-pack: unknown option: {}", other),
540        }
541        i += 1;
542    }
543
544    let pack_dir = pack_dir.context("--pack-dir is required for sign-pack")?;
545    let key_str = key_str.context("--key is required for sign-pack")?;
546    let output = output.context("--output is required for sign-pack")?;
547
548    if !pack_dir.exists() {
549        bail!("pack-dir not found: {}", pack_dir.display());
550    }
551
552    let signed = sign_pack_dir(&pack_dir, key_str.as_bytes(), &signer_id)
553        .with_context(|| format!("signing pack dir: {}", pack_dir.display()))?;
554    write_signature_file(&signed, &output)
555        .with_context(|| format!("writing signature file: {}", output.display()))?;
556    println!("Pack signed. Signature written to: {}", output.display());
557    Ok(())
558}
559
560// ── verify-sign ───────────────────────────────────────────────────────────────
561
562#[allow(dead_code)]
563pub fn cmd_verify_sign(args: &[String]) -> Result<()> {
564    let mut pack_dir: Option<PathBuf> = None;
565    let mut sig_file: Option<PathBuf> = None;
566    let mut key_str: Option<String> = None;
567
568    let mut i = 0;
569    while i < args.len() {
570        match args[i].as_str() {
571            "--pack-dir" => {
572                i += 1;
573                pack_dir = Some(PathBuf::from(&args[i]));
574            }
575            "--sig-file" => {
576                i += 1;
577                sig_file = Some(PathBuf::from(&args[i]));
578            }
579            "--key" => {
580                i += 1;
581                key_str = Some(args[i].clone());
582            }
583            other => bail!("verify-sign: unknown option: {}", other),
584        }
585        i += 1;
586    }
587
588    let pack_dir = pack_dir.context("--pack-dir is required for verify-sign")?;
589    let sig_file = sig_file.context("--sig-file is required for verify-sign")?;
590    let key_str = key_str.context("--key is required for verify-sign")?;
591
592    if !pack_dir.exists() {
593        bail!("pack-dir not found: {}", pack_dir.display());
594    }
595    if !sig_file.exists() {
596        bail!("sig-file not found: {}", sig_file.display());
597    }
598
599    let signed = read_signature_file(&sig_file)
600        .with_context(|| format!("reading sig file: {}", sig_file.display()))?;
601    if verify_pack_signature(&pack_dir, &signed, key_str.as_bytes()) {
602        println!("VALID");
603    } else {
604        println!("INVALID");
605    }
606    Ok(())
607}
608
609// ── pack-dist-manifest ────────────────────────────────────────────────────────
610
611/// Generate a SHA-256 distribution manifest for all files in a pack directory.
612///
613/// Usage: `oxihuman pack-dist-manifest --pack-dir <DIR>`
614///
615/// Prints the manifest JSON to stdout.
616#[allow(dead_code)]
617pub fn cmd_pack_dist_manifest(args: &[String]) -> Result<()> {
618    let mut pack_dir: Option<PathBuf> = None;
619
620    let mut i = 0;
621    while i < args.len() {
622        match args[i].as_str() {
623            "--pack-dir" => {
624                i += 1;
625                if i >= args.len() {
626                    bail!("pack-dist-manifest: --pack-dir requires an argument");
627                }
628                pack_dir = Some(PathBuf::from(&args[i]));
629            }
630            other => bail!("pack-dist-manifest: unknown option: {}", other),
631        }
632        i += 1;
633    }
634
635    let pack_dir = pack_dir.context("--pack-dir is required for pack-dist-manifest")?;
636    if !pack_dir.exists() {
637        bail!("pack-dir not found: {}", pack_dir.display());
638    }
639
640    let manifest = oxihuman_core::asset_pack_builder::generate_distribution_manifest(&pack_dir)
641        .with_context(|| {
642            format!(
643                "generating distribution manifest for: {}",
644                pack_dir.display()
645            )
646        })?;
647    println!("{manifest}");
648    Ok(())
649}
650
651// ── pack-verify-dist ──────────────────────────────────────────────────────────
652
653/// Verify all files in a pack directory against a distribution manifest.
654///
655/// Usage: `oxihuman pack-verify-dist --manifest <FILE> --pack-dir <DIR>`
656///
657/// Exits with code 0 on success, 1 on verification failure.
658#[allow(dead_code)]
659pub fn cmd_pack_verify_dist(args: &[String]) -> Result<()> {
660    let mut manifest_path: Option<PathBuf> = None;
661    let mut pack_dir: Option<PathBuf> = None;
662
663    let mut i = 0;
664    while i < args.len() {
665        match args[i].as_str() {
666            "--manifest" => {
667                i += 1;
668                if i >= args.len() {
669                    bail!("pack-verify-dist: --manifest requires an argument");
670                }
671                manifest_path = Some(PathBuf::from(&args[i]));
672            }
673            "--pack-dir" => {
674                i += 1;
675                if i >= args.len() {
676                    bail!("pack-verify-dist: --pack-dir requires an argument");
677                }
678                pack_dir = Some(PathBuf::from(&args[i]));
679            }
680            other => bail!("pack-verify-dist: unknown option: {}", other),
681        }
682        i += 1;
683    }
684
685    let manifest_path = manifest_path.context("--manifest is required for pack-verify-dist")?;
686    let pack_dir = pack_dir.context("--pack-dir is required for pack-verify-dist")?;
687
688    if !manifest_path.exists() {
689        bail!("manifest file not found: {}", manifest_path.display());
690    }
691    if !pack_dir.exists() {
692        bail!("pack-dir not found: {}", pack_dir.display());
693    }
694
695    let json = std::fs::read_to_string(&manifest_path)
696        .with_context(|| format!("reading manifest: {}", manifest_path.display()))?;
697
698    let ok = oxihuman_core::asset_pack_builder::verify_distribution_manifest(&json, &pack_dir)
699        .with_context(|| {
700            format!(
701                "verifying distribution manifest against: {}",
702                pack_dir.display()
703            )
704        })?;
705
706    if ok {
707        println!("Manifest verification: OK");
708    } else {
709        eprintln!("Manifest verification: FAILED");
710        std::process::exit(1);
711    }
712    Ok(())
713}