Files
lightcraft/xtask/src/assets.rs
T
Claude 6bd64e4ad4 LR-IMP-FORMATS: Panasonic RW2 / Leica RWL / Panasonic RAW in every raw format
Decode every raw encoding Panasonic and Leica bodies write, from the DMC-LX1 (2005) to the DC-S1RM2 (2025):
compressed formats 4 (most bodies, 2008-2022) and 6 (S1 / S1R / S1H / S5, GH5M2), the prefix-coded strips of
format 8 (GH6 and later), packed formats 2, 5 and 7, and the 16-bit words of the 2005-2007 .RAW files. Until now
only format 5 decoded; every other Panasonic file opened as preview only.

Clean-room: no raw-decoder source was read. Tag names come from tag documentation; every encoding was recovered
by black-box analysis of 178 CC0 files from raw.pixls.us (118 bodies), and the module docs note the measurement
that settled each rule. Format-8 tags of unknown meaning must carry the values every sample has, otherwise the file
is reported as unsupported rather than guessed at.

Levels and geometry: formats 4 and 6 store samples 15 above the recorded black level (files without one use 15);
white is the active area's saturation plateau (the linearity limits are wrong on extended low-ISO shots, the GF1
and the Digilux 3); the in-camera aspect ratio is the default crop; mapped-out defect pixels are filled from
same-colour neighbours; the CFA pattern applies at sample (0, 0).

.rwl and .raw files are imported (engine, MCP folder expansion, file dialog, Linux MIME types). One file per
encoding joins the never-panic proptest; 11 CC0 files join `cargo xtask corpus`, and corpus_panasonic_encodings
checks each encoding's bit depth, black level, crop and CFA layout against the camera's own JPEG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtQmZ6peZXGn3wiqeowV2v
2026-10-07 16:00:39 +00:00

137 lines
6.1 KiB
Rust

//! Asset attribution check (AGENTS.md "Assets" rule): every image, icon, font, sound, video, raw
//! file or colour profile in the repository must be covered by a path pattern in the first column of
//! `assets/ATTRIBUTION.md`, licence files referenced there must exist, and nothing may look like it
//! came from an Adobe product.
use std::path::Path;
use std::process::Command;
/// Extensions treated as assets (lower-case).
const ASSET_EXT: &[&str] = &[
"png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "icns", "bmp", "ttf", "otf", "woff", "woff2", "wav", "mp3", "ogg", "flac", "mp4",
"mov", "tif", "tiff", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "heic", "jxl", "psd", "cube", "icc", "icm",
"xmp",
];
/// Adobe-specific formats we never ship (camera/lens profiles, Lightroom templates).
const FORBIDDEN_EXT: &[&str] = &["dcp", "lcp", "lrtemplate", "lrcat", "lrsmcol", "aco", "ase", "abr"];
const FORBIDDEN_NAME: &[&str] = &["adobe", "lightroom", "photoshop", "creative cloud", "creativecloud"];
/// `*` matches any run of characters except `/`; a pattern ending in `/` matches everything below it.
pub fn glob(pat: &str, path: &str) -> bool {
if let Some(dir) = pat.strip_suffix('/') {
return path.starts_with(dir) && path[dir.len()..].starts_with('/');
}
fn rec(p: &[u8], s: &[u8]) -> bool {
match (p.first(), s.first()) {
(None, None) => true,
(Some(b'*'), _) => rec(&p[1..], s) || (!s.is_empty() && s[0] != b'/' && rec(p, &s[1..])),
(Some(a), Some(b)) if a == b => rec(&p[1..], &s[1..]),
_ => false,
}
}
rec(pat.as_bytes(), path.as_bytes())
}
fn backticked(cell: &str) -> impl Iterator<Item = &str> {
cell.split('`').skip(1).step_by(2)
}
/// (path patterns, referenced licence files) from the attribution table.
pub fn parse(md: &str) -> (Vec<String>, Vec<String>) {
let (mut pats, mut licences) = (Vec::new(), Vec::new());
for line in md.lines().filter(|l| l.trim_start().starts_with('|')) {
let cells: Vec<&str> = line.trim().trim_matches('|').split('|').collect();
if cells.len() < 5 {
continue;
}
pats.extend(backticked(cells[0]).filter(|p| p.contains('/')).map(str::to_string));
licences.extend(backticked(cells[4]).filter(|p| p.contains('/')).map(str::to_string));
}
(pats, licences)
}
/// Problems with `files` given the attribution document `md`; `exists` checks licence files.
pub fn check(files: &[String], md: &str, exists: impl Fn(&str) -> bool) -> Vec<String> {
let (pats, licences) = parse(md);
let mut out = Vec::new();
for f in files {
let lower = f.to_ascii_lowercase();
let ext = Path::new(&lower).extension().and_then(|e| e.to_str()).unwrap_or("");
let name = Path::new(&lower).file_name().and_then(|e| e.to_str()).unwrap_or("");
if FORBIDDEN_EXT.contains(&ext) {
out.push(format!("{f}: `.{ext}` files (Adobe profile/template formats) must never be committed"));
continue;
}
if !ASSET_EXT.contains(&ext) {
continue;
}
if FORBIDDEN_NAME.iter().any(|n| name.contains(n)) {
out.push(format!("{f}: asset name suggests Adobe material; Adobe assets are forbidden (AGENTS.md)"));
}
if !pats.iter().any(|p| glob(p, f)) {
out.push(format!("{f}: no entry in assets/ATTRIBUTION.md (add path, creator, source, licence, date, modifications)"));
}
}
for l in licences {
if !exists(&l) {
out.push(format!("assets/ATTRIBUTION.md references missing licence file `{l}`"));
}
}
out
}
pub fn run(root: &Path) -> Result<(), String> {
let out = Command::new("git")
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
.current_dir(root)
.output()
.map_err(|e| format!("git ls-files: {e}"))?;
if !out.status.success() {
return Err("git ls-files failed".into());
}
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|f| root.join(f).exists()).map(str::to_string).collect();
let md = std::fs::read_to_string(root.join("assets/ATTRIBUTION.md")).map_err(|e| format!("assets/ATTRIBUTION.md: {e}"))?;
let problems = check(&files, &md, |p| root.join(p).exists());
if problems.is_empty() {
let n = files
.iter()
.filter(|f| Path::new(&f.to_ascii_lowercase()).extension().and_then(|e| e.to_str()).is_some_and(|e| ASSET_EXT.contains(&e)))
.count();
eprintln!("assets: {n} asset files, all attributed");
Ok(())
} else {
Err(format!("asset rule violations:\n {}", problems.join("\n ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
const MD: &str = "| Path | Asset | Creator | Source | Licence | Added | Mods |\n|---|---|---|---|---|---|---|\n\
| `assets/fonts/Foo-*.ttf` | Foo | A | url | OFL (`assets/fonts/OFL-Foo.txt`) | d | none |\n\
| `crates/scenes/` | gen | us | original | MIT | d | n/a |\n";
#[test]
fn globbing() {
assert!(glob("docs/images/*-tetons.jpg", "docs/images/ba-tetons.jpg"));
assert!(!glob("docs/images/*.jpg", "docs/images/sub/x.jpg"));
assert!(glob("crates/scenes/", "crates/scenes/a/b.png"));
assert!(!glob("crates/scenes/", "crates/scenesx/b.png"));
}
#[test]
fn flags_unattributed_forbidden_and_missing_licence() {
let files: Vec<String> =
["assets/fonts/Foo-Bold.ttf", "assets/icons/x.svg", "crates/scenes/t.png", "src/lib.rs", "p/camera.dcp", "assets/fonts/Adobe-Foo.otf"]
.map(String::from)
.into();
let p = check(&files, MD, |_| false);
assert!(p.iter().any(|m| m.starts_with("assets/icons/x.svg: no entry")));
assert!(p.iter().any(|m| m.starts_with("p/camera.dcp")));
assert!(p.iter().any(|m| m.contains("Adobe-Foo.otf: asset name")));
assert!(p.iter().any(|m| m.contains("OFL-Foo.txt")));
assert!(!p.iter().any(|m| m.starts_with("assets/fonts/Foo-Bold.ttf") || m.starts_with("crates/scenes") || m.starts_with("src/")));
}
}