orber 0.3.0

Turn photos and videos into abstract orb mood images and short-form vertical videos
Documentation
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
412
413
414
415
416
417
418
//! SVG / CSS の静的書き出しモジュール。
//!
//! ラスタを焼かず、ベクター(SVG)または CSS 背景グラデーションとして
//! orb 配置を出力する。動的化(@keyframes・SMIL アニメ等)は将来 Issue。
//!
//! # 設計メモ
//!
//! - viewBox は PNG/動画と揃えて 1080x1920。CSS は % 指定なので解像度を
//!   持たない(`StyleOptions` も解像度フィールドを持たない)
//! - 色は [`crate::orb::adjust_saturation`] を共有して使う。HSL 経路で
//!   彩度を変えるルールも PNG と一致
//! - SVG は `<radialGradient>` の 3 stop(0% / mid% / 100%)で減衰を表現。
//!   CSS も 3 stop の `radial-gradient(...)` を `--orber-bg` 変数に合成
//! - 文字列組み立てのみ。crate 依存は追加しない

use crate::cluster::Cluster;
use crate::orb::adjust_saturation;

/// SVG viewBox 幅。PNG / 動画と揃える。
pub(crate) const STYLE_WIDTH: u32 = 1080;
/// SVG viewBox 高さ。PNG / 動画と揃える。
pub(crate) const STYLE_HEIGHT: u32 = 1920;

/// SVG / CSS 描画オプション。
///
/// 解像度は SVG では viewBox 固定、CSS では % 指定なのでフィールドを持たない。
#[derive(Debug, Clone)]
pub struct StyleOptions {
    /// orb サイズ倍率(1.0 = デフォルト)
    pub orb_size: f32,
    /// ぼかし強度 0.0..=1.0
    pub blur: f32,
    /// 彩度倍率(1.0 = unchanged)
    pub saturation: f32,
    /// 背景 RGBA。alpha=0 で透過 SVG / `background-color: transparent`。
    pub background: [u8; 4],
}

impl Default for StyleOptions {
    fn default() -> Self {
        Self {
            orb_size: 1.0,
            blur: 0.5,
            saturation: 1.0,
            background: [0, 0, 0, 255],
        }
    }
}

/// クラスタ列を SVG 文字列として描画する。
///
/// 出力は viewBox `0 0 1080 1920` の自己完結 SVG。背景は黒い `<rect>`、
/// 各 cluster は `<radialGradient>` と `<circle>` のペアになる。
pub fn render_svg(clusters: &[Cluster], opts: &StyleOptions) -> String {
    let blur = opts.blur.clamp(0.0, 1.0);
    let saturation = opts.saturation.max(0.0);
    let orb_size = opts.orb_size.max(0.0);

    let width = STYLE_WIDTH as f32;
    let height = STYLE_HEIGHT as f32;
    let base_radius_unit = width.min(height) * 0.25 * orb_size;

    // mid_offset: blur=0 で外寄り(中心の不透明領域が広い)、blur=1 で中心寄り。
    // PNG 側 (1.0 - blur*0.8) と意味的に整合させ、% 表記の中間 stop を作る。
    let mid_pct = ((1.0 - blur * 0.8).clamp(0.05, 0.95) * 100.0).round() as i32;

    let mut s = String::new();
    s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
    s.push_str(&format!(
        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {STYLE_WIDTH} {STYLE_HEIGHT}\" width=\"{STYLE_WIDTH}\" height=\"{STYLE_HEIGHT}\">\n"
    ));
    let [bg_r, bg_g, bg_b, bg_a] = opts.background;
    if bg_a > 0 {
        if bg_a == 255 {
            s.push_str(&format!(
                "  <rect width=\"100%\" height=\"100%\" fill=\"rgb({bg_r},{bg_g},{bg_b})\"/>\n"
            ));
        } else {
            let opacity = bg_a as f32 / 255.0;
            s.push_str(&format!(
                "  <rect width=\"100%\" height=\"100%\" fill=\"rgb({bg_r},{bg_g},{bg_b})\" fill-opacity=\"{opacity:.3}\"/>\n"
            ));
        }
    }

    // 描画対象 cluster だけ事前に絞り込んでから defs と circle の両方に使う。
    // weight=0 の cluster で空の gradient ID が defs に残らないようにする。
    let visible: Vec<(usize, &Cluster, f32)> = clusters
        .iter()
        .enumerate()
        .filter_map(|(i, c)| {
            let w = c.weight.max(0.0);
            let r = base_radius_unit * w.sqrt();
            if r > 0.0 {
                Some((i, c, r))
            } else {
                None
            }
        })
        .collect();

    s.push_str("  <defs>\n");
    for (i, cluster, _) in &visible {
        let [r, g, b] = adjust_saturation(cluster.color, saturation);
        s.push_str(&format!(
            "    <radialGradient id=\"orb-{i}\" cx=\"50%\" cy=\"50%\" r=\"50%\">\n"
        ));
        s.push_str(&format!(
            "      <stop offset=\"0%\" stop-color=\"rgb({r},{g},{b})\" stop-opacity=\"1\"/>\n"
        ));
        s.push_str(&format!(
            "      <stop offset=\"{mid_pct}%\" stop-color=\"rgb({r},{g},{b})\" stop-opacity=\"0.5\"/>\n"
        ));
        s.push_str(&format!(
            "      <stop offset=\"100%\" stop-color=\"rgb({r},{g},{b})\" stop-opacity=\"0\"/>\n"
        ));
        s.push_str("    </radialGradient>\n");
    }
    s.push_str("  </defs>\n");

    for (i, cluster, radius) in &visible {
        let cx = (cluster.centroid.x.clamp(0.0, 1.0) * width).round() as i32;
        let cy = (cluster.centroid.y.clamp(0.0, 1.0) * height).round() as i32;
        let r_px = radius.round() as i32;
        s.push_str(&format!(
            "  <circle cx=\"{cx}\" cy=\"{cy}\" r=\"{r_px}\" fill=\"url(#orb-{i})\"/>\n"
        ));
    }

    s.push_str("</svg>\n");
    s
}

/// クラスタ列を CSS スニペットとして書き出す。
///
/// `--orber-bg` カスタムプロパティに、各 cluster を 1 つの
/// `radial-gradient(...)` として合成した値を入れる。利用側は
/// `background-image: var(--orber-bg);` で参照する。
pub fn render_css(clusters: &[Cluster], opts: &StyleOptions) -> String {
    let blur = opts.blur.clamp(0.0, 1.0);
    let saturation = opts.saturation.max(0.0);
    let orb_size = opts.orb_size.max(0.0);

    // mid_factor: PNG/SVG と同じ意味で「中間 stop が gradient 終端からどの程度内側か」。
    // blur=0 → mid=end の 95% (中心の不透明領域が広い、急峻に縁が落ちる)
    // blur=1 → mid=end の 20% (中心が点に近く、緩やかに減衰)
    let mid_factor = (1.0 - blur * 0.8).clamp(0.05, 0.95);

    let [bg_r, bg_g, bg_b, bg_a] = opts.background;
    let bg_css = if bg_a == 0 {
        "transparent".to_string()
    } else if bg_a == 255 {
        format!("rgb({bg_r}, {bg_g}, {bg_b})")
    } else {
        let opacity = bg_a as f32 / 255.0;
        format!("rgba({bg_r}, {bg_g}, {bg_b}, {opacity:.3})")
    };

    let mut s = String::new();
    s.push_str("/* orber-generated background.\n");
    s.push_str("   Apply to <body> or any block element:\n");
    s.push_str("       body {\n");
    s.push_str("           margin: 0;\n");
    s.push_str("           min-height: 100vh;\n");
    s.push_str("           background-color: var(--orber-bg-color);\n");
    s.push_str("           background-image: var(--orber-bg);\n");
    s.push_str("       }\n");
    s.push_str("   Generated as CSS variables; reference with var(--orber-bg) and var(--orber-bg-color). */\n");
    s.push_str(":root {\n");
    s.push_str(&format!("    --orber-bg-color: {bg_css};\n"));

    // 有効な gradient だけ集めてから書き出す(最後のカンマを抑制するため)。
    let mut gradients: Vec<String> = Vec::new();
    for cluster in clusters {
        let weight = cluster.weight.max(0.0);
        if weight <= 0.0 {
            continue;
        }
        let [r, g, b] = adjust_saturation(cluster.color, saturation);
        let x = (cluster.centroid.x.clamp(0.0, 1.0) * 100.0).round() as i32;
        let y = (cluster.centroid.y.clamp(0.0, 1.0) * 100.0).round() as i32;
        // PNG 側 radius = min(W,H) * 0.25 * orb_size * sqrt(weight)。
        // CSS は背景全体に対する % 指定なので、視覚的に近づくよう
        // sqrt(weight) * 30% * orb_size を採用。orb_size を大きくして 100% を超えると
        // PNG ならキャンバス外まではみ出すが、CSS は背景比なので 100% で頭打ちになる。
        let end_f = (weight.sqrt() * 30.0 * orb_size).clamp(2.0, 100.0);
        let end_pct = end_f.round() as i32;
        // mid_pct は end_pct の mid_factor 倍。round 後の衝突を最終ガードで防ぐ
        // (mid_pct < end_pct を構造的に担保する)。
        let mid_pct = (end_f * mid_factor).round() as i32;
        let mid_pct = mid_pct.clamp(0, end_pct - 1);
        gradients.push(format!(
            "radial-gradient(circle at {x}% {y}%, rgba({r},{g},{b},1) 0%, rgba({r},{g},{b},0.5) {mid_pct}%, rgba({r},{g},{b},0) {end_pct}%)"
        ));
    }

    if gradients.is_empty() {
        // 描画対象が無いときは `none` を明示する。`var(--orber-bg)` 参照側でも
        // background-image: none と同義になり、空値プロパティ ": ;" 経由の
        // IACVT フォールバックより意図が明確。
        s.push_str("    --orber-bg: none;\n");
    } else {
        s.push_str("    --orber-bg:\n");
        for (i, g) in gradients.iter().enumerate() {
            s.push_str("        ");
            s.push_str(g);
            if i + 1 < gradients.len() {
                s.push_str(",\n");
            } else {
                s.push_str(";\n");
            }
        }
    }
    s.push_str("}\n");
    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cluster::{Centroid, Cluster};

    fn cluster(color: [u8; 3], cx: f32, cy: f32, weight: f32) -> Cluster {
        Cluster {
            color,
            centroid: Centroid { x: cx, y: cy },
            weight,
        }
    }

    fn six_clusters() -> Vec<Cluster> {
        vec![
            cluster([200, 100, 50], 0.1, 0.1, 0.3),
            cluster([50, 200, 100], 0.5, 0.2, 0.2),
            cluster([100, 50, 200], 0.9, 0.3, 0.15),
            cluster([255, 255, 0], 0.2, 0.7, 0.15),
            cluster([0, 255, 255], 0.5, 0.8, 0.1),
            cluster([255, 0, 255], 0.8, 0.9, 0.1),
        ]
    }

    #[test]
    fn svg_contains_expected_elements() {
        let svg = render_svg(&six_clusters(), &StyleOptions::default());
        assert_eq!(svg.matches("<radialGradient").count(), 6);
        assert_eq!(svg.matches("<circle ").count(), 6);
        assert!(svg.contains("viewBox=\"0 0 1080 1920\""));
        assert!(svg.contains("<rect width=\"100%\" height=\"100%\" fill=\"rgb(0,0,0)\""));
        assert!(svg.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
        assert!(svg.ends_with("</svg>\n"));
    }

    #[test]
    fn css_contains_expected_gradients() {
        let css = render_css(&six_clusters(), &StyleOptions::default());
        assert_eq!(css.matches("radial-gradient(").count(), 6);
        assert!(css.contains("--orber-bg:"));
        // 最後の gradient の後にカンマが無い(valid CSS)。
        // ";" の直前は ")" のはず。
        let semi = css.find(";\n}").expect("CSS must end with ;\\n}");
        let before_semi: char = css[..semi].chars().next_back().unwrap();
        assert_eq!(
            before_semi, ')',
            "last char before ';' must be ')', got {before_semi:?}"
        );
    }

    #[test]
    fn deterministic_svg() {
        let clusters = six_clusters();
        let opts = StyleOptions::default();
        let a = render_svg(&clusters, &opts);
        let b = render_svg(&clusters, &opts);
        assert_eq!(a, b);
    }

    #[test]
    fn deterministic_css() {
        let clusters = six_clusters();
        let opts = StyleOptions::default();
        let a = render_css(&clusters, &opts);
        let b = render_css(&clusters, &opts);
        assert_eq!(a, b);
    }

    #[test]
    fn saturation_zero_grays_colors_svg() {
        // saturation=0 のとき、stop-color="rgb(R,G,B)" の R==G==B な色が少なくとも 1 つ含まれる。
        let opts = StyleOptions {
            saturation: 0.0,
            ..StyleOptions::default()
        };
        let svg = render_svg(&six_clusters(), &opts);
        // rgb(R,G,B) を抽出して R==G==B が少なくとも 1 件あること。
        let mut found_gray = false;
        for line in svg.lines() {
            if let Some(start) = line.find("rgb(") {
                let rest = &line[start + 4..];
                if let Some(end) = rest.find(')') {
                    let nums = &rest[..end];
                    let parts: Vec<&str> = nums.split(',').collect();
                    if parts.len() == 3 {
                        let r: i32 = parts[0].trim().parse().unwrap();
                        let g: i32 = parts[1].trim().parse().unwrap();
                        let b: i32 = parts[2].trim().parse().unwrap();
                        if (r - g).abs() <= 1 && (g - b).abs() <= 1 && (r - b).abs() <= 1 {
                            found_gray = true;
                            break;
                        }
                    }
                }
            }
        }
        assert!(found_gray, "saturation=0 should produce a grayscale stop");
    }

    /// CSS の各 gradient から `0% / mid% / end%` の 3 stop を抽出する。
    /// 各 stop は `rgba(...,A) N%` 形式で書かれているので、`A` の値ごとに
    /// 後続の数値を拾う簡易パーサ。
    fn extract_css_stops(css: &str) -> Vec<(i32, i32, i32)> {
        fn read_pct_after(hay: &str, marker: &str, from: usize) -> Option<(i32, usize)> {
            let pos = hay[from..].find(marker)? + from;
            let after = pos + marker.len();
            // skip spaces, read digits until '%'
            let bytes = hay.as_bytes();
            let mut i = after;
            while i < bytes.len() && bytes[i] == b' ' {
                i += 1;
            }
            let start = i;
            while i < bytes.len() && bytes[i].is_ascii_digit() {
                i += 1;
            }
            if i == start || i >= bytes.len() || bytes[i] != b'%' {
                return None;
            }
            let n = hay[start..i].parse::<i32>().ok()?;
            Some((n, i + 1))
        }

        let mut out = Vec::new();
        let mut cursor = 0usize;
        while let Some(rel) = css[cursor..].find("radial-gradient(") {
            let base = cursor + rel + "radial-gradient(".len();
            let (s0, p1) = match read_pct_after(css, ",1) ", base) {
                Some(v) => v,
                None => break,
            };
            let (mid, p2) = match read_pct_after(css, ",0.5) ", p1) {
                Some(v) => v,
                None => break,
            };
            let (end, p3) = match read_pct_after(css, ",0) ", p2) {
                Some(v) => v,
                None => break,
            };
            out.push((s0, mid, end));
            cursor = p3;
        }
        out
    }

    #[test]
    fn css_stops_strictly_monotonic_default() {
        let css = render_css(&six_clusters(), &StyleOptions::default());
        let stops = extract_css_stops(&css);
        assert_eq!(stops.len(), 6);
        for (s0, m, e) in stops {
            assert_eq!(s0, 0, "first stop must be 0%");
            assert!(m < e, "mid ({m}) < end ({e}) must hold");
            assert!(m >= 0, "mid ({m}) must be >= 0");
        }
    }

    #[test]
    fn css_stops_strictly_monotonic_boundary_values() {
        // weight=1.0 / blur=0.0 / orb_size=2.0 の極端なケースで mid/end の
        // 衝突が起きないことを保証する。
        let extreme_clusters = vec![
            cluster([200, 100, 50], 0.5, 0.5, 1.0),   // 最大 weight
            cluster([50, 200, 100], 0.5, 0.5, 0.001), // 微小 weight
        ];
        for blur in [0.0_f32, 0.5, 1.0] {
            for orb_size in [0.1_f32, 1.0, 2.0, 4.0] {
                let opts = StyleOptions {
                    orb_size,
                    blur,
                    saturation: 1.0,
                    ..Default::default()
                };
                let css = render_css(&extreme_clusters, &opts);
                let stops = extract_css_stops(&css);
                assert!(!stops.is_empty(), "blur={blur} orb_size={orb_size}");
                for (s0, m, e) in &stops {
                    assert_eq!(*s0, 0);
                    assert!(
                        m < e,
                        "monotonic stops violated at blur={blur} orb_size={orb_size}: 0/{m}/{e}"
                    );
                }
            }
        }
    }

    #[test]
    fn empty_clusters_produces_valid_svg() {
        let svg = render_svg(&[], &StyleOptions::default());
        assert!(svg.contains("<svg"));
        assert!(svg.contains("</svg>"));
        assert!(svg.contains("<rect")); // 背景は残る
        assert_eq!(svg.matches("<radialGradient").count(), 0);
        assert_eq!(svg.matches("<circle ").count(), 0);

        let css = render_css(&[], &StyleOptions::default());
        assert!(css.contains("--orber-bg: none"));
        assert_eq!(css.matches("radial-gradient(").count(), 0);
    }
}