rustmotion-core 0.8.0

Core types, traits, and rendering utilities for rustmotion
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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use std::collections::HashMap;

use super::animation::EasingType;
use super::background::{
    deserialize_animated_backgrounds, deserialize_background_value, AnimatedBackground,
    BackgroundValue, ResolvedBackground,
};
use super::shake::SceneShake;
use super::style::{CardAlign, CardDirection, CardJustify};
use super::time::TimePoint;

/// Definition of a variable in a structural component.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VariableDefinition {
    #[serde(rename = "type")]
    pub var_type: VariableType,
    pub default: serde_json::Value,
    /// Optional description for documentation/schema.
    #[serde(default)]
    pub description: Option<String>,
}

/// Supported variable types.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum VariableType {
    String,
    Number,
    Boolean,
    Object,
    Array,
}

/// One entry of [`Scenario::components`] — a named, reusable template.
///
/// Schema-only twin of `rustmotion_core::expand::ComponentDefinition`
/// (private to that module — this type exists so `components` has a real,
/// documented shape in the exported schema; see [`Scenario::components`]'s
/// doc for why it can't just reuse that private struct, and why this one is
/// never populated in practice).
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ComponentTemplateDef {
    /// Parameters this template accepts. Same shape as
    /// [`ComponentTemplateParam`] — *not* [`VariableDefinition`] (the
    /// scenario-level `config` entry shape): a template parameter's
    /// `default` is itself optional, and omitting it is what makes the
    /// parameter required at every `use` site. `config`'s `default` has no
    /// such omission story — it is always required there.
    #[serde(default)]
    pub params: HashMap<String, ComponentTemplateParam>,
    /// The subtree to instantiate: a single component object, or an array
    /// of sibling component objects (a fragment spliced in place). May
    /// itself contain nested `for-each`/`use` directives.
    pub template: serde_json::Value,
}

/// One parameter declared by a [`ComponentTemplateDef`]. See
/// [`ComponentTemplateDef::params`] for how this differs from
/// [`VariableDefinition`].
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ComponentTemplateParam {
    #[serde(rename = "type")]
    pub param_type: VariableType,
    /// Omitting this makes the parameter required at every `use` site.
    #[serde(default)]
    pub default: Option<serde_json::Value>,
    #[serde(default)]
    pub description: Option<String>,
}

/// Deliberately **not** `#[derive(Deserialize)]` — see
/// [`ScenarioDe`]/`impl From<ScenarioDe> for Scenario` just below for why:
/// this type needs a post-deserialize pass (propagating `bpm`/`beat_offset`/
/// `timing`/`snap` down onto every reachable [`Scene`]) that a plain derive
/// can't express, and every call site that builds a `Scenario` from JSON
/// (`loader.rs`, `include.rs`'s own included-file loading) is outside this
/// workstream's owned files this wave — the propagation has to happen
/// *inside* `Scenario::deserialize` itself so those callers need no changes.
#[derive(Debug, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Scenario {
    #[serde(default = "default_version")]
    pub version: String,
    pub video: VideoConfig,
    /// File-based tracks (`"audio": [...]`, unchanged since before issue
    /// #331) or, for a scenario that synthesises its own soundtrack, a
    /// single object carrying both (`"audio": {"tracks": [...], "voices":
    /// {...}, "score": [...], "master": {...}}`) — see [`AudioValue`].
    #[serde(default)]
    pub audio: AudioValue,
    #[serde(default)]
    pub fonts: Vec<FontEntry>,
    #[serde(default, deserialize_with = "deserialize_scene_entries")]
    pub scenes: Vec<SceneEntry>,
    /// Composition: a sequence of views (slide or world). Mutually exclusive with top-level `scenes`.
    #[serde(default)]
    pub composition: Option<Vec<View>>,
    /// Config definitions for structural components. Each config entry has a type and default value.
    #[serde(default)]
    pub config: Option<HashMap<String, VariableDefinition>>,
    /// Named background templates that scenes can reference via `$ref`.
    #[serde(default)]
    pub backgrounds: HashMap<String, serde_json::Value>,
    /// Reusable component template definitions — `"components": { "name": {
    /// "params": {...}, "template": {...} } }`, instantiated from a
    /// `children` entry via `{"use": "name", "props": {...}}` (see
    /// [`ComponentTemplateDef`]). Documented in `CLAUDE.md`'s
    /// "Factorisation" section.
    ///
    /// This field exists so the exported JSON Schema (`rustmotion schema`)
    /// declares the shape the engine actually accepts — it is **not** how
    /// `components` is consumed at runtime. `rustmotion_core::expand::
    /// expand_directives` (a sibling workstream's file, outside this one's
    /// scope) resolves every `for-each`/`use` against this block and then
    /// *removes the key entirely*, before this struct is ever deserialized
    /// — the same way `variables::apply_variables` consumes `config`'s
    /// `$name` placeholders without `config` disappearing from the struct.
    /// The practical difference: `config` stays meaningful after expansion
    /// (`Scenario::config` is read elsewhere), while `components` is spent
    /// in full during expansion, so this field is always empty by the time
    /// any code outside `schema/` could read it.
    #[serde(default)]
    pub components: HashMap<String, ComponentTemplateDef>,
    /// Studio feedback annotations. Persisted in the scenario but never read by
    /// the renderer or the geometry validator. Skipped on serialization when empty.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub annotations: Vec<Annotation>,
    /// Beats per minute for this scenario's beat grid (issue #336). `None`
    /// (default) means no grid exists: any [`TimePoint`] beat (`b`) unit
    /// then fails to resolve with `unresolved_beat_unit` (schema:
    /// [`crate::schema::TimeError::NoBpm`]).
    #[serde(default)]
    pub bpm: Option<f64>,
    /// Where beat 0 sits on the scenario's absolute timeline, in seconds.
    /// The grid is `beat_offset + n * 60 / bpm` — not decoration: a reel
    /// whose first beat lands at 2.2s anchors its grid there, not at 0.
    #[serde(default)]
    pub beat_offset: f64,
    /// Optional override of the scenario's total rendered duration, in
    /// seconds. Reserved for downstream consumers (audio/export tooling);
    /// the frame-task scheduler in `rustmotion`'s `encode` module does not
    /// read it — under `timing: "v2"` the total is always `at_last +
    /// duration_last`, computed from the scenes themselves.
    #[serde(default)]
    pub duration: Option<f64>,
    /// Scene-placement semantics. See [`TimingMode`].
    #[serde(default)]
    pub timing: TimingMode,
    /// Rounds resolved times onto a grid before scheduling. See [`SnapMode`].
    #[serde(default)]
    pub snap: Option<SnapMode>,
    /// Scenario-level variables (issue #329): a scalar declared once,
    /// animated on the scenario's absolute timeline, and readable from any
    /// expression in any scene as `$name`. Visible everywhere; shadowed by
    /// a scene's own [`Scene::vars`] of the same name. See
    /// `rustmotion_core::vars` for the resolver and the `Scope`
    /// implementation that reads this.
    #[serde(default)]
    pub vars: crate::vars::VarSet,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ScenarioDe {
    #[serde(default = "default_version")]
    version: String,
    video: VideoConfig,
    #[serde(default)]
    audio: AudioValue,
    #[serde(default)]
    fonts: Vec<FontEntry>,
    #[serde(default, deserialize_with = "deserialize_scene_entries")]
    scenes: Vec<SceneEntry>,
    #[serde(default)]
    composition: Option<Vec<View>>,
    #[serde(default)]
    config: Option<HashMap<String, VariableDefinition>>,
    #[serde(default)]
    backgrounds: HashMap<String, serde_json::Value>,
    #[serde(default)]
    components: HashMap<String, ComponentTemplateDef>,
    #[serde(default)]
    annotations: Vec<Annotation>,
    #[serde(default)]
    bpm: Option<f64>,
    #[serde(default)]
    beat_offset: f64,
    #[serde(default)]
    duration: Option<f64>,
    #[serde(default)]
    timing: TimingMode,
    #[serde(default)]
    snap: Option<SnapMode>,
    #[serde(default)]
    vars: crate::vars::VarSet,
}

impl<'de> Deserialize<'de> for Scenario {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let raw = ScenarioDe::deserialize(deserializer)?;
        let mut scenario = Scenario {
            version: raw.version,
            video: raw.video,
            audio: raw.audio,
            fonts: raw.fonts,
            scenes: raw.scenes,
            composition: raw.composition,
            config: raw.config,
            backgrounds: raw.backgrounds,
            components: raw.components,
            annotations: raw.annotations,
            bpm: raw.bpm,
            beat_offset: raw.beat_offset,
            duration: raw.duration,
            timing: raw.timing,
            snap: raw.snap,
            vars: raw.vars,
        };
        scenario.propagate_time_ctx();
        Ok(scenario)
    }
}

impl Scenario {
    fn propagate_time_ctx(&mut self) {
        let ctx = super::time::TimeCtx {
            bpm: self.bpm,
            beat_offset: self.beat_offset,
            scene_start: 0.0,
        };
        let timing = self.timing;
        let snap = self.snap;
        let scenario_vars = self.vars.clone();
        stamp_entries(&mut self.scenes, ctx, timing, snap, &scenario_vars);
        if let Some(views) = &mut self.composition {
            for view in views {
                stamp_entries(&mut view.scenes, ctx, timing, snap, &scenario_vars);
            }
        }
    }
}

fn stamp_entries(
    entries: &mut [SceneEntry],
    ctx: super::time::TimeCtx,
    timing: TimingMode,
    snap: Option<SnapMode>,
    scenario_vars: &crate::vars::VarSet,
) {
    for entry in entries {
        if let SceneEntry::Scene(scene) = entry {
            scene.resolved_time_ctx = ctx;
            scene.resolved_timing = timing;
            scene.resolved_snap = snap;
            scene.resolved_scenario_vars = scenario_vars.clone();
        }
    }
}

/// Scene-placement semantics for a scenario (issue #336). See
/// [`Scene::at`] and [`Scene::tail`] for what `"v2"` unlocks.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TimingMode {
    /// Today's behaviour, byte-identical for every scenario that predates
    /// issue #336: a transition's frames *replace* frames at the tail of
    /// the outgoing scene and the head of the incoming one, so the
    /// scenario's rendered length is `sum(scene durations) - sum(transition
    /// durations)`.
    #[default]
    V1,
    /// Absolute scene placement with a declared overlap: scene *i* occupies
    /// `[at_i, at_i + duration_i)`, and a transition entering it
    /// additionally renders the *previous* scene during `[at_i, at_i +
    /// transition duration)` — past its own end, per that scene's
    /// [`SceneTail`]. Total duration is `at_last + duration_last`: no
    /// subtraction anywhere.
    V2,
}

/// What `Scenario.snap` rounds resolved times onto. Currently only the beat
/// grid; the variant exists so the field reads as intent (`"beat"`) rather
/// than a bare boolean.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SnapMode {
    /// Round to the nearest point on `beat_offset + n * 60 / bpm`.
    Beat,
}

/// Lifecycle of a studio annotation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum AnnotationStatus {
    #[default]
    Open,
    Resolved,
}

/// What an annotation points at: a JSON Pointer into the source scenario,
/// plus optional context captured at click time.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AnnotationTarget {
    /// RFC 6901 JSON Pointer into the source scenario (e.g. "/scenes/2/children/5").
    pub pointer: String,
    /// Component kind label captured at click time (e.g. "text", "card").
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
    /// Bounding box [x, y, w, h] in video coords at the capture frame.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rect: Option<[f32; 4]>,
}

/// A single studio feedback note attached to an element at a moment in time.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Annotation {
    /// Stable id (generated by the studio).
    pub id: String,
    /// Free-text change request for the agent/skill.
    pub note: String,
    #[serde(default)]
    pub status: AnnotationStatus,
    /// Frame index at capture time (global playhead).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub frame: Option<u32>,
    /// Resolved view index (convenience for the skill).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub view: Option<usize>,
    /// Resolved scene index (convenience for the skill).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scene: Option<usize>,
    pub target: AnnotationTarget,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ViewType {
    Slide,
    World,
}

fn default_view_type() -> ViewType {
    ViewType::Slide
}

fn default_camera_pan_duration() -> f64 {
    0.8
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct View {
    #[serde(rename = "type", default = "default_view_type")]
    pub view_type: ViewType,
    #[serde(default, deserialize_with = "deserialize_scene_entries")]
    pub scenes: Vec<SceneEntry>,
    /// Transition entering this view (between views).
    #[serde(default)]
    pub transition: Option<Transition>,
    /// (world) Shared background: color string, animated entry, or array.
    #[serde(default, deserialize_with = "deserialize_background_value")]
    pub background: Option<BackgroundValue>,
    /// (world) Legacy shared animated backgrounds.
    #[serde(
        default,
        rename = "animated-background",
        deserialize_with = "deserialize_animated_backgrounds"
    )]
    pub animated_background: Vec<AnimatedBackground>,
    /// (world) Easing for camera pan between scenes.
    #[serde(default = "default_transition_easing")]
    pub camera_easing: EasingType,
    /// (world) Duration of camera pan between scenes (default 0.8s).
    #[serde(default = "default_camera_pan_duration")]
    pub camera_pan_duration: f64,
}

#[derive(Debug)]
pub struct ResolvedScenario {
    pub video: VideoConfig,
    pub audio: Vec<AudioTrack>,
    pub fonts: Vec<FontEntry>,
    pub views: Vec<ResolvedView>,
    pub included_paths: Vec<std::path::PathBuf>,
}

impl ResolvedScenario {
    pub fn all_scenes(&self) -> impl Iterator<Item = &Scene> {
        self.views.iter().flat_map(|v| v.scenes.iter())
    }

    #[allow(dead_code)]
    pub fn all_scenes_vec(&self) -> Vec<&Scene> {
        self.all_scenes().collect()
    }
}

#[derive(Debug)]
pub struct ResolvedView {
    pub view_type: ViewType,
    pub scenes: Vec<Scene>,
    pub transition: Option<Transition>,
    pub background: ResolvedBackground,
    pub camera_easing: EasingType,
    pub camera_pan_duration: f64,
}

/// An entry in the `scenes` array: either a concrete scene or an include directive.
///
/// `#[serde(untagged)]` is kept so [`schemars`] still emits the correct
/// (flat, non-wrapped) JSON Schema for this enum, and so a direct
/// `SceneEntry::deserialize` call elsewhere keeps working. It is **not**
/// how `Scenario.scenes` / `View.scenes` actually deserialize an entry from
/// JSON, though: those two fields use [`deserialize_scene_entries`] instead
/// (see its doc comment for why — M6, issue #110).
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
#[allow(clippy::large_enum_variant)]
pub enum SceneEntry {
    /// A regular scene defined inline.
    Scene(Scene),
    /// A reference to an external scenario file whose scenes will be injected here.
    Include(IncludeDirective),
}

fn deserialize_scene_entries<'de, D>(deserializer: D) -> Result<Vec<SceneEntry>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::de::Error as _;

    let raw: Vec<serde_json::Value> = Vec::deserialize(deserializer)?;
    let mut out = Vec::with_capacity(raw.len());
    for (i, entry) in raw.into_iter().enumerate() {
        let is_include = entry.get("include").is_some();
        if is_include {
            let directive: IncludeDirective = serde_json::from_value(entry)
                .map_err(|e| D::Error::custom(format!("scenes[{i}] (include directive): {e}")))?;
            out.push(SceneEntry::Include(directive));
        } else {
            let scene: Scene = serde_json::from_value(entry)
                .map_err(|e| D::Error::custom(format!("scenes[{i}]: {e}")))?;
            out.push(SceneEntry::Scene(scene));
        }
    }
    Ok(out)
}

/// Directive to inject scenes from an external scenario file.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IncludeDirective {
    /// Path (relative to parent file) or URL (http/https) to a scenario JSON file.
    pub include: String,
    /// Only include scenes at these 0-based indices. When absent, all scenes are included.
    #[serde(default)]
    pub scenes: Option<Vec<usize>>,
    /// Config overrides to pass to the included structural component.
    #[serde(default)]
    pub config: Option<HashMap<String, serde_json::Value>>,
}

/// Font file to load at startup.
///
/// Two mutually exclusive modes:
/// - **Local**: `path` (required) + `family`. Loads a `.ttf`/`.otf` file directly.
/// - **Google Fonts**: `source = "google"` + `family` + optional `weights` (default [400]).
///   The font is downloaded from the Google Fonts CSS2 API and cached in
///   `~/.cache/rustmotion/fonts` (or `%LOCALAPPDATA%\rustmotion\fonts` on Windows).
///   Subsequent renders with a warm cache make zero network calls.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct FontEntry {
    /// Local file path (.ttf/.otf). Required when `source` is absent.
    #[serde(default)]
    pub path: Option<String>,
    /// Font family name (e.g. "Inter", "JetBrains Mono").
    pub family: String,
    /// Font source. Currently the only recognised value is `"google"`.
    /// When set, `path` must be absent.
    #[serde(default)]
    pub source: Option<String>,
    /// Font weights to download (Google Fonts only). Defaults to `[400]`.
    #[serde(default)]
    pub weights: Option<Vec<u16>>,
}

/// `Scenario::audio`'s wire shape (issue #331): either the legacy bare
/// array of file-based [`AudioTrack`]s, or a single object that can carry
/// both file tracks *and* a synthesised score, mixed into one bus. Which
/// shape a given `"audio"` value is is unambiguous from its JSON kind
/// (array vs. object), so `#[serde(untagged)]` needs no help distinguishing
/// them.
///
/// An old scenario using the bare-array form is unaffected byte-for-byte —
/// [`AudioValue::Tracks`] round-trips through exactly the type `audio` used
/// to be, and the `rustmotion` crate's include-resolution pass (outside
/// this crate) never has to look past [`AudioValue::into_tracks`].
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum AudioValue {
    Tracks(Vec<AudioTrack>),
    Config(Box<AudioConfig>),
}

impl Default for AudioValue {
    fn default() -> Self {
        AudioValue::Tracks(Vec::new())
    }
}

impl AudioValue {
    pub fn tracks(&self) -> &[AudioTrack] {
        match self {
            AudioValue::Tracks(tracks) => tracks,
            AudioValue::Config(config) => &config.tracks,
        }
    }

    pub fn into_tracks(self) -> Vec<AudioTrack> {
        match self {
            AudioValue::Tracks(tracks) => tracks,
            AudioValue::Config(config) => config.tracks,
        }
    }

    pub fn config(&self) -> Option<&AudioConfig> {
        match self {
            AudioValue::Tracks(_) => None,
            AudioValue::Config(config) => Some(config),
        }
    }
}

/// The object form of [`AudioValue`] — file tracks plus, optionally, a
/// synthesised score (issue #331's `voices`/`score`/`master`, matching the
/// issue body's example verbatim). `bpm`/`beat_offset` default to the
/// *scenario's* own [`Scenario::bpm`]/[`Scenario::beat_offset`] when
/// absent here — see `AudioConfig::as_score`'s caller in `rustmotion`'s
/// `encode` crate, which is where that fallback is actually applied (this
/// crate only carries the override, it does not resolve it: `Scenario`
/// alone does not know its own `bpm` by the time `include::resolve_includes`
/// has consumed it — the caller captures both before that happens).
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AudioConfig {
    #[serde(default)]
    pub tracks: Vec<AudioTrack>,
    /// Overrides the scenario's own `bpm` for this score only. Normally
    /// left absent — sharing the scenario's real grid (deliverable #1's
    /// whole point) is the common case, not the exception.
    #[serde(default)]
    pub bpm: Option<f64>,
    #[serde(default)]
    pub beat_offset: Option<f64>,
    #[serde(default)]
    pub voices: HashMap<String, crate::audio::Voice>,
    #[serde(default)]
    pub score: Vec<crate::audio::ScoreEvent>,
    #[serde(default)]
    pub master: crate::audio::MasterBus,
}

impl AudioConfig {
    pub fn has_synth(&self) -> bool {
        !self.voices.is_empty() || !self.score.is_empty()
    }

    pub fn as_score(&self) -> crate::audio::Score {
        crate::audio::Score {
            voices: self.voices.clone(),
            score: self.score.clone(),
            master: self.master.clone(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AudioTrack {
    pub src: String,
    #[serde(default)]
    pub start: f64,
    #[serde(default)]
    pub end: Option<f64>,
    #[serde(default = "default_volume")]
    pub volume: f32,
    #[serde(default)]
    pub fade_in: Option<f64>,
    #[serde(default)]
    pub fade_out: Option<f64>,
    #[serde(default)]
    pub volume_keyframes: Vec<VolumeKeyframe>,
}

/// Dynamic volume control point
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct VolumeKeyframe {
    pub time: f64,
    pub volume: f32,
    #[serde(default)]
    pub easing: EasingType,
}

fn default_volume() -> f32 {
    1.0
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VideoConfig {
    pub width: u32,
    pub height: u32,
    #[serde(default = "default_fps")]
    pub fps: u32,
    #[serde(default = "default_background")]
    pub background: String,
    #[serde(default)]
    pub codec: Option<VideoCodec>,
    #[serde(default)]
    pub crf: Option<u8>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct WorldPosition {
    #[serde(default)]
    pub x: f32,
    #[serde(default)]
    pub y: f32,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Scene {
    pub duration: f64,
    /// Unified background: color string, animated entry (with optional $ref), or array.
    #[serde(default, deserialize_with = "deserialize_background_value")]
    pub background: Option<BackgroundValue>,
    #[serde(default)]
    pub children: Vec<serde_json::Value>,
    /// Filled once, by whoever first deserializes `children` into the component
    /// type it owns. `rustmotion-core` cannot name that type, so it is held
    /// erased; `Scene::prepared_children` is the typed accessor.
    #[serde(skip)]
    #[schemars(skip)]
    pub prepared_children: std::sync::OnceLock<std::sync::Arc<dyn std::any::Any + Send + Sync>>,
    #[serde(default)]
    pub transition: Option<Transition>,
    #[serde(default)]
    pub freeze_at: Option<f64>,
    /// Flex layout for automatic layer positioning
    #[serde(default)]
    pub layout: Option<SceneLayout>,
    /// Legacy animated background (kept for backward compat)
    #[serde(
        default,
        rename = "animated-background",
        deserialize_with = "deserialize_animated_backgrounds"
    )]
    pub animated_background: Vec<AnimatedBackground>,
    /// Virtual camera with animatable x, y, zoom, rotation.
    #[serde(default)]
    pub camera: Option<Camera>,
    /// Declarative camera shake (issue #330): a list of beat-synced
    /// impacts, each a damped harmonic oscillator, summed and **additive**
    /// over `camera` above — a pan and a shake ride the same frame instead
    /// of fighting over one keyframe track. See [`SceneShake`] for the
    /// formula and `crate::engine::shake::shake_offset` for the function
    /// that evaluates it.
    #[serde(default)]
    pub shake: Option<SceneShake>,
    /// Position of this scene in the 2D world (used by world views).
    #[serde(default, rename = "world-position")]
    pub world_position: Option<WorldPosition>,
    /// (world) Keep this scene visible after its time window ends.
    #[serde(default)]
    pub persist: bool,
    /// Direction the light comes from, shared by every `style.material` in
    /// the scene. Absent means upper-left, the convention an eye reads as
    /// "lit from above" without being told.
    ///
    /// Declaring it once is what makes several materials look like they
    /// belong to the same frame: a per-node preset cannot agree with its
    /// neighbours about where the light is.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub light: Option<SceneLight>,
    /// Post-processing effects applied to the full frame buffer after Skia renders.
    /// Effects are additive and applied in declaration order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub effects: Vec<PostEffect>,
    /// Where this scene starts, on the scenario's absolute timeline (issue
    /// #336). `at` is absolute by definition — see the anchoring rule
    /// documented once on [`TimePoint`]. Defaults to [`SceneStart::Auto`]:
    /// immediately after the previous scene's own window ends. Only
    /// consulted by `rustmotion`'s frame-task scheduler under
    /// `Scenario.timing = "v2"`; a `"v1"` scenario places scenes
    /// back-to-back regardless of what this field says.
    #[serde(default, skip_serializing_if = "SceneStart::is_auto")]
    pub at: SceneStart,
    /// How this scene behaves when the *next* scene's transition overlaps
    /// into it, rendering it past its own `duration` (issue #336, `timing:
    /// "v2"` only). Defaults to [`SceneTail::Freeze`].
    #[serde(default, skip_serializing_if = "SceneTail::is_default_freeze")]
    pub tail: SceneTail,
    /// Post-resolution background (populated by include.rs, ignored by serde).
    #[serde(skip)]
    #[schemars(skip)]
    pub resolved_background: ResolvedBackground,
    /// The beat-grid context (`bpm`/`beat_offset`) of the `Scenario` this
    /// scene was declared in — populated by [`Scenario`]'s own
    /// `Deserialize` impl, *before* `include.rs` resolution ever runs, not
    /// by `include.rs` itself (issue #336; see the doc on
    /// [`ResolvedScenario`] for why). `scene_start` is always `0.0` here:
    /// [`Scene::at`] is resolved with
    /// [`TimePoint::resolve_absolute`](super::time::TimePoint::resolve_absolute),
    /// which ignores it.
    #[serde(skip)]
    #[schemars(skip)]
    pub resolved_time_ctx: super::time::TimeCtx,
    /// The `Scenario.timing` this scene was declared under. Same
    /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`].
    #[serde(skip)]
    #[schemars(skip)]
    pub resolved_timing: TimingMode,
    /// The `Scenario.snap` this scene was declared under. Same
    /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`].
    #[serde(skip)]
    #[schemars(skip)]
    pub resolved_snap: Option<SnapMode>,
    /// This scene's own declared variables (issue #329): shadow a
    /// same-named scenario-level variable ([`Scenario::vars`]) for
    /// expressions evaluated inside this scene, and are themselves
    /// invisible from any other scene — see `rustmotion_core::vars::VarScope`
    /// for the shadowing rule and why that isolation needs no special-case
    /// error handling. A variable with no `animation` here is a constant,
    /// same as at the scenario level.
    #[serde(default)]
    pub vars: crate::vars::VarSet,
    /// The enclosing [`Scenario::vars`] this scene was declared under —
    /// populated by `Scenario`'s own `Deserialize` impl, *before*
    /// `include.rs` resolution ever runs, not by `include.rs` itself. Same
    /// populate-at-deserialize-time note as [`Scene::resolved_time_ctx`]:
    /// [`ResolvedScenario`] carries no scenario-level fields of its own, so
    /// anything a scene needs to remember about its enclosing `Scenario`
    /// has to already be sitting on the `Scene` by the time `include.rs` —
    /// a file no single workstream owns — merges included scenes in.
    #[serde(skip)]
    #[schemars(skip)]
    pub resolved_scenario_vars: crate::vars::VarSet,
}

/// The literal string `"auto"` — the only value [`SceneStart::Auto`] can
/// hold. Its own tiny externally-tagged enum so it round-trips as the bare
/// string `"auto"` (serde's default representation for a fieldless
/// variant), matching every other `snake_case` string enum in this file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SceneStartAuto {
    Auto,
}

/// When a scene starts, on the scenario's absolute timeline. See
/// [`Scene::at`].
///
/// Not `#[derive(Deserialize)]` (see [`SceneStartDe`] / its manual
/// `Deserialize` impl just below): a syntactically malformed `at` — `"at":
/// "banana"` — must be rejected the moment the scenario is deserialized,
/// not discovered later as a silent fallback to automatic placement. A
/// derive can't express that extra grammar check, only the untagged
/// try-`"auto"`-then-`TimePoint` shape.
#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum SceneStart {
    /// Immediately after the previous scene's own window ends. Serializes
    /// and deserializes as the bare string `"auto"`.
    Auto(SceneStartAuto),
    /// An explicit point on the scenario's absolute timeline. Grammar is
    /// validated at deserialize time via
    /// [`TimePoint::validate_grammar`](super::time::TimePoint::validate_grammar)
    /// — resolving it (which additionally needs `bpm` for a beat unit) is a
    /// separate, later step: `rustmotion validate`'s schema pass, which can
    /// name the offending scene and report `unresolved_beat_unit` — see
    /// `crates/rustmotion/src/cli/commands/validate_schema.rs`.
    At(TimePoint),
}

#[derive(Deserialize)]
#[serde(untagged)]
enum SceneStartDe {
    Auto(SceneStartAuto),
    At(TimePoint),
}

impl<'de> Deserialize<'de> for SceneStart {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        use serde::de::Error as _;
        match SceneStartDe::deserialize(deserializer)? {
            SceneStartDe::Auto(a) => Ok(SceneStart::Auto(a)),
            SceneStartDe::At(tp) => {
                tp.validate_grammar().map_err(D::Error::custom)?;
                Ok(SceneStart::At(tp))
            }
        }
    }
}

impl Default for SceneStart {
    fn default() -> Self {
        SceneStart::Auto(SceneStartAuto::Auto)
    }
}

impl SceneStart {
    fn is_auto(&self) -> bool {
        matches!(self, SceneStart::Auto(_))
    }
}

/// How a scene behaves when it is rendered past its own `duration` because
/// the *next* scene's transition overlaps into it. See [`Scene::tail`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SceneTail {
    /// Hold the scene's last frame for the overlap — the same
    /// fixed-non-advancing-time effect `freeze_at` produces.
    #[default]
    Freeze,
    /// Let the scene's own animations keep running past its declared
    /// `duration` for the overlap.
    Continue,
}

impl SceneTail {
    fn is_default_freeze(&self) -> bool {
        matches!(self, SceneTail::Freeze)
    }
}

/// Virtual camera for pan/zoom/rotation effects at the scene level.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Camera {
    /// Camera center X offset from scene center (pixels). Default: 0.
    #[serde(default)]
    pub x: f32,
    /// Camera center Y offset from scene center (pixels). Default: 0.
    #[serde(default)]
    pub y: f32,
    /// Zoom factor. 1.0 = no zoom, 2.0 = 2x zoom in, 0.5 = zoom out.
    #[serde(default = "default_camera_zoom")]
    pub zoom: f32,
    /// Rotation in degrees around the camera origin. Default: 0.
    #[serde(default)]
    pub rotation: f32,
    /// Focal point for zoom/rotation, in frame pixels. Absent = frame centre
    /// (the historical behaviour). When the object is present, `x`/`y`
    /// default to 0 (top-left corner) — set both explicitly.
    #[serde(default)]
    pub origin: Option<CameraOrigin>,
    /// Depth the camera is focused on, on the same scale as `style.depth`.
    /// Default `1.0` — the plane an element sits on when it declares no
    /// depth, so a scenario that never mentions focus is focused on
    /// everything it has.
    #[serde(default = "default_camera_focus")]
    pub focus: f32,
    /// How fast sharpness falls off away from `focus`, in blur pixels per
    /// unit of depth difference. Default `0.0` — no depth of field at all,
    /// which is what every scenario written before this rendered. `4.0` is
    /// a visible but readable separation; past `20.0` an out-of-focus plane
    /// reads as a colour wash.
    ///
    /// Animate `focus` through `keyframes` for a rack focus; animating
    /// `aperture` instead opens and closes the effect without moving the
    /// focal plane.
    #[serde(default)]
    pub aperture: f32,
    /// Tilt of the whole shot about the horizontal axis, in degrees, applied
    /// to each direct child of the scene scaled by its `style.depth` — the
    /// same rule parallax and `focus` already follow. Default 0.
    #[serde(default)]
    pub rotate_x: f32,
    /// Tilt about the vertical axis, same convention. Default 0.
    #[serde(default)]
    pub rotate_y: f32,
    /// Viewing distance for `rotate_x`/`rotate_y`, in pixels. **One vanishing
    /// point for the whole shot** — which is the difference from copying the
    /// same rotation onto every group, where each group gets its own. `0`
    /// means no perspective, an orthographic tilt. Default 0.
    #[serde(default)]
    pub perspective: f32,
    /// Keyframe animations for camera properties.
    #[serde(default)]
    pub keyframes: Vec<CameraKeyframe>,
    /// Blurs the frame across a shutter window when the camera itself is
    /// moving (pan, zoom, rotation or `shake`) — opt-in, since it renders
    /// `samples` sub-frame exposures and averages them instead of one.
    /// Absent (the default) renders exactly as before. Skipped on any frame
    /// where the resolved camera pose does not change across the window, so
    /// a static shot never pays the extra cost.
    #[serde(default)]
    pub motion_blur: Option<CameraMotionBlurConfig>,
}

impl Camera {
    pub fn resolve_property(&self, property: &str, time: f64) -> f32 {
        let track = self
            .keyframes
            .iter()
            .find(|k| k.property == property)
            .filter(|t| !t.values.is_empty());
        let Some(track) = track else {
            return self.static_property(property);
        };

        let points = &track.values;
        if time <= points[0].time {
            return points[0].value;
        }
        if time >= points[points.len() - 1].time {
            return points[points.len() - 1].value;
        }
        for i in 0..points.len() - 1 {
            let p0 = &points[i];
            let p1 = &points[i + 1];
            if time >= p0.time && time <= p1.time {
                let segment_duration = p1.time - p0.time;
                let segment_t = if segment_duration.abs() < 1e-9 {
                    1.0
                } else {
                    (time - p0.time) / segment_duration
                };
                let segment_easing = p0.easing.as_ref().unwrap_or(&track.easing);
                let eased = crate::engine::animator::ease(segment_t, segment_easing) as f32;
                return p0.value + (p1.value - p0.value) * eased;
            }
        }
        points[points.len() - 1].value
    }

    fn static_property(&self, property: &str) -> f32 {
        match property {
            "x" => self.x,
            "y" => self.y,
            "zoom" => self.zoom,
            "rotation" => self.rotation,
            "focus" => self.focus,
            "aperture" => self.aperture,
            "rotate_x" => self.rotate_x,
            "rotate_y" => self.rotate_y,
            "perspective" => self.perspective,
            "origin.x" => self.origin.as_ref().map(|o| o.x).unwrap_or(0.0),
            "origin.y" => self.origin.as_ref().map(|o| o.y).unwrap_or(0.0),
            _ => 0.0,
        }
    }
}

/// Shutter-window camera motion blur, opt-in via `camera.motion_blur`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraMotionBlurConfig {
    /// Sub-frame exposures averaged together. Each one is a full render, so
    /// this is a direct cost multiplier — default `8`, clamped to `1..=16`.
    #[serde(default = "default_camera_motion_blur_samples")]
    pub samples: u32,
    /// Exposure length as a multiple of one frame duration (`shutter / fps`
    /// seconds), ending at the frame's own time. `0.5` (the default) covers
    /// half a frame, the 180-degree shutter of a film camera; `1.0` leaves it
    /// open for the whole frame and smears twice as far.
    #[serde(default = "default_camera_motion_blur_shutter")]
    pub shutter: f64,
}

fn default_camera_motion_blur_samples() -> u32 {
    8
}

fn default_camera_motion_blur_shutter() -> f64 {
    0.5
}

/// Where the light comes from, and how strong it is.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SceneLight {
    /// Horizontal component of the vector pointing **toward** the light.
    /// Negative puts the light on the left. Default `-0.35`.
    #[serde(default = "default_light_x")]
    pub x: f32,
    /// Vertical component, same convention: negative puts the light above.
    /// Default `-0.8`, so by default the light sits upper-left — where an
    /// eye expects it without being told. The vector need not be a unit
    /// vector; only its direction is read.
    #[serde(default = "default_light_y")]
    pub y: f32,
    /// Overall strength of every material in the scene. `0` flattens them
    /// all without editing each node. Default `1.0`.
    #[serde(default = "default_light_intensity")]
    pub intensity: f32,
    /// Colour of the highlight and the specular edge. Default `#FFFFFF`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub color: Option<String>,
}

fn default_light_x() -> f32 {
    -0.35
}

fn default_light_y() -> f32 {
    -0.8
}

fn default_light_intensity() -> f32 {
    1.0
}

impl Default for SceneLight {
    fn default() -> Self {
        SceneLight {
            x: default_light_x(),
            y: default_light_y(),
            intensity: default_light_intensity(),
            color: None,
        }
    }
}

/// Focal point of the camera in frame pixels.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraOrigin {
    #[serde(default)]
    pub x: f32,
    #[serde(default)]
    pub y: f32,
}

const KNOWN_CAMERA_PROPERTIES: &[&str] = &[
    "x",
    "y",
    "zoom",
    "rotation",
    "origin.x",
    "origin.y",
    "focus",
    "aperture",
    "rotate_x",
    "rotate_y",
    "perspective",
];

fn validate_camera_property<E: serde::de::Error>(value: &str) -> Result<(), E> {
    if KNOWN_CAMERA_PROPERTIES.contains(&value) {
        return Ok(());
    }
    let normalize = |s: &str| s.replace(['-', '_', ' '], ".").to_lowercase();
    let normalized = normalize(value);
    if let Some(suggestion) = KNOWN_CAMERA_PROPERTIES
        .iter()
        .find(|known| normalize(known) == normalized)
    {
        Err(E::custom(format!(
            "unknown camera keyframe property '{value}' — did you mean '{suggestion}'?"
        )))
    } else {
        Err(E::custom(format!(
            "unknown camera keyframe property '{value}': expected one of {}",
            KNOWN_CAMERA_PROPERTIES.join(", ")
        )))
    }
}

fn deserialize_camera_property<'de, D>(deserializer: D) -> Result<String, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    validate_camera_property::<D::Error>(&s)?;
    Ok(s)
}

/// A keyframe for a camera property.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraKeyframe {
    /// The camera property to animate: "x", "y", "zoom", "rotation",
    /// "origin.x", "origin.y" (dotted form, matching the component keyframe
    /// convention for compound properties).
    #[serde(deserialize_with = "deserialize_camera_property")]
    pub property: String,
    /// Time-value pairs for the animation.
    pub values: Vec<CameraKeyframePoint>,
    /// Easing function for interpolation.
    #[serde(default)]
    pub easing: EasingType,
}

/// A single time-value point in a camera keyframe.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CameraKeyframePoint {
    /// Time in seconds (relative to scene start).
    pub time: f64,
    /// Value at this time.
    pub value: f32,
    /// Easing for the segment starting at this keyframe, overriding the
    /// track-level `easing` for that segment only — same convention as a
    /// component `Keyframe`.
    #[serde(default)]
    pub easing: Option<EasingType>,
}

fn default_camera_focus() -> f32 {
    1.0
}

fn default_camera_zoom() -> f32 {
    1.0
}

/// Direction for progressive blur.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum BlurDirection {
    Top,
    #[default]
    Bottom,
}

/// A post-processing effect applied to the full frame buffer after Skia renders.
/// Effects are pure Rust, deterministic, and applied in declaration order.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PostEffect {
    /// Film grain noise overlaid on every pixel.
    Grain {
        /// Noise strength clamped to 0..1. Default: 0.15.
        #[serde(default = "default_grain_intensity")]
        intensity: f32,
        /// Base seed for the noise hash. Default: 42.
        #[serde(default = "default_grain_seed")]
        seed: u64,
        /// When true, the pattern changes per frame (`seed ^ frame_index`). Default: true.
        #[serde(default = "default_true")]
        animated: bool,
    },
    /// Darken the frame edges towards the corners.
    Vignette {
        /// Darkness strength clamped to 0..1. Default: 0.5.
        #[serde(default = "default_vignette_intensity")]
        intensity: f32,
        /// Fraction of the half-diagonal where darkening starts. Default: 0.75.
        #[serde(default = "default_vignette_radius")]
        radius: f32,
    },
    /// Reduce spatial resolution by averaging square pixel blocks.
    Pixelate {
        /// Block size in pixels, clamped to 1..=256. Default: 8.
        #[serde(default = "default_pixelate_size")]
        size: u32,
    },
    /// Blur that grows from zero at `start` to `max_radius` at the frame edge.
    ProgressiveBlur {
        /// Which edge gets the maximum blur. Default: bottom.
        #[serde(default)]
        direction: BlurDirection,
        /// Fraction of the frame height where blur begins (0.0..1.0). Default: 0.5.
        #[serde(default = "default_blur_start")]
        start: f32,
        /// Maximum box-blur radius in pixels at the far edge. Default: 12.0.
        #[serde(default = "default_blur_max_radius")]
        max_radius: f32,
    },
    /// A full-frame flash that decays from `intensity` to zero over
    /// `duration`, starting at `at` — issue #330's companion to
    /// [`super::shake::SceneShake`]: the reference reel this pair was
    /// built for lights one of these on every shake impact, so a beat-grid
    /// `at` can share the same value the matching
    /// [`super::shake::ShakeImpact::at`] uses. `at` is a [`TimePoint`],
    /// resolved the same way as any other in-scene time (relative to the
    /// scene's own start unless `@`-prefixed).
    Flash {
        /// When the flash starts, on the scene's own timeline.
        at: TimePoint,
        /// Flash colour as a hex string. Default: `"#FFFFFF"`.
        #[serde(default = "default_flash_color")]
        color: String,
        /// Peak strength, clamped to 0..1, at `t = at`. Default: 0.6.
        #[serde(default = "default_flash_intensity")]
        intensity: f32,
        /// How long the flash takes to decay to zero, in seconds. Default: 0.15.
        #[serde(default = "default_flash_duration")]
        duration: f32,
    },
    /// A tape-tear glitch: the frame breaks into horizontal bands slid
    /// sideways, with optional noise, scanlines and a travelling tracking
    /// line. Bounded by `at` and `duration` like [`PostEffect::Flash`],
    /// because it reads as a beat rather than as a state — a reference reel
    /// holds it for well under a second on a rewind.
    Vhs {
        /// When the tear starts, on the scene's own timeline.
        at: TimePoint,
        /// How long it lasts, in seconds. Default: 0.5.
        #[serde(default = "default_vhs_duration")]
        duration: f32,
        /// Number of horizontal strips the frame breaks into. Default: 12.
        #[serde(default = "default_vhs_bands")]
        bands: u32,
        /// Largest sideways shift a band can take, in pixels. Default: 40.
        #[serde(default = "default_vhs_offset")]
        offset: f32,
        /// Strength of the added noise, 0..1. Default: 0.25.
        #[serde(default = "default_vhs_noise")]
        noise: f32,
        /// Strength of the darkened scanlines, 0..1. Default: 0.2.
        #[serde(default = "default_vhs_scanlines")]
        scanlines: f32,
        /// A thin coloured line travelling down the frame, as tape tracking
        /// error looks. Absent means none.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        tracking_line: Option<VhsTrackingLine>,
        /// Seed for the band layout and the noise. The same seed gives the
        /// same tear every render.
        #[serde(default = "default_vhs_seed")]
        seed: u64,
    },
}

/// The travelling line of a [`PostEffect::Vhs`] tear.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VhsTrackingLine {
    /// Line colour as a hex string. Default: `"#3DA5FF"`.
    #[serde(default = "default_vhs_tracking_color")]
    pub color: String,
    /// How many times it crosses the frame per second. Default: 1.5.
    #[serde(default = "default_vhs_tracking_speed")]
    pub speed: f32,
    /// Line thickness in pixels. Default: 3.0.
    #[serde(default = "default_vhs_tracking_thickness")]
    pub thickness: f32,
}

fn default_vhs_duration() -> f32 {
    0.5
}
fn default_vhs_bands() -> u32 {
    12
}
fn default_vhs_offset() -> f32 {
    40.0
}
fn default_vhs_noise() -> f32 {
    0.25
}
fn default_vhs_scanlines() -> f32 {
    0.2
}
fn default_vhs_seed() -> u64 {
    1
}
fn default_vhs_tracking_color() -> String {
    "#3DA5FF".to_string()
}
fn default_vhs_tracking_speed() -> f32 {
    1.5
}
fn default_vhs_tracking_thickness() -> f32 {
    3.0
}

fn default_grain_intensity() -> f32 {
    0.15
}
fn default_grain_seed() -> u64 {
    42
}
fn default_true() -> bool {
    true
}
fn default_vignette_intensity() -> f32 {
    0.5
}
fn default_vignette_radius() -> f32 {
    0.75
}
fn default_pixelate_size() -> u32 {
    8
}
fn default_blur_start() -> f32 {
    0.5
}
fn default_blur_max_radius() -> f32 {
    12.0
}
fn default_flash_color() -> String {
    "#FFFFFF".to_string()
}
fn default_flash_intensity() -> f32 {
    0.6
}
fn default_flash_duration() -> f32 {
    0.15
}

/// Scene-level flex layout configuration
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SceneLayout {
    #[serde(default)]
    pub direction: Option<CardDirection>,
    #[serde(default)]
    pub gap: Option<f32>,
    #[serde(default)]
    pub align_items: Option<CardAlign>,
    #[serde(default)]
    pub justify_content: Option<CardJustify>,
    #[serde(default)]
    pub padding: Option<f32>,
}

/// The order `pixel_dissolve` turns its cells in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PixelDissolveOrder {
    /// From the frame's border inward, so the centre — where the subject
    /// usually is — is the last thing to go. This is what the reference piece
    /// does, and it is the default for that reason.
    #[default]
    EdgesIn,
    /// The mirror: the centre opens first and the border closes last.
    CenterOut,
    /// No spatial order at all — every cell on its own draw.
    Random,
}

/// The corner a `corner_reveal` is anchored to.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionCorner {
    /// Measured default: the reference piece grows its reveal from here, with
    /// the right and top edges pinned and the left and bottom edges travelling.
    #[default]
    TopRight,
    TopLeft,
    BottomRight,
    BottomLeft,
}

/// Which way a directional transition travels.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionDirection {
    /// Both frames travel leftwards; the incoming one enters from the right.
    #[default]
    Left,
    Right,
    Up,
    Down,
}

/// The centre an `iris` mask grows from (or closes onto), or a `zoom_blur`
/// transition radiates its streaks from, in frame pixels. Absent = frame
/// centre.
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ZoomBlurOrigin {
    /// Horizontal centre, in frame pixels.
    #[serde(default)]
    pub x: f32,
    /// Vertical centre, in frame pixels.
    #[serde(default)]
    pub y: f32,
}

/// The silhouette an `iris` transition's mask takes as it grows.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum IrisShape {
    /// A plain disc. `aspect` has no effect on this shape.
    #[default]
    Circle,
    /// A stadium — a rounded rectangle whose width-to-height ratio is set by
    /// `aspect`. `aspect: 1.0` degenerates to a circle.
    Pill,
}

/// A coloured border traced along an `iris` mask's moving edge. Inside the
/// ring is whichever scene the mask currently reveals, outside it is the one
/// it is covering.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IrisRing {
    /// Ring colour, any CSS colour string.
    pub color: String,
    /// Ring thickness, in px.
    #[serde(default = "default_iris_ring_width")]
    pub width: f32,
}

fn default_iris_ring_width() -> f32 {
    12.0
}

fn default_iris_aspect() -> f32 {
    1.0
}

/// The arbitrary silhouette a `mask` transition scales up from `origin`,
/// authored in the shape's own local coordinates (whatever range its points
/// or path data use — a 100x100 star, a 24x24 icon glyph, anything). The
/// silhouette's own bounding-box centre is what tracks `origin`, not a fixed
/// point like `(0, 0)`, so a lopsided glyph still grows from its visual
/// middle. Reuses the same `kind` vocabulary as `style.clip-path`
/// (`polygon`, `path`) rather than inventing a second grammar for the same
/// idea.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum MaskShape {
    /// A closed straight-edge outline, in local units.
    Polygon { points: Vec<(f32, f32)> },
    /// SVG path data, in local units.
    Path { d: String },
}

fn default_mask_to_scale() -> f32 {
    20.0
}

fn default_blob_lobes() -> u32 {
    8
}

fn default_blob_wobble() -> f32 {
    0.15
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Transition {
    #[serde(rename = "type")]
    pub transition_type: TransitionType,
    /// Which corner a `corner_reveal` grows from. Ignored by every other type.
    #[serde(default)]
    pub corner: TransitionCorner,
    /// Cell edge in px for `pixel_dissolve`. Ignored by every other type.
    #[serde(default = "default_transition_cell")]
    pub cell: f32,
    /// `pixel_dissolve` and `blob` only: `pixel_dissolve`'s stable scatter
    /// selector, or the stable lobe-wobble selector `blob` grows its
    /// silhouette from. Two transitions with the same seed (and, for `blob`,
    /// the same `lobes`/`wobble`) reproduce the same shape.
    #[serde(default = "default_transition_seed")]
    pub seed: u32,
    /// `pixel_dissolve` only: which cells turn first.
    #[serde(default)]
    pub order: PixelDissolveOrder,
    /// Which way a `chromatic_wipe` or `whip` travels. Ignored by every
    /// other type — the `wipe_*`/`slide` family encodes its direction in the
    /// type name.
    #[serde(default)]
    pub direction: TransitionDirection,
    /// `chromatic_wipe` only: how far the red and cyan channels split at the
    /// peak of the wipe, as a multiple of the tuned default. `0` removes the
    /// colour flash and leaves a plain fast slide; `2` doubles it.
    #[serde(default = "default_transition_aberration")]
    pub aberration: f32,
    /// `zoom_blur` and `whip` only: how far the streak reaches — radially
    /// from `origin` for `zoom_blur`, along `direction` for `whip`. `0`
    /// collapses the streak pass entirely, leaving a plain zoom or a plain
    /// slide with no smear. Ignored by every other transition type.
    #[serde(default = "default_transition_strength")]
    pub strength: f32,
    /// `zoom_blur`, `iris`, `mask` and `blob` only: for `zoom_blur`, the
    /// centre the streaks radiate from; for the other three, the mask's
    /// centre — in `mask`'s case, the point its silhouette's own
    /// bounding-box centre grows from. Ignored by every other transition
    /// type. Frame pixels, like `zoom_blur`'s, not a `0..1` fraction.
    #[serde(default)]
    pub origin: Option<ZoomBlurOrigin>,
    /// `iris` only: the mask's silhouette. Ignored by every other type.
    #[serde(default)]
    pub shape: IrisShape,
    /// `iris` `pill` only: width-to-height ratio of the stadium as it grows.
    /// Ignored by `circle` and by every other transition type.
    #[serde(default = "default_iris_aspect")]
    pub aspect: f32,
    /// `iris` only: a flat colour the mask fills with instead of revealing
    /// the next scene directly. Once the colour covers the whole frame, the
    /// next scene fades in under it. Absent reveals the next scene straight
    /// away. Ignored by every other transition type.
    #[serde(default)]
    pub fill: Option<String>,
    /// `iris` only, and only meaningful with `fill` set: seconds the solid
    /// colour holds once it covers the whole frame, before the next scene
    /// fades in. Has no effect without `fill`.
    #[serde(default)]
    pub hold: f32,
    /// `iris` only: a coloured ring traced along the mask's moving edge.
    /// Ignored by every other transition type.
    #[serde(default)]
    pub ring: Option<IrisRing>,
    /// `iris` only: the mask closes onto `origin` instead of opening from
    /// it — the outgoing scene shrinks to a point rather than the incoming
    /// one (or `fill`) growing to cover the frame. Ignored by every other
    /// transition type.
    #[serde(default)]
    pub reverse: bool,
    /// `mask` only: the arbitrary silhouette it scales up from `origin` —
    /// `polygon` or `path`, the same `kind` vocabulary as `style.clip-path`.
    /// Required for `mask`; ignored by every other type (`blob` grows its
    /// own procedural silhouette instead, from `lobes`/`wobble`/`seed`).
    #[serde(default)]
    pub silhouette: Option<MaskShape>,
    /// `mask` only: `silhouette`'s scale factor at `progress: 0`, relative to
    /// its own bounding-box size. `0` is a true point; small positive values
    /// (the default) start it as a barely-visible speck rather than exactly
    /// nothing, which is fine since `progress <= 0.0` always short-circuits
    /// to the untouched outgoing frame regardless of this value. Ignored by
    /// every other transition type.
    #[serde(default)]
    pub from_scale: f32,
    /// `mask` only: `silhouette`'s scale factor at `progress: 1`. Large
    /// enough to guarantee full-frame coverage is the author's
    /// responsibility — unlike `iris`, an arbitrary silhouette's covering
    /// radius cannot be solved for automatically. Ignored by every other
    /// transition type.
    #[serde(default = "default_mask_to_scale")]
    pub to_scale: f32,
    /// `blob` only: how many lobes its procedural organic silhouette grows.
    /// Ignored by every other transition type.
    #[serde(default = "default_blob_lobes")]
    pub lobes: u32,
    /// `blob` only: how far each lobe's radius wanders from a perfect circle,
    /// as a fraction of it — `0` degenerates to a circle (a slow `iris` by
    /// another name), values approaching `1` pinch some lobes almost to the
    /// centre. Ignored by every other transition type.
    #[serde(default = "default_blob_wobble")]
    pub wobble: f32,
    /// `wipe_*`, `mask` and `blob` only: width, in px, of the soft gradient
    /// edge instead of a hard cut. `0` (the default) is a hard edge — the
    /// wipe's clip-rect boundary, or the mask/blob's clip-path boundary, with
    /// no blur pass at all. Ignored by every other transition type.
    #[serde(default)]
    pub feather: f32,
    /// `wipe_*`, `mask` and `blob` only, and only visible when `feather` is
    /// greater than `0`: a colour that tints the feathered edge itself,
    /// peaking exactly on the boundary and fading to nothing at both sides of
    /// the feather band — a coloured front that sweeps across ahead of the
    /// incoming scene. Absent leaves the feather a plain alpha blend. Ignored
    /// by every other transition type, and by these three when `feather` is
    /// `0` (a hard edge has no band to tint).
    #[serde(default)]
    pub band_color: Option<String>,
    #[serde(default = "default_transition_duration")]
    pub duration: f64,
    #[serde(default = "default_transition_easing")]
    pub easing: EasingType,
    /// How the background behaves during a `camera_pan`. Ignored by every
    /// other transition type, which composite two finished frames and have no
    /// separate background layer to move.
    #[serde(default)]
    pub background: PanBackground,
}

/// Whether a `camera_pan` treats the background as a fixed backdrop the scenes
/// slide across, or as part of each scene, travelling with it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PanBackground {
    /// The outgoing scene's background stays put while both foregrounds slide
    /// over it. Keeps a shared ambience continuous, so the cut is invisible —
    /// this is the default because it is what makes a multi-beat video read as
    /// one shot.
    #[default]
    Static,
    /// Each scene carries its own background, and both travel with their
    /// foreground. Use this when the beats are meant to look like different
    /// places rather than one continuous space.
    Travel,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum TransitionType {
    Fade,
    WipeLeft,
    WipeRight,
    WipeUp,
    WipeDown,
    ZoomIn,
    ZoomOut,
    Flip,
    ClockWipe,
    /// A mask that grows from `origin` to reveal the next scene — a plain
    /// disc by default. `shape` and `aspect` change its silhouette, `fill`
    /// paints it a flat colour first (held for `hold` seconds before the
    /// next scene fades in under it), `ring` traces its moving edge, and
    /// `reverse` closes the mask onto `origin` instead of opening from it.
    Iris,
    Slide,
    Dissolve,
    CornerReveal,
    PixelDissolve,
    CameraPan,
    /// A fast slide in which the reveal edge splits into its red and cyan
    /// channels at the peak and recombines as it lands — the glitch-flash
    /// cut. `direction` steers it, `aberration` scales the split.
    ChromaticWipe,
    /// A radial zoom blur: the outgoing frame streaks outward from `origin`
    /// while it fades, then the incoming frame is left standing alone — the
    /// "tunnel" cut. `strength` sets how far the streaks reach; `0`
    /// collapses it to a plain zoom with no smear. Zero at both ends of the
    /// transition, so no fringe bleeds into the next scene.
    ZoomBlur,
    /// A directional slide whose axis grows a motion-blur streak that peaks
    /// at the midpoint: the outgoing frame shoots off along `direction`
    /// while it fades, and the incoming frame arrives already streaked and
    /// lands sharp. `strength` sets how far the streak reaches; `0`
    /// collapses it to a plain `slide`. Zero at both ends of the
    /// transition, so no streak bleeds into the next scene.
    Whip,
    /// `iris` generalised to any silhouette: `silhouette` (a `polygon` or a
    /// `path`, the same grammar as `style.clip-path`) scales up about its own
    /// bounding-box centre, from `from_scale` to `to_scale`, centred on
    /// `origin`. `feather` and `band_color` soften and tint the moving edge.
    /// Byte-identical to the outgoing frame at `progress: 0` and to the
    /// incoming one at `progress: 1`, by construction rather than by tuning
    /// `from_scale`/`to_scale` precisely.
    Mask,
    /// An organic metaball silhouette — `lobes` control points at a radius
    /// wobbling by `wobble` around a circle, joined into a smooth closed
    /// blob — growing from `origin` exactly like `mask`, but with a covering
    /// radius solved for automatically the way `iris`'s is, instead of an
    /// author-supplied `to_scale`. `seed` picks which wobble the lobes get.
    /// `feather` and `band_color` behave as they do on `mask`.
    Blob,
    None,
}

fn default_transition_cell() -> f32 {
    48.0
}

fn default_transition_seed() -> u32 {
    11
}

fn default_transition_aberration() -> f32 {
    1.0
}

fn default_transition_strength() -> f32 {
    1.0
}

fn default_transition_duration() -> f64 {
    0.5
}

pub(crate) fn default_transition_easing() -> EasingType {
    EasingType::EaseInOut
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum VideoCodec {
    #[default]
    H264,
    H265,
    Vp9,
    Prores,
}

fn default_version() -> String {
    "1.0".to_string()
}

fn default_fps() -> u32 {
    30
}

fn default_background() -> String {
    "#000000".to_string()
}

#[cfg(test)]
mod annotation_tests {
    use super::*;

    const MINIMAL: &str = r#"{ "video": { "width": 1920, "height": 1080 }, "scenes": [] }"#;

    const WITH_ANNOTATIONS: &str = r#"{
        "video": { "width": 1920, "height": 1080 },
        "scenes": [],
        "annotations": [
            {
                "id": "an_4f2a",
                "note": "reduce font-size",
                "status": "open",
                "frame": 142,
                "view": 0,
                "scene": 2,
                "target": {
                    "pointer": "/scenes/2/children/5",
                    "kind": "text",
                    "rect": [10.0, 20.0, 30.0, 40.0]
                }
            }
        ]
    }"#;

    #[test]
    fn scenario_without_annotations_defaults_empty() {
        let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
        assert!(s.annotations.is_empty());
    }

    #[test]
    fn scenario_with_annotations_deserializes() {
        let s: Scenario = serde_json::from_str(WITH_ANNOTATIONS).unwrap();
        assert_eq!(s.annotations.len(), 1);
        let a = &s.annotations[0];
        assert_eq!(a.id, "an_4f2a");
        assert_eq!(a.status, AnnotationStatus::Open);
        assert_eq!(a.frame, Some(142));
        assert_eq!(a.view, Some(0));
        assert_eq!(a.scene, Some(2));
        assert_eq!(a.target.pointer, "/scenes/2/children/5");
        assert_eq!(a.target.kind.as_deref(), Some("text"));
        assert_eq!(a.target.rect, Some([10.0, 20.0, 30.0, 40.0]));
    }

    #[test]
    fn empty_annotations_are_not_serialized() {
        let s: Scenario = serde_json::from_str(MINIMAL).unwrap();
        let json = serde_json::to_string(&s).unwrap();
        assert!(
            !json.contains("annotations"),
            "empty annotations must be skipped, got: {json}"
        );
    }

    #[test]
    fn status_defaults_to_open_and_target_fields_optional() {
        let json = r#"{
            "video": { "width": 1, "height": 1 },
            "scenes": [],
            "annotations": [ { "id": "x", "note": "n", "target": { "pointer": "/scenes/0" } } ]
        }"#;
        let s: Scenario = serde_json::from_str(json).unwrap();
        assert_eq!(s.annotations[0].status, AnnotationStatus::Open);
        assert_eq!(s.annotations[0].target.kind, None);
        assert_eq!(s.annotations[0].target.rect, None);
        assert_eq!(s.annotations[0].frame, None);
    }
}

#[cfg(test)]
mod scene_entry_error_tests {
    use super::*;

    #[test]
    fn missing_duration_names_the_scene_index_and_field_not_the_untagged_message() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [
                { "duration": 1.0, "children": [] },
                { "children": [] }
            ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("missing duration must fail");
        let msg = err.to_string();
        assert!(
            !msg.contains("did not match any variant of untagged enum"),
            "must not regress to the opaque untagged message: {msg}"
        );
        assert!(
            msg.contains("scenes[1]"),
            "must name the offending scene index: {msg}"
        );
        assert!(
            msg.contains("duration"),
            "must name the missing field: {msg}"
        );
    }

    #[test]
    fn misspelled_transition_type_names_itself() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [
                {
                    "duration": 1.0,
                    "children": [],
                    "transition": { "type": "wip_left" }
                }
            ]
        }"#;
        let err =
            serde_json::from_str::<Scenario>(json).expect_err("bad transition type must fail");
        let msg = err.to_string();
        assert!(
            !msg.contains("did not match any variant of untagged enum"),
            "must not regress to the opaque untagged message: {msg}"
        );
        assert!(
            msg.contains("scenes[0]"),
            "must name the offending scene index: {msg}"
        );
        assert!(
            msg.contains("wip_left"),
            "must echo the bad value so the author can spot the typo: {msg}"
        );
    }

    #[test]
    fn include_directive_still_works() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [
                { "include": "does/not/matter.json" }
            ]
        }"#;
        let s: Scenario = serde_json::from_str(json).expect("include entry must parse");
        assert!(matches!(s.scenes[0], SceneEntry::Include(_)));
    }

    #[test]
    fn broken_include_directive_names_itself() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [
                { "include": "x.json", "scenes": "not-an-array" }
            ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        let msg = err.to_string();
        assert!(msg.contains("scenes[0]"), "got: {msg}");
        assert!(msg.contains("include directive"), "got: {msg}");
    }

    #[test]
    fn view_scenes_field_uses_the_same_precise_errors() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "composition": [
                { "type": "slide", "scenes": [ { "children": [] } ] }
            ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        let msg = err.to_string();
        assert!(
            !msg.contains("did not match any variant of untagged enum"),
            "got: {msg}"
        );
        assert!(msg.contains("scenes[0]"), "got: {msg}");
        assert!(msg.contains("duration"), "got: {msg}");
    }
}

#[cfg(test)]
mod strict_schema_tests {
    use super::*;

    #[test]
    fn misspelled_scene_field_is_rejected() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [ { "durration": 3.0, "children": [] } ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        assert!(err.to_string().contains("durration"), "got: {err}");
    }

    #[test]
    fn misspelled_video_field_is_rejected() {
        let json = r#"{
            "video": { "width": 100, "height": 100, "framerate": 30 },
            "scenes": [ { "duration": 1.0, "children": [] } ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        assert!(err.to_string().contains("framerate"), "got: {err}");
    }

    #[test]
    fn misspelled_top_level_scenario_field_is_rejected() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [ { "duration": 1.0, "children": [] } ],
            "titel": "typo"
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        assert!(err.to_string().contains("titel"), "got: {err}");
    }

    #[test]
    fn misspelled_camera_field_is_rejected() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [ {
                "duration": 1.0,
                "children": [],
                "camera": { "zooom": 1.5 }
            } ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        assert!(err.to_string().contains("zooom"), "got: {err}");
    }

    #[test]
    fn misspelled_scene_layout_field_is_rejected() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [ {
                "duration": 1.0,
                "children": [],
                "layout": { "gapp": 10 }
            } ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        assert!(err.to_string().contains("gapp"), "got: {err}");
    }

    #[test]
    fn misspelled_view_field_is_rejected() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "composition": [ { "typ": "slide", "scenes": [] } ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        assert!(err.to_string().contains("typ"), "got: {err}");
    }

    #[test]
    fn valid_scenario_with_every_covered_struct_still_parses() {
        let json = r##"{
            "version": "1.0",
            "video": { "width": 100, "height": 100, "fps": 30, "background": "#000000" },
            "scenes": [ {
                "duration": 1.0,
                "children": [],
                "layout": { "direction": "column", "gap": 10, "align_items": "center", "justify_content": "center", "padding": 5 },
                "transition": { "type": "fade", "duration": 0.5, "easing": "ease_in_out" },
                "camera": { "x": 0, "y": 0, "zoom": 1.0, "rotation": 0, "origin": { "x": 1, "y": 2 }, "keyframes": [ { "property": "zoom", "values": [ { "time": 0.0, "value": 1.0 } ], "easing": "linear" } ] }
            } ]
        }"##;
        let s: Scenario = serde_json::from_str(json).expect("valid scenario must still parse");
        assert_eq!(s.scenes.len(), 1);
    }
}

#[cfg(test)]
mod camera_keyframe_property_tests {
    use super::*;

    #[test]
    fn known_camera_properties_still_work() {
        for prop in ["x", "y", "zoom", "rotation", "origin.x", "origin.y"] {
            let json = format!(
                r#"{{ "property": "{prop}", "values": [ {{ "time": 0.0, "value": 1.0 }} ] }}"#
            );
            let kf: CameraKeyframe = serde_json::from_str(&json)
                .unwrap_or_else(|e| panic!("property '{prop}' must be accepted, got: {e}"));
            assert_eq!(kf.property, prop);
        }
    }

    #[test]
    fn unknown_camera_property_is_a_named_error_not_a_silent_no_op() {
        let json = r#"{ "property": "tilt", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
        let err = serde_json::from_str::<CameraKeyframe>(json).expect_err(
            "an unrecognised camera keyframe property must be rejected, not silently inert",
        );
        assert!(err.to_string().contains("tilt"), "got: {err}");
    }

    #[test]
    fn misspelled_origin_property_is_a_named_error() {
        let json = r#"{ "property": "origin_x", "values": [ { "time": 0.0, "value": 1.0 } ] }"#;
        let err = serde_json::from_str::<CameraKeyframe>(json)
            .expect_err("origin_x must be rejected — the real property is origin.x");
        let msg = err.to_string();
        assert!(msg.contains("origin_x"), "got: {msg}");
        assert!(
            msg.contains("origin.x"),
            "expected a did-you-mean nudge toward origin.x, got: {msg}"
        );
    }
}

#[cfg(test)]
mod scene_at_grammar_tests {
    use super::*;

    fn scenario_with_at(at: &str) -> String {
        format!(
            r#"{{
                "video": {{ "width": 100, "height": 100 }},
                "scenes": [
                    {{ "duration": 1.0, "children": [] }},
                    {{ "duration": 1.0, "children": [], "at": {at} }}
                ]
            }}"#
        )
    }

    #[test]
    fn syntactically_malformed_at_is_rejected_at_deserialize_time() {
        let json = scenario_with_at(r#""banana""#);
        let err = serde_json::from_str::<Scenario>(&json)
            .expect_err("a malformed `at` must fail to deserialize, not fall back silently");
        let msg = err.to_string();
        assert!(
            msg.contains("cannot parse time"),
            "expected the grammar error to surface, got: {msg}"
        );
        assert!(msg.contains("banana"), "got: {msg}");
    }

    #[test]
    fn grammatically_valid_beat_unit_deserializes_even_with_no_bpm() {
        let json = scenario_with_at(r#""@8b""#);
        let scenario: Scenario =
            serde_json::from_str(&json).expect("grammar-valid `at` must deserialize");
        let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
            panic!("expected a Scene entry");
        };
        assert!(matches!(scene.at, SceneStart::At(TimePoint::Spec(ref s)) if s == "@8b"));
    }

    #[test]
    fn auto_and_plain_seconds_still_deserialize() {
        let json = scenario_with_at(r#""auto""#);
        let scenario: Scenario = serde_json::from_str(&json).expect("auto must deserialize");
        let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
            panic!("expected a Scene entry");
        };
        assert!(matches!(scene.at, SceneStart::Auto(_)));

        let json = scenario_with_at("2.5");
        let scenario: Scenario = serde_json::from_str(&json).expect("bare number must deserialize");
        let SceneEntry::Scene(ref scene) = scenario.scenes[1] else {
            panic!("expected a Scene entry");
        };
        assert!(matches!(scene.at, SceneStart::At(TimePoint::Seconds(s)) if s == 2.5));
    }
}

#[cfg(test)]
mod scene_shake_and_flash_tests {
    use super::*;

    #[test]
    fn scene_shake_is_absent_by_default() {
        let json = r#"{ "duration": 1.0, "children": [] }"#;
        let scene: Scene = serde_json::from_str(json).unwrap();
        assert!(scene.shake.is_none());
    }

    #[test]
    fn scene_shake_deserializes_with_six_impacts_on_the_beat_grid() {
        let json = r##"{
            "duration": 4.0,
            "children": [],
            "shake": {
                "impacts": [
                    { "at": "0b", "amplitude": 24.0 },
                    { "at": "4b", "amplitude": 20.0 },
                    { "at": "8b", "amplitude": 20.0 },
                    { "at": "12b", "amplitude": 16.0 },
                    { "at": "16b", "amplitude": 16.0 },
                    { "at": "20b", "amplitude": 12.0 }
                ],
                "decay": 12.0,
                "frequency": 22.0,
                "rotation": 0.3
            }
        }"##;
        let scene: Scene = serde_json::from_str(json).unwrap();
        let shake = scene.shake.expect("shake must deserialize");
        assert_eq!(shake.impacts.len(), 6);
        assert_eq!(shake.decay, 12.0);
        assert_eq!(shake.frequency, 22.0);
        assert_eq!(shake.rotation, 0.3);
    }

    #[test]
    fn scene_shake_and_camera_coexist_on_the_same_scene() {
        let json = r#"{
            "duration": 2.0,
            "children": [],
            "camera": { "zoom": 1.2 },
            "shake": { "impacts": [ { "at": 0.5, "amplitude": 10.0 } ] }
        }"#;
        let scene: Scene = serde_json::from_str(json).unwrap();
        assert!(scene.camera.is_some());
        assert!(scene.shake.is_some());
    }

    #[test]
    fn misspelled_shake_field_is_rejected() {
        let json = r#"{
            "video": { "width": 100, "height": 100 },
            "scenes": [ {
                "duration": 1.0,
                "children": [],
                "shake": { "impacts": [], "decayy": 5.0 }
            } ]
        }"#;
        let err = serde_json::from_str::<Scenario>(json).expect_err("must fail");
        assert!(err.to_string().contains("decayy"), "got: {err}");
    }

    #[test]
    fn post_effect_flash_deserializes_and_defaults() {
        let json = r#"{ "type": "flash", "at": "4b" }"#;
        let effect: PostEffect = serde_json::from_str(json).unwrap();
        match effect {
            PostEffect::Flash {
                at,
                color,
                intensity,
                duration,
            } => {
                assert_eq!(at, TimePoint::Spec("4b".to_string()));
                assert_eq!(color, "#FFFFFF");
                assert_eq!(intensity, 0.6);
                assert_eq!(duration, 0.15);
            }
            other => panic!("expected Flash, got {other:?}"),
        }
    }

    #[test]
    fn post_effect_flash_accepts_every_known_field() {
        let json = r##"{
            "type": "flash",
            "at": 1.2,
            "color": "#FF3300",
            "intensity": 0.9,
            "duration": 0.08
        }"##;
        let effect: PostEffect = serde_json::from_str(json).unwrap();
        match effect {
            PostEffect::Flash {
                at,
                color,
                intensity,
                duration,
            } => {
                assert_eq!(at, TimePoint::Seconds(1.2));
                assert_eq!(color, "#FF3300");
                assert_eq!(intensity, 0.9);
                assert_eq!(duration, 0.08);
            }
            other => panic!("expected Flash, got {other:?}"),
        }
    }

    #[test]
    fn scene_effects_accept_a_flash_alongside_other_post_effects() {
        let json = r#"{
            "duration": 1.0,
            "children": [],
            "effects": [
                { "type": "flash", "at": "0b" },
                { "type": "grain", "intensity": 0.1 }
            ]
        }"#;
        let scene: Scene = serde_json::from_str(json).unwrap();
        assert_eq!(scene.effects.len(), 2);
        assert!(matches!(scene.effects[0], PostEffect::Flash { .. }));
        assert!(matches!(scene.effects[1], PostEffect::Grain { .. }));
    }
}

#[cfg(test)]
mod camera_keyframe_easing_tests {
    use super::*;

    fn camera_with_x_track(easing_on_first_point: Option<EasingType>) -> Camera {
        let json = serde_json::json!({
            "keyframes": [{
                "property": "x",
                "easing": "linear",
                "values": [
                    { "time": 0.0, "value": 0.0, "easing": easing_on_first_point },
                    { "time": 1.0, "value": 100.0 }
                ]
            }]
        });
        serde_json::from_value(json).expect("camera with one x track deserializes")
    }

    #[test]
    fn a_keyframes_own_easing_overrides_the_track_easing_for_its_segment() {
        let linear = camera_with_x_track(None);
        let linear_mid = linear.resolve_property("x", 0.5);
        assert!(
            (linear_mid - 50.0).abs() < 1e-4,
            "with no per-keyframe easing the track's own linear easing must still apply, \
             got {linear_mid}"
        );

        let eased = camera_with_x_track(Some(EasingType::EaseIn));
        let eased_mid = eased.resolve_property("x", 0.5);
        assert!(
            (eased_mid - 12.5).abs() < 1e-4,
            "a per-keyframe `ease_in` on the segment's starting point must override the \
             track's `linear` for that segment only (ease_in_cubic(0.5) = 0.125, so \
             0 + 100 * 0.125 = 12.5), got {eased_mid}"
        );
    }

    #[test]
    fn per_keyframe_easing_only_affects_the_segment_it_starts() {
        let json = serde_json::json!({
            "keyframes": [{
                "property": "x",
                "easing": "linear",
                "values": [
                    { "time": 0.0, "value": 0.0, "easing": "ease_in" },
                    { "time": 1.0, "value": 100.0 },
                    { "time": 2.0, "value": 200.0 }
                ]
            }]
        });
        let camera: Camera = serde_json::from_value(json).expect("camera deserializes");

        let first_segment_mid = camera.resolve_property("x", 0.5);
        assert!(
            (first_segment_mid - 12.5).abs() < 1e-4,
            "the first segment must still use its own ease_in, got {first_segment_mid}"
        );

        let second_segment_mid = camera.resolve_property("x", 1.5);
        assert!(
            (second_segment_mid - 150.0).abs() < 1e-4,
            "the second segment has no easing of its own and must fall back to the track's \
             linear easing, not inherit ease_in from the first segment, got {second_segment_mid}"
        );
    }
}