mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-14 01:42:18 -06:00
Compare commits
556
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6888c3800a | ||
|
|
26d0ecba74 | ||
|
|
9b1fe3ff23 | ||
|
|
653f596958 | ||
|
|
129efb16bd | ||
|
|
b8983ae65c | ||
|
|
8f93cd9099 | ||
|
|
0e0d7f8cb2 | ||
|
|
b5b77eb27e | ||
|
|
b73a53df3d | ||
|
|
1d608bb464 | ||
|
|
6849fa0149 | ||
|
|
6eebec7df6 | ||
|
|
a82e37dee2 | ||
|
|
2127bd1bd8 | ||
|
|
eec4f59535 | ||
|
|
988a492097 | ||
|
|
2406ce46ec | ||
|
|
60f91f7806 | ||
|
|
2ef5f31bc8 | ||
|
|
c6846f6a75 | ||
|
|
a3f2f215b0 | ||
|
|
35166b3841 | ||
|
|
b57b4ccfcf | ||
|
|
2fbdcfe5f5 | ||
|
|
98eac5590d | ||
|
|
46712d0293 | ||
|
|
e03c22c445 | ||
|
|
98208a9eda | ||
|
|
de52b6b766 | ||
|
|
7c83fa43ac | ||
|
|
7fe979aa24 | ||
|
|
690171665a | ||
|
|
203fa79211 | ||
|
|
3e2e73aab7 | ||
|
|
cf0d7fbb14 | ||
|
|
121c59d521 | ||
|
|
a55457ee53 | ||
|
|
7bd141c1c9 | ||
|
|
e3e218a8fc | ||
|
|
c532ae749b | ||
|
|
e0f3639e94 | ||
|
|
eb10e2b9b3 | ||
|
|
92a0437ab6 | ||
|
|
4e87f2b850 | ||
|
|
54846750d4 | ||
|
|
1e9c392f67 | ||
|
|
c3fb184e9d | ||
|
|
0623b43032 | ||
|
|
5ee82b4e45 | ||
|
|
61423ea35b | ||
|
|
c4674f24fb | ||
|
|
4ba9a1a5d9 | ||
|
|
3504a9da42 | ||
|
|
ccab980657 | ||
|
|
48e2203123 | ||
|
|
76bd26cca0 | ||
|
|
02fbb6a976 | ||
|
|
c16db2ef3e | ||
|
|
809c44654d | ||
|
|
220b52dfdb | ||
|
|
424f575ca5 | ||
|
|
aefd54d28e | ||
|
|
25809faf1a | ||
|
|
fc35ad67c6 | ||
|
|
57780f9e61 | ||
|
|
0627fb2589 | ||
|
|
170c5591b5 | ||
|
|
a883d6b2d9 | ||
|
|
4b1e50a6b0 | ||
|
|
c7919102ac | ||
|
|
159ed07eb3 | ||
|
|
de9e79a445 | ||
|
|
d7343553b3 | ||
|
|
9a95c3a639 | ||
|
|
daeedd5d19 | ||
|
|
a2c1633fe3 | ||
|
|
bfdcc5cdfa | ||
|
|
73de9900a7 | ||
|
|
b225bb16a1 | ||
|
|
9299113268 | ||
|
|
2b6d27d525 | ||
|
|
9998ee5e07 | ||
|
|
9e8aa81298 | ||
|
|
6b5faf6d51 | ||
|
|
0b5cd0f2d6 | ||
|
|
4f171e8f78 | ||
|
|
edeb99b00e | ||
|
|
29162e66ad | ||
|
|
8435cc301f | ||
|
|
75885b49de | ||
|
|
f7b728d0c2 | ||
|
|
d82aaddab4 | ||
|
|
8f26681a1f | ||
|
|
f2356fcf63 | ||
|
|
60132037b2 | ||
|
|
ac09977c12 | ||
|
|
480cbfd4ef | ||
|
|
afc7f765c9 | ||
|
|
d9186c7ce1 | ||
|
|
75744dd67d | ||
|
|
ebd306acc1 | ||
|
|
161af3b7d5 | ||
|
|
4e4d98e69d | ||
|
|
a52b2d348f | ||
|
|
acd19ff4ac | ||
|
|
b06d60b8ed | ||
|
|
a2d9e71b5e | ||
|
|
fe35df948c | ||
|
|
ede154d6be | ||
|
|
6431460e80 | ||
|
|
af9f522c6a | ||
|
|
efd26de81d | ||
|
|
7c47d0a9ac | ||
|
|
884e3d3353 | ||
|
|
f49ff09b8a | ||
|
|
52f87aca13 | ||
|
|
dfee4c1e88 | ||
|
|
f321416b34 | ||
|
|
41f07a3c4e | ||
|
|
85de6ca5d7 | ||
|
|
fc18a78e65 | ||
|
|
31abd2d819 | ||
|
|
070f12cc9c | ||
|
|
46a15a4684 | ||
|
|
144fd28f69 | ||
|
|
30ccc38e90 | ||
|
|
65ddd8ad51 | ||
|
|
d6cc51e07a | ||
|
|
077cd36e9f | ||
|
|
9d36b5931c | ||
|
|
c252a681a0 | ||
|
|
80f784d210 | ||
|
|
07dc2210a1 | ||
|
|
51d19baa0c | ||
|
|
50187449c2 | ||
|
|
b8de82e2cc | ||
|
|
6eaae2bd8c | ||
|
|
23ac4f7efd | ||
|
|
69c91bb265 | ||
|
|
a790fdbc20 | ||
|
|
9d59fa96d3 | ||
|
|
2dfb07f2c4 | ||
|
|
f5fbadde63 | ||
|
|
996bb42ff5 | ||
|
|
3c3d504eb1 | ||
|
|
f5511d93b6 | ||
|
|
c6b7de43bc | ||
|
|
65d5c28512 | ||
|
|
d2a8be197d | ||
|
|
872cdbf331 | ||
|
|
0233d29064 | ||
|
|
a72a5167d0 | ||
|
|
d5471bccf0 | ||
|
|
b7d57bacab | ||
|
|
42108d0ee7 | ||
|
|
0107186cee | ||
|
|
54e24ab868 | ||
|
|
19dceffd0b | ||
|
|
1eec8b266c | ||
|
|
32f069eb84 | ||
|
|
aa13eeb45e | ||
|
|
50e02490b3 | ||
|
|
907ebd2de0 | ||
|
|
ed77cc9eaa | ||
|
|
928aa9cc16 | ||
|
|
5cb154ce9d | ||
|
|
ef55104799 | ||
|
|
3f29dccc0a | ||
|
|
160c923caf | ||
|
|
9f01f2ebdb | ||
|
|
d365e0b9de | ||
|
|
1d181017f6 | ||
|
|
996ca4dd65 | ||
|
|
6c1170af62 | ||
|
|
ed62aafbe6 | ||
|
|
9f68e8e2cc | ||
|
|
a6e2b64875 | ||
|
|
c7b91ff2ec | ||
|
|
6546136a42 | ||
|
|
67c1d78948 | ||
|
|
4df4ea6c9f | ||
|
|
751ac26d28 | ||
|
|
82ec8137dc | ||
|
|
ba3d74dfb5 | ||
|
|
b61c5f11bd | ||
|
|
a0956fee13 | ||
|
|
bf60dbb773 | ||
|
|
328b4ed274 | ||
|
|
1e5b78de37 | ||
|
|
73bd231589 | ||
|
|
63531e2a6a | ||
|
|
6cdbee50a3 | ||
|
|
0c00f22403 | ||
|
|
3c8dc6bc98 | ||
|
|
6ab485a096 | ||
|
|
597b9488f5 | ||
|
|
4eb9098a7c | ||
|
|
df4bb8b84a | ||
|
|
f66bed88ac | ||
|
|
ee64e93c4a | ||
|
|
f8128f9107 | ||
|
|
97050a353b | ||
|
|
fb3b857f53 | ||
|
|
ba3b57c756 | ||
|
|
e4a0c2971b | ||
|
|
0f5364ee9f | ||
|
|
bb417bf98a | ||
|
|
d00ef08fae | ||
|
|
0d06481c34 | ||
|
|
44b133a3c9 | ||
|
|
b66ba2e10a | ||
|
|
8ec4f6cabb | ||
|
|
5a668bf02e | ||
|
|
3e178d8f5a | ||
|
|
58c24883c7 | ||
|
|
f68150ee10 | ||
|
|
b9eaaeb23b | ||
|
|
3cfd4c5e6e | ||
|
|
a200b92258 | ||
|
|
f2803102c1 | ||
|
|
fd4d2a8f79 | ||
|
|
e90e7f6c03 | ||
|
|
9e59b5f223 | ||
|
|
5cb6ba8403 | ||
|
|
62ada3ab9a | ||
|
|
77cd617596 | ||
|
|
72ac32715f | ||
|
|
e0992c1325 | ||
|
|
4bed9b6c37 | ||
|
|
ea9dad8ef9 | ||
|
|
0d45a64db2 | ||
|
|
e832a373f5 | ||
|
|
6c63345615 | ||
|
|
1f2c75a288 | ||
|
|
fd4c533425 | ||
|
|
d4d99c7627 | ||
|
|
9fce04f0b4 | ||
|
|
ffd77c7db0 | ||
|
|
1f6587b60d | ||
|
|
d09f4c1284 | ||
|
|
37a6aed679 | ||
|
|
02ea528e94 | ||
|
|
bcc4fc97e6 | ||
|
|
f4fcac9f2d | ||
|
|
babc950f81 | ||
|
|
468394b3c8 | ||
|
|
5eb0b7d2c0 | ||
|
|
01220df579 | ||
|
|
b0b87c5cad | ||
|
|
1192e5ef30 | ||
|
|
befa4cd69f | ||
|
|
f006b5d8ce | ||
|
|
e1cfe8516e | ||
|
|
cbb0c48445 | ||
|
|
cc00a17dfe | ||
|
|
90058395b7 | ||
|
|
ee39ff6781 | ||
|
|
bbd579813f | ||
|
|
eaf8d1ce3f | ||
|
|
f2f0c4373d | ||
|
|
14cc73101b | ||
|
|
650c4f9497 | ||
|
|
9f952550b2 | ||
|
|
5fb694d991 | ||
|
|
7f742c9d4f | ||
|
|
b5033b6213 | ||
|
|
b6c1d87cf0 | ||
|
|
c37b150950 | ||
|
|
74d7b844fe | ||
|
|
f3d8ba85a6 | ||
|
|
e690829543 | ||
|
|
c0468b59cd | ||
|
|
5d27461fa6 | ||
|
|
773133ec42 | ||
|
|
8ec4f29059 | ||
|
|
87a10fc8e2 | ||
|
|
3fb6c43136 | ||
|
|
82573331a8 | ||
|
|
d990678b32 | ||
|
|
cd19752bb5 | ||
|
|
2ed2ce18d8 | ||
|
|
c5ac1dd44b | ||
|
|
84ac51a439 | ||
|
|
c3e9bd07e5 | ||
|
|
a3fbf02952 | ||
|
|
92bf7fc67d | ||
|
|
0ed4b394ac | ||
|
|
e747bff425 | ||
|
|
f7a7fd7f5f | ||
|
|
800ffeaa59 | ||
|
|
d080a23dc0 | ||
|
|
243c1a269f | ||
|
|
06a1ca4672 | ||
|
|
40728b5362 | ||
|
|
d2504e5f8b | ||
|
|
d92874f541 | ||
|
|
6a34f13808 | ||
|
|
6058fc7882 | ||
|
|
04b0958bee | ||
|
|
acf054852c | ||
|
|
861879f8ee | ||
|
|
e626625d40 | ||
|
|
4a0f48a04c | ||
|
|
8f6e1b420a | ||
|
|
c0db6ebeba | ||
|
|
a667e13a03 | ||
|
|
3bf35c801f | ||
|
|
47eb6732fa | ||
|
|
8a2eccc863 | ||
|
|
fb05eaa30f | ||
|
|
231a52438c | ||
|
|
a00b7e8a51 | ||
|
|
9678ed7d7f | ||
|
|
1de7b44a8d | ||
|
|
5a235590dd | ||
|
|
a96dcdbe8f | ||
|
|
98138f563b | ||
|
|
0e3f4cd353 | ||
|
|
72c8621358 | ||
|
|
cccc9064ca | ||
|
|
1de0101ced | ||
|
|
917d4b96a9 | ||
|
|
f5c2aca644 | ||
|
|
4408ff6153 | ||
|
|
ad97533aec | ||
|
|
727b682d17 | ||
|
|
8770fb90c3 | ||
|
|
b1900e526d | ||
|
|
9a716be59e | ||
|
|
479c3cc5ea | ||
|
|
4ce2944630 | ||
|
|
255df314da | ||
|
|
b7482dd392 | ||
|
|
e83f964077 | ||
|
|
eaeb06c655 | ||
|
|
ede7a7ead7 | ||
|
|
68dda6f7d8 | ||
|
|
9aef8cc5ab | ||
|
|
6113f4e1a2 | ||
|
|
77e3a444b8 | ||
|
|
4c1d77ddce | ||
|
|
d453bee54f | ||
|
|
09bbfc7217 | ||
|
|
443f503407 | ||
|
|
e812da58df | ||
|
|
ee4bdbcd7d | ||
|
|
adaa4e6fa1 | ||
|
|
446bc528ad | ||
|
|
3c068df628 | ||
|
|
f8165751e5 | ||
|
|
7190c85860 | ||
|
|
ddd39249ba | ||
|
|
b4c1905d78 | ||
|
|
2c11865b2d | ||
|
|
b92a7ddbd5 | ||
|
|
0768e95591 | ||
|
|
82adf7326e | ||
|
|
ef6fd29928 | ||
|
|
a3449185ac | ||
|
|
6303166a18 | ||
|
|
c4221b1a7c | ||
|
|
46f23229ab | ||
|
|
ae08bbe62d | ||
|
|
7b46958fa2 | ||
|
|
c7b8a11895 | ||
|
|
81eff036d2 | ||
|
|
bcbdb76bfd | ||
|
|
3dc66ac1a5 | ||
|
|
1b5d860dfc | ||
|
|
3e8a9193bc | ||
|
|
cee1d015ec | ||
|
|
dc1fc8d83b | ||
|
|
c66fee15f2 | ||
|
|
b7bec00386 | ||
|
|
087c11bb42 | ||
|
|
4d2c1a3bac | ||
|
|
28756fc755 | ||
|
|
c3d1df6d28 | ||
|
|
2275e56319 | ||
|
|
c8d1a7c7df | ||
|
|
8713686274 | ||
|
|
e6f9e912f7 | ||
|
|
6208d3eaeb | ||
|
|
58abe9ddb7 | ||
|
|
34cfc5b20b | ||
|
|
e279461ca7 | ||
|
|
63934efc69 | ||
|
|
f61b7a34dd | ||
|
|
b8cfcf20a2 | ||
|
|
1841ab75da | ||
|
|
c1b0cf72df | ||
|
|
ba29b61f8c | ||
|
|
11b271b9ae | ||
|
|
352fec7725 | ||
|
|
359e46f042 | ||
|
|
ecad13d1cc | ||
|
|
9554cc7eb8 | ||
|
|
7554d647cd | ||
|
|
e713bccefd | ||
|
|
2153f8ac4d | ||
|
|
42b0e1de8f | ||
|
|
9749ec992f | ||
|
|
f08e5f8691 | ||
|
|
5b0871e5f3 | ||
|
|
0f5d01a461 | ||
|
|
234329a8d2 | ||
|
|
71c5da07eb | ||
|
|
a3d14bc8b0 | ||
|
|
2feb7dc2b8 | ||
|
|
5187e01856 | ||
|
|
6030fb4f8c | ||
|
|
b3c36e816a | ||
|
|
95a55b0ea7 | ||
|
|
5e85f73c5e | ||
|
|
5191af8ebb | ||
|
|
8d278d71cb | ||
|
|
9cb51393b3 | ||
|
|
2d48221edb | ||
|
|
b3ccea8577 | ||
|
|
e96ae0b6f5 | ||
|
|
6fa022d50f | ||
|
|
62b06a0a91 | ||
|
|
c389f62b33 | ||
|
|
6d02e90177 | ||
|
|
52d81a9468 | ||
|
|
81ab1bce0d | ||
|
|
3465ed9d47 | ||
|
|
f29b7e7eed | ||
|
|
8075b7ecd8 | ||
|
|
a37fa65852 | ||
|
|
21bbb4127d | ||
|
|
36eaa7e051 | ||
|
|
932ec7bef6 | ||
|
|
34af55bd6b | ||
|
|
90c8f65f5e | ||
|
|
b082f12f70 | ||
|
|
fe54c0eb3c | ||
|
|
3a4e30e10e | ||
|
|
505a6f28ac | ||
|
|
0da9cac9dc | ||
|
|
97db282daf | ||
|
|
99bb0f0a62 | ||
|
|
32ef926c46 | ||
|
|
4c1916466b | ||
|
|
afe4c324a7 | ||
|
|
079ea961dd | ||
|
|
f7b76e7894 | ||
|
|
35a9b812fd | ||
|
|
8333c371e1 | ||
|
|
1bdd63dabe | ||
|
|
6fbfea12a6 | ||
|
|
62e020c1c1 | ||
|
|
8cc25169ab | ||
|
|
9e5afc2c90 | ||
|
|
0e556d4e84 | ||
|
|
cfd30ce4b2 | ||
|
|
23c19d8e0c | ||
|
|
14eef3aceb | ||
|
|
33b6e11726 | ||
|
|
6d2fd18923 | ||
|
|
d8c1880331 | ||
|
|
8c2560f31c | ||
|
|
2ba45cdd4c | ||
|
|
d9f90c517d | ||
|
|
2ac978ce54 | ||
|
|
f893f6af7d | ||
|
|
6d699b484f | ||
|
|
22d9796786 | ||
|
|
83ad5d287c | ||
|
|
9388884d20 | ||
|
|
e0c64599cc | ||
|
|
b54edd61b0 | ||
|
|
d0f0d14c99 | ||
|
|
c2f3397483 | ||
|
|
d1487e6916 | ||
|
|
63b181170c | ||
|
|
352086304e | ||
|
|
6a9f18af92 | ||
|
|
fd102fb47b | ||
|
|
edbf542570 | ||
|
|
fca0492725 | ||
|
|
b57ab3fb35 | ||
|
|
e75a72c0cc | ||
|
|
cd5ff01810 | ||
|
|
9c24a63d1e | ||
|
|
5638b0ca09 | ||
|
|
8dfa6f1994 | ||
|
|
ddd7d210ef | ||
|
|
d8c88cea1d | ||
|
|
322be04e7b | ||
|
|
2cad063d1d | ||
|
|
3356bc071c | ||
|
|
0efd6f1b85 | ||
|
|
1ca71474a2 | ||
|
|
f58ec39968 | ||
|
|
d027f9d866 | ||
|
|
7c891de450 | ||
|
|
b5928a182e | ||
|
|
5896859440 | ||
|
|
d989389441 | ||
|
|
31efc67f73 | ||
|
|
8680166081 | ||
|
|
90ce7dcb9b | ||
|
|
9d32bc4da9 | ||
|
|
faf4c0ee52 | ||
|
|
4c83fb9abe | ||
|
|
6a1b8f1e39 | ||
|
|
1e842137b0 | ||
|
|
d6f532bb0d | ||
|
|
952e4db95f | ||
|
|
f4ca20659c | ||
|
|
b5257f9446 | ||
|
|
50b200be7c | ||
|
|
48535be970 | ||
|
|
7b424a463b | ||
|
|
964f4759c7 | ||
|
|
110d46daae | ||
|
|
b003dde823 | ||
|
|
feecee0a30 | ||
|
|
356195ba33 | ||
|
|
b1645b006e | ||
|
|
b9842d0b82 | ||
|
|
d8936f94af | ||
|
|
cd2e780432 | ||
|
|
20553865cc | ||
|
|
d8961ebc1e | ||
|
|
9ad147b310 | ||
|
|
6fa873dcfc | ||
|
|
65826295cc | ||
|
|
8c8ab9561b | ||
|
|
62ea604394 | ||
|
|
fa0a3e9dee | ||
|
|
1b2e5ea9e0 | ||
|
|
c5994e16db | ||
|
|
5a28760b1e | ||
|
|
8ada601630 | ||
|
|
b31a59ad89 | ||
|
|
039395ae4f | ||
|
|
ae30368106 | ||
|
|
0865152e86 | ||
|
|
1956b6b491 | ||
|
|
6396c480b2 | ||
|
|
aef364867d | ||
|
|
78c77ad450 | ||
|
|
939bd12832 | ||
|
|
71352733d4 | ||
|
|
1705712a41 | ||
|
|
48c5ff67bf | ||
|
|
78ab8f0626 | ||
|
|
813378cc20 | ||
|
|
811758f791 | ||
|
|
c3981daeb6 | ||
|
|
300872fb7d | ||
|
|
cc6773c95f | ||
|
|
6655a1d68d |
@@ -0,0 +1,31 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ci.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml': []
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/release.yml': []
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
@@ -26,8 +26,12 @@ jobs:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: House rules
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
- .github/workflows/house-rules.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: house-rules-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
house-rules:
|
||||
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
|
||||
with:
|
||||
rust: true
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -27,7 +27,11 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "16 1 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -12,16 +16,25 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
|
||||
@@ -29,6 +42,7 @@ jobs:
|
||||
# manifest fails the release instead of silently skipping it.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -44,3 +58,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -24,8 +24,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-dir: DeckCraft
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# AGENTS.md — rules for every agent and contributor
|
||||
|
||||
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
|
||||
@@ -6,14 +59,8 @@ Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conv
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
|
||||
(`../<app>`).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
|
||||
next to this repo's parent, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a
|
||||
session, follow its standards, and contribute reusable learnings back there. Never code: repos
|
||||
don't share code.
|
||||
|
||||
These rules bind every AI agent and every human contributor. They come before any task,
|
||||
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
|
||||
file wins.**
|
||||
instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
|
||||
|
||||
---
|
||||
|
||||
@@ -69,7 +116,7 @@ allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed
|
||||
or `docs/brand/` has no row.
|
||||
- **`NOTICE`** keeps the notices the licences require.
|
||||
- Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo (craftrules `standards/fonts.md`). DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
repo. DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
`fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at
|
||||
`../craft-fonts`).
|
||||
|
||||
@@ -99,7 +146,6 @@ once and tell the user.
|
||||
## 3. Never crash
|
||||
|
||||
People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.**
|
||||
Standard: craftrules `standards/never-crash.md`.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test
|
||||
code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
@@ -131,14 +177,14 @@ Standard: craftrules `standards/never-crash.md`.
|
||||
CLI, control channel and MCP do exactly the same thing.
|
||||
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
|
||||
tokens. **Never use Tauri**; egui only.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## 5. Quality gates (before every commit)
|
||||
|
||||
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
|
||||
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
|
||||
to `main`.
|
||||
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`).
|
||||
New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots
|
||||
declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
|
||||
|
||||
## 6. Running and looking at the app
|
||||
|
||||
@@ -147,20 +193,19 @@ to `main`.
|
||||
`{"id":1,"method":"engine.execute","params":{"command":"shape.insert","params":{"preset":"star5","rect":[100,100,200,200]}}}`
|
||||
then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods:
|
||||
`crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG) and compare with the observations
|
||||
in `plan/powerpoint/01-observed-ui.md`.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG). If available, compare with the observations
|
||||
in `plan/powerpoint/01-observed-ui.md`. Otherwise verify the intended behaviour against the task and
|
||||
committed documentation; the optional notebook is not a prerequisite.
|
||||
- Headless: `deckcraft-cli render --sample --all --scale 1 out/`,
|
||||
`deckcraft-cli run --sample --cmd 'slide.new={"title":"Hi"}' --export out.png`.
|
||||
- MCP: `deckcraft-cli mcp` (headless) or `deckcraft-cli mcp --connect 7990` (drive the app).
|
||||
See `docs/mcp.md`.
|
||||
- Shell gotcha on the dev machine: `mv`/`cp` are aliased interactive; use `/bin/mv -f`, `/bin/cp -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own.
|
||||
|
||||
## 7. Planning
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
`plan/` is an optional gitignored notebook (status, execution notes, `powerpoint/` observations). Do not ask for it and do not stop when it is absent. The committed progress
|
||||
docs:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
@@ -184,11 +229,13 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) |
|
||||
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About |
|
||||
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
|
||||
@@ -1,21 +1 @@
|
||||
# DeckCraft — working notes for Claude
|
||||
|
||||
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
|
||||
win over anything here.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## Start every session
|
||||
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
|
||||
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
|
||||
3. Run `cargo xtask ci` before you change anything, so you know the tree is green.
|
||||
|
||||
## Habits
|
||||
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
|
||||
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
|
||||
`deckcraft-cli render`.
|
||||
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
|
||||
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
|
||||
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+212
-119
@@ -222,7 +222,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -565,18 +565,42 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"polling",
|
||||
"rustix 1.1.5",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
|
||||
dependencies = [
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"rustix 0.38.44",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
|
||||
dependencies = [
|
||||
"calloop 0.14.5",
|
||||
"rustix 1.1.5",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -820,15 +844,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-audio"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -851,7 +884,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -859,7 +892,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -868,7 +901,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -888,15 +921,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -910,6 +945,36 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-video"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=09dcbc225b95232998498a40ca56e24716571d53#09dcbc225b95232998498a40ca56e24716571d53"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -983,7 +1048,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1005,32 +1070,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1040,14 +1089,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1073,7 +1122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1085,7 +1134,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1096,47 +1145,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1146,16 +1163,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
"deckcraft-hevc",
|
||||
"deckcraft-isobmff",
|
||||
"deckcraft-matroska",
|
||||
"deckcraft-ogg",
|
||||
"deckcraft-opus",
|
||||
"deckcraft-vp9",
|
||||
"craft-audio",
|
||||
"craft-media",
|
||||
"craft-video",
|
||||
"image",
|
||||
"log",
|
||||
"symphonia",
|
||||
@@ -1164,7 +1176,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1173,7 +1185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1183,17 +1195,9 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1211,7 +1215,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1227,7 +1231,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1246,7 +1250,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1260,7 +1264,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1274,6 +1278,7 @@ dependencies = [
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1283,19 +1288,12 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
"eframe",
|
||||
@@ -1506,19 +1504,35 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1586,6 +1600,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -1651,7 +1685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2707,7 +2741,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3013,7 +3047,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3486,7 +3520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4041,7 +4075,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4098,7 +4132,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4402,8 +4436,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"calloop 0.13.0",
|
||||
"calloop-wayland-source 0.3.0",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4420,6 +4454,44 @@ dependencies = [
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"calloop 0.14.5",
|
||||
"calloop-wayland-source 0.4.1",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-csd-frame",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-experimental",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-clipboard"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"smithay-client-toolkit 0.20.0",
|
||||
"wayland-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
@@ -4731,10 +4803,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4927,6 +4999,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -5369,6 +5442,32 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-experimental"
|
||||
version = "20250721.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-misc"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
@@ -5658,7 +5757,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5979,7 +6078,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -6000,7 +6099,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"smithay-client-toolkit",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
@@ -6176,7 +6275,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6422,7 +6521,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6510,11 +6608,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+11
-16
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.18.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -27,16 +27,6 @@ deckcraft-engine = { path = "crates/engine" }
|
||||
deckcraft-mcp = { path = "crates/mcp" }
|
||||
deckcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
deckcraft-media = { path = "crates/media" }
|
||||
# codecs (ported from FilmCraft, same owner and licence)
|
||||
deckcraft-bitstream = { path = "crates/bitstream" }
|
||||
deckcraft-h264 = { path = "crates/h264" }
|
||||
deckcraft-hevc = { path = "crates/hevc" }
|
||||
deckcraft-vp9 = { path = "crates/vp9" }
|
||||
deckcraft-av1 = { path = "crates/av1" }
|
||||
deckcraft-isobmff = { path = "crates/isobmff" }
|
||||
deckcraft-matroska = { path = "crates/matroska" }
|
||||
deckcraft-ogg = { path = "crates/ogg" }
|
||||
deckcraft-opus = { path = "crates/opus" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -59,10 +49,15 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
|
||||
# craft-media, craft-audio and craft-video share types and stay on one rev.
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false }
|
||||
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false, features = ["opus-decode"] }
|
||||
# H.264, HEVC, VP9 and AV1 decoders.
|
||||
craft-video = { git = "https://github.com/storytold/craft-libs", rev = "09dcbc225b95232998498a40ca56e24716571d53", version = "=0.1.0", default-features = false, features = ["h264-decode", "hevc-decode", "vp9-decode", "av1-decode", "threads"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -108,7 +103,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -76,8 +76,9 @@ Code dependencies
|
||||
- symphonia (audio decoding for MP3, AAC, ALAC, FLAC, Vorbis, WAV, AIFF, CAF) is used unmodified
|
||||
under the Mozilla Public License 2.0; its source is available at
|
||||
https://github.com/pdeljanov/Symphonia.
|
||||
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
|
||||
FilmCraft (same owner, MIT OR Apache-2.0).
|
||||
- The H.264, HEVC, VP9 and AV1 crates under crates/ are ported from FilmCraft (same owner,
|
||||
MIT OR Apache-2.0). The Opus decoder (craft-audio) and the MP4, Matroska and Ogg demuxers
|
||||
(craft-media) come from craft-libs (same owner, MIT OR Apache-2.0).
|
||||
|
||||
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
|
||||
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
|
||||
|
||||
@@ -286,7 +286,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+71
-5
@@ -2,9 +2,26 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (progress log: fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.15.0; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (accessibility contrast checks now include every text run, paragraph and nested group descendant; parity summary unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -43,7 +60,7 @@ Calibration is the same as for the full number (see [docs/target-app-parity.md](
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
@@ -116,11 +133,11 @@ ships 26.
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
image paste on Windows (#1414), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
|
||||
shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
|
||||
fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, EMF+ records,
|
||||
MicroType Express font subsets and SVG. 36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
@@ -137,7 +154,35 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-11 | Media: the Opus decoder and the Ogg, MP4/MOV and Matroska demuxers are craft-audio and craft-media from craft-libs; DeckCraft's own copies and re-export crates are removed |
|
||||
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
|
||||
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
|
||||
| 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) |
|
||||
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
|
||||
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
|
||||
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
|
||||
| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen |
|
||||
| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
|
||||
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
|
||||
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
|
||||
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
|
||||
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
|
||||
| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it |
|
||||
| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text |
|
||||
| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows |
|
||||
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
|
||||
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
||||
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
|
||||
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
|
||||
| 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) |
|
||||
| 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) |
|
||||
| 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide |
|
||||
| 2026-10-26 | A space, digit or punctuation after a symbol-font character (`a:sym`, e.g. a Wingdings arrow typed in a run) draws in the run's own font, as PowerPoint does; it stayed in the symbol font, whose space is about four times as wide (synthetic probe) |
|
||||
| 2026-10-29 | Review ▸ Accessibility checks text runs in every paragraph and nested group descendants for low contrast, reporting each affected shape once; the all-black control stays clear |
|
||||
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
|
||||
| 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits |
|
||||
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
|
||||
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
|
||||
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
|
||||
@@ -148,6 +193,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
| 2026-10-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
|
||||
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
|
||||
| 2026-10-26 | Duplicate (Ctrl+D) names the copy as PowerPoint does: a shape with an automatic name ("Rectangle 1") gets the next one ("Rectangle 5", also for the shapes inside a duplicated group), and a name you chose ("Hero") stays as it is instead of becoming "Hero copy" |
|
||||
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
|
||||
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
|
||||
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
|
||||
@@ -161,6 +207,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
|
||||
| 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
|
||||
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
|
||||
| 2026-10-25 | Format Shape ▸ Fill: Slide background fill shows the slide background through the shape (`shape.fill {background: true}`, saved as `useBgFill`). A shape opened from a file with this fill showed it as Automatic, and the pane offered no way to set it |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
@@ -283,7 +330,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-10 | Progress docs restructured; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
|
||||
@@ -294,7 +341,25 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-11 | trivial | Opus and container code moved to craft-libs crates; parity scores unchanged |
|
||||
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
|
||||
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
|
||||
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
|
||||
| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged |
|
||||
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
|
||||
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
|
||||
| 2026-11-01 | trivial | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
|
||||
| 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. |
|
||||
| 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged |
|
||||
| 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Review: accessibility contrast checks all runs, paragraphs and nested group descendants; checklist score and headline figures unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
@@ -312,6 +377,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
||||
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
||||
| 2026-10-26 | trivial | Progress log: spaces after symbol-font characters |
|
||||
| 2026-10-24 | trivial | Progress log: picture and embedded object fills |
|
||||
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
@@ -384,5 +450,5 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Restructured: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
|
||||
|
||||
@@ -4,29 +4,24 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect() }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
/// Returns whether this argument is a root flag. Both spellings (`--flag DIR` and
|
||||
/// `--flag=DIR`) may be repeated.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
@@ -40,8 +35,7 @@ impl AutomationArgs {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
roots.push(value.into());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -51,17 +45,16 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
fn roots_are_repeated_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--sample", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -71,8 +64,8 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
@@ -84,7 +77,7 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
@@ -71,6 +71,15 @@ slide). --export-params passes file.export options as one JSON object, such as
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting
|
||||
and command-line length limits. Each line can be up to 16 MiB, including commands with embedded
|
||||
media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF
|
||||
line endings and an optional UTF-8 BOM at the start of the file are accepted.
|
||||
Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear.
|
||||
File errors name the path; line-reading, JSON and command errors also name the line number.
|
||||
|
||||
deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
@@ -127,16 +136,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.
|
||||
|
||||
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
|
||||
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
|
||||
app is not confined by them).",
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed. Repeat either
|
||||
flag to grant access to more folders; a root left out grants none of its access. They run the in-process
|
||||
server, so they conflict with --connect (a running app is not confined by them).",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
@@ -180,7 +189,10 @@ A presentation is edited by running commands on it:
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
`deckcraft-cli help run` explains how to write a command.
|
||||
|
||||
If an output reader closes early, run, render, render-transition and convert continue; their exit
|
||||
status reflects the work, not output delivery. Stop the process to cancel them.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
+119
-22
@@ -7,23 +7,38 @@ mod automation_args;
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
if !STDOUT_CLOSED.load(Ordering::Relaxed) && let Err(e) = stdout_line(format_args!($($arg)*)) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
std::process::exit(0);
|
||||
STDOUT_CLOSED.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
fn stdout_line(args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
#[cfg(unix)]
|
||||
let mut stdout = {
|
||||
use std::os::fd::AsFd as _;
|
||||
std::fs::File::from(std::io::stdout().as_fd().try_clone_to_owned()?)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.write_all(format!("{args}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
@@ -146,9 +161,13 @@ fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f6
|
||||
f()?;
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
Ok(summarize_ms(ms))
|
||||
}
|
||||
|
||||
/// Summarize one population of measurements; each maximum must come from that population.
|
||||
fn summarize_ms(mut ms: Vec<f64>) -> (f64, f64) {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
|
||||
Ok((median, ms.last().copied().unwrap_or(0.0)))
|
||||
(ms.get(ms.len() / 2).copied().unwrap_or(0.0), ms.last().copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
|
||||
@@ -210,9 +229,13 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
|
||||
("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||||
];
|
||||
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
|
||||
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
|
||||
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
|
||||
let render_all = time_ms(runs, || {
|
||||
for i in 0..slides {
|
||||
deckcraft_engine::render::render_slide(&doc, i, &opts);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
steps.insert(1, ("render all slides (1×)", render_all));
|
||||
let peak = peak_rss_mib();
|
||||
if as_json {
|
||||
let v = json!({
|
||||
@@ -450,6 +473,69 @@ fn output_format(path: &str) -> Result<&'static str, String> {
|
||||
})
|
||||
}
|
||||
|
||||
enum CommandSource {
|
||||
Inline(String),
|
||||
File(String),
|
||||
}
|
||||
|
||||
const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec<u8>) -> std::io::Result<bool> {
|
||||
line.clear();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok(!line.is_empty());
|
||||
}
|
||||
let newline = available.iter().position(|byte| *byte == b'\n');
|
||||
let count = newline.unwrap_or(available.len());
|
||||
line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit"))
|
||||
})?;
|
||||
line.extend_from_slice(&available[..count]);
|
||||
reader.consume(count + usize::from(newline.is_some()));
|
||||
if newline.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same command syntax and execution path for --cmd and each command-file line.
|
||||
fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> {
|
||||
let (id, params) = match command.split_once('=') {
|
||||
Some((id, j)) => (id, serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (command, json!({})),
|
||||
};
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> {
|
||||
use std::io::BufReader;
|
||||
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut bytes = Vec::new();
|
||||
let mut line_number = 0usize;
|
||||
loop {
|
||||
line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?;
|
||||
if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? {
|
||||
break;
|
||||
}
|
||||
let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?;
|
||||
let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line };
|
||||
let command = line.trim();
|
||||
if command.is_empty() || command.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut sample = false;
|
||||
@@ -463,7 +549,8 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)),
|
||||
"--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
@@ -486,14 +573,10 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let mut s = Session::new();
|
||||
open(&mut s, input.as_deref(), sample)?;
|
||||
for c in cmds {
|
||||
let (id, p) = match c.split_once('=') {
|
||||
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (c.clone(), json!({})),
|
||||
};
|
||||
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
for source in cmds {
|
||||
match source {
|
||||
CommandSource::Inline(command) => run_command(&mut s, &command, print)?,
|
||||
CommandSource::File(path) => run_command_file(&mut s, &path, print)?,
|
||||
}
|
||||
}
|
||||
if let Some(p) = save {
|
||||
@@ -624,7 +707,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
let roots = AutomationRoots::new(&roots_args.read_roots, &roots_args.write_roots)?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
@@ -641,8 +724,12 @@ so drop --connect (or use --connect without the roots)"
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
let show = |roots: Vec<String>| if roots.is_empty() { "none".to_string() } else { roots.join(", ") };
|
||||
eprintln!(
|
||||
"deckcraft-cli: files confined: read roots {}, write roots {}",
|
||||
show(roots.read_roots().map(|r| r.display().to_string()).collect()),
|
||||
show(roots.write_roots().map(|r| r.display().to_string()).collect())
|
||||
);
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
Box::new(h)
|
||||
@@ -663,6 +750,16 @@ mod tests {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_summary_keeps_median_and_max_from_one_population() {
|
||||
let slide_medians = [8.99, 16.565];
|
||||
let whole_deck = summarize_ms(vec![17.0, 18.0, 16.0]);
|
||||
assert_eq!(whole_deck, (17.0, 18.0));
|
||||
assert!(whole_deck.1 >= whole_deck.0);
|
||||
assert!(slide_medians.iter().sum::<f64>() > whole_deck.1, "fixture must expose summing independent slide medians");
|
||||
assert_eq!(summarize_ms(vec![]), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_prints_usage_and_writes_nothing() {
|
||||
assert!(render(&args(&["--help"])).is_ok());
|
||||
|
||||
@@ -20,6 +20,153 @@ fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn command_file_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn inspected_titles(out: &str) -> Vec<String> {
|
||||
let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(answer).unwrap();
|
||||
doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_runs_utf8_lines_in_order_and_skips_comments() {
|
||||
let dir = command_file_dir("lines");
|
||||
let file = dir.join("commands with spaces.txt");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}",
|
||||
)
|
||||
.unwrap();
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]);
|
||||
let out = stdout(&o);
|
||||
let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect();
|
||||
assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 4);
|
||||
assert_eq!(titles[1], "Résumé = # 测试");
|
||||
assert_eq!(titles[3], "Last");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_files_and_inline_commands_keep_argument_order() {
|
||||
let dir = command_file_dir("mixed");
|
||||
let first = dir.join("first.txt");
|
||||
let second = dir.join("second.txt");
|
||||
let saved = dir.join("result.deckcraft");
|
||||
std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap();
|
||||
std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline first\"}",
|
||||
"--cmd-file",
|
||||
&first.to_string_lossy(),
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline middle\"}",
|
||||
"--cmd-file",
|
||||
&second.to_string_lossy(),
|
||||
"--cmd",
|
||||
"document.inspect",
|
||||
"--print",
|
||||
"--save",
|
||||
&saved.to_string_lossy(),
|
||||
]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]);
|
||||
assert_eq!(out.lines().count(), 5);
|
||||
let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]);
|
||||
assert_eq!(inspected_titles(&stdout(&reopened)), titles);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_file_command_stops_later_commands_and_final_outputs() {
|
||||
let dir = command_file_dir("failure");
|
||||
let file = dir.join("commands.txt");
|
||||
let later = dir.join("later-in-file.deckcraft");
|
||||
let inline = dir.join("later-inline.deckcraft");
|
||||
let save = dir.join("saved.deckcraft");
|
||||
let export = dir.join("exported.png");
|
||||
let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()}));
|
||||
for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] {
|
||||
let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()}));
|
||||
std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap();
|
||||
std::fs::write(&save, b"keep saved file").unwrap();
|
||||
std::fs::write(&export, b"keep exported file").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--print",
|
||||
"--cmd-file",
|
||||
&file.to_string_lossy(),
|
||||
"--cmd",
|
||||
&inline_command,
|
||||
"--save",
|
||||
&save.to_string_lossy(),
|
||||
"--export",
|
||||
&export.to_string_lossy(),
|
||||
]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}");
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran");
|
||||
assert!(!later.exists() && !inline.exists(), "commands after the failure must not run");
|
||||
assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file");
|
||||
assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_read_errors_name_the_path_and_utf8_line() {
|
||||
let dir = command_file_dir("read-errors");
|
||||
let missing = dir.join("missing.txt");
|
||||
let invalid = dir.join("invalid.txt");
|
||||
let save = dir.join("out.deckcraft");
|
||||
std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap();
|
||||
for file in [&missing, &dir, &invalid] {
|
||||
let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&file.display().to_string()), "{err}");
|
||||
if file == &invalid {
|
||||
assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}");
|
||||
}
|
||||
assert!(!save.exists());
|
||||
}
|
||||
assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_rejects_an_oversized_line_before_running_it() {
|
||||
let dir = command_file_dir("line-limit");
|
||||
let file = dir.join("commands.txt");
|
||||
let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
|
||||
std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap();
|
||||
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}");
|
||||
assert!(o.stdout.is_empty(), "the oversized command must not run");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_commands_keep_their_existing_syntax_and_errors() {
|
||||
let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 3);
|
||||
assert_eq!(titles[2], "Inline = #");
|
||||
assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:"));
|
||||
assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
@@ -74,6 +221,9 @@ fn run_help_says_what_the_ids_do_not() {
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
"--cmd-file FILE reads the same commands from a UTF-8 text file, one per line",
|
||||
"commands execute in the order those options appear.",
|
||||
"If a command fails, run stops there and saves nothing.",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::fs::{self, File};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("deckcraft-closed-stdout-{name}-{}-{nonce}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn closed_stdout() -> Stdio {
|
||||
let (writer, reader) = UnixStream::pair().unwrap();
|
||||
drop(reader);
|
||||
Stdio::from(OwnedFd::from(writer))
|
||||
}
|
||||
|
||||
fn run(dir: &Path, args: &[&str], stdout: Stdio) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(dir).args(args).stdout(stdout).stderr(Stdio::piped()).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_commands_or_save() {
|
||||
let dir = Scratch::new("save");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&[
|
||||
"run",
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"First"}"#,
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"Later"}"#,
|
||||
"--print",
|
||||
"--save",
|
||||
"out.deckcraft",
|
||||
],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(out.status.success(), "exit {:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let saved = dir.0.join("out.deckcraft");
|
||||
assert!(saved.is_file(), "requested save was skipped");
|
||||
let info = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).arg("info").arg(saved).output().unwrap();
|
||||
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
|
||||
let data: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
let slides = data["slides"].as_array().unwrap();
|
||||
assert!(slides.iter().any(|slide| slide["title"] == "Later"), "later command did not persist: {data}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_later_command_failure() {
|
||||
let dir = Scratch::new("command-failure");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&["run", "--cmd", r#"slide.new={"title":"First"}"#, "--cmd", r#"slide.go={"index":999}"#, "--print", "--save", "out.deckcraft"],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(!out.status.success(), "a later command failure returned success");
|
||||
assert!(!dir.0.join("out.deckcraft").exists(), "failed run unexpectedly saved");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("no slide 999"), "missing command failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_save_failure() {
|
||||
let dir = Scratch::new("save-failure");
|
||||
fs::create_dir(dir.0.join("out.deckcraft")).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print", "--save", "out.deckcraft"], closed_stdout());
|
||||
assert!(!out.status.success(), "a failed save returned success");
|
||||
assert!(!out.stderr.is_empty(), "missing save failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_rendered_slides() {
|
||||
let dir = Scratch::new("render-all");
|
||||
let baseline = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "normal"], Stdio::piped());
|
||||
assert!(baseline.status.success(), "{}", String::from_utf8_lossy(&baseline.stderr));
|
||||
let closed = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "closed"], closed_stdout());
|
||||
assert!(closed.status.success(), "{}", String::from_utf8_lossy(&closed.stderr));
|
||||
let names = |folder: &str| {
|
||||
let mut files: Vec<_> = fs::read_dir(dir.0.join(folder)).unwrap().map(|entry| entry.unwrap().file_name()).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let expected = names("normal");
|
||||
assert!(expected.len() > 1, "sample must have multiple slides");
|
||||
assert_eq!(names("closed"), expected, "rendering stopped after stdout closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_stdout_write_errors_fail() {
|
||||
let dir = Scratch::new("write-failure");
|
||||
let path = dir.0.join("readonly");
|
||||
fs::write(&path, b"").unwrap();
|
||||
let readonly = File::open(path).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print"], Stdio::from(readonly));
|
||||
assert!(!out.status.success(), "a non-broken stdout error returned success");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout"));
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
mod cli;
|
||||
#[cfg(unix)]
|
||||
mod closed_stdout;
|
||||
mod convert;
|
||||
mod damaged;
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
|
||||
@@ -87,6 +87,45 @@ fn mcp_automation_roots_confine_the_server() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_accepts_multiple_read_and_write_roots() {
|
||||
let base = tmpdir("multiple-roots");
|
||||
let read1 = base.join("read1");
|
||||
let read2 = base.join("read2");
|
||||
let write1 = base.join("write1");
|
||||
let write2 = base.join("write2");
|
||||
for dir in [&read1, &read2, &write1, &write2] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let (input1, input2) = (read1.join("one.txt"), read2.join("two.txt"));
|
||||
std::fs::write(&input1, "one").unwrap();
|
||||
std::fs::write(&input2, "two").unwrap();
|
||||
let (output1, output2) = (write1.join("one.deckcraft"), write2.join("two.deckcraft"));
|
||||
let args = [
|
||||
"--automation-read-root".to_owned(),
|
||||
read1.to_string_lossy().into_owned(),
|
||||
format!("--automation-read-root={}", read2.display()),
|
||||
"--automation-write-root".to_owned(),
|
||||
write1.to_string_lossy().into_owned(),
|
||||
format!("--automation-write-root={}", write2.display()),
|
||||
];
|
||||
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let calls = [
|
||||
("open_presentation", serde_json::json!({"path": input1})),
|
||||
("open_presentation", serde_json::json!({"path": input2})),
|
||||
("save_presentation", serde_json::json!({"path": output1})),
|
||||
("save_presentation", serde_json::json!({"path": output2})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&args, &calls);
|
||||
assert!(success, "{stderr}");
|
||||
for reply in &replies {
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
assert!(output1.exists());
|
||||
assert!(output2.exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
@@ -103,8 +142,12 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let mut full = vec!["mcp", flag, dir, flag];
|
||||
full.push(dir);
|
||||
let out = run(&full);
|
||||
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--sample"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
|
||||
@@ -16,6 +16,10 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -27,4 +31,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
|
||||
//! applying a command the page was told had failed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use deckcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
|
||||
struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
state: Arc<AtomicU8>,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
self.answered = true;
|
||||
Some(reply)
|
||||
}
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "the app dropped the request"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
ControlRequest::abandon(&self.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use deckcraft_ui_egui::{ControlRequest, SlideApp};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{POLL_MS, Pending};
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "deckcraft";
|
||||
|
||||
/// Give the app its control queue and put `window.deckcraft` on the page. An existing
|
||||
/// `window.deckcraft` keeps the functions it already has.
|
||||
pub fn install(app: &mut SlideApp, ctx: egui::Context) {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
app.control_rx = Some(requests);
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
|
||||
if queue.send(control).is_err() {
|
||||
pending.answered = true;
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = pending.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its
|
||||
/// sender gave up.
|
||||
const TAKEN: u8 = 1;
|
||||
const ABANDONED: u8 = 2;
|
||||
|
||||
fn pending() -> (Pending, ControlRequest) {
|
||||
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
|
||||
(Pending { reply, state: control.state.clone(), answered: false }, control)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
|
||||
let (waiting, control) = pending();
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
|
||||
assert!(!control.take(), "the app must not run a request the page was told had failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answered_call_is_not_released() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert!(control.take());
|
||||
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
|
||||
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert_eq!(waiting.poll(), None);
|
||||
drop(control);
|
||||
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! The host bridge, for pages that embed DeckCraft and keep its files (a Nextcloud app, a personal
|
||||
//! file server). The messages and the two transports are `craft-web-host`'s
|
||||
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
|
||||
//! `deckcraft:` prefix. This module is DeckCraft's part of it.
|
||||
//!
|
||||
//! - `?host=parent` (an `<iframe>`) talks to the same-origin parent over `postMessage`;
|
||||
//! `data-host="page"` on the canvas (the page's own document, no iframe) uses DOM
|
||||
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
|
||||
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
|
||||
//! - `deckcraft:ready` (to the host): sent once the app is running and listening; send a file now. DeckCraft
|
||||
//! reads bytes, so it says `file: false` and a host sends `bytes`.
|
||||
//! - `deckcraft:open` (from the host): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
|
||||
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
|
||||
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
|
||||
//! a damaged file open untitled.
|
||||
//! - `deckcraft:save` (to the host): `{ name, saveAs, bytes }` (with the bytes as a transferred `ArrayBuffer` over `postMessage`) for
|
||||
//! every file DeckCraft would otherwise download. The host stores it and reports failures
|
||||
//! itself. `saveAs` says whether the file becomes the presentation's file:
|
||||
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
|
||||
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
|
||||
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
|
||||
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
|
||||
//! Save suggests on the desktop.
|
||||
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
|
||||
//! now that file; the next Save sends its name with `false`.
|
||||
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
|
||||
//! and Print: `false`. They are copies; the presentation's file stays the same.
|
||||
//! - `deckcraft:dirty` whenever unsaved changes appear or go away, `deckcraft:failed` when the
|
||||
//! app couldn't start or refused a file of 2 GiB or larger.
|
||||
//!
|
||||
//! In host mode the app starts with an empty presentation instead of the sample, which gives way to
|
||||
//! the first file the host sends; the host guards leaving the page, and `?author=` names the user
|
||||
//! for comments.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
|
||||
/// The app id in the host messages (`deckcraft:ready`).
|
||||
const APP: &str = "deckcraft";
|
||||
|
||||
/// The name `craft-web-host` gives an open request that carries none (it cleans a host's name and
|
||||
/// falls back to this): no name is no file, so the presentation opens untitled.
|
||||
const NAMELESS: &str = "document";
|
||||
|
||||
/// The largest file a host may send. File › Open on the desktop refuses anything over 2 GiB.
|
||||
/// On wasm32 a `Vec` cannot hold 2 GiB (`isize::MAX` is one byte less), so a file of exactly
|
||||
/// 2 GiB is refused too, before its bytes are copied.
|
||||
const MAX_OPEN_BYTES: f64 = 2_147_483_648.0;
|
||||
|
||||
/// The longest user name taken from `?author=` (the preferences' own limit).
|
||||
const MAX_AUTHOR_CHARS: usize = 256;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
/// Files the host sent (the name, if it sent one), waiting for the next frame.
|
||||
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when DeckCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
let host = Host::connect(APP, canvas)?;
|
||||
// An opaque origin (a file:// page in some browsers) can't be a postMessage target.
|
||||
if host.mode() == craft_web_host::Mode::Parent && web_sys::window()?.location().origin().ok()? == "null" {
|
||||
return None;
|
||||
}
|
||||
Some(Self { host: Rc::new(host), opened: Rc::default() })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the host instead of downloading them, and hear the files
|
||||
/// it sends.
|
||||
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
|
||||
self.listen(ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Exports and Print are copies.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = bridge.post_save(name, bytes, false) {
|
||||
log::error!("couldn't hand {name} to the page: {e}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs new comments.
|
||||
pub fn set_author(app: &mut SlideApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.prefs.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// Before each frame: open the files the host sent.
|
||||
pub fn open_files(&self, app: &mut SlideApp) {
|
||||
let opened = std::mem::take(&mut *self.opened.borrow_mut());
|
||||
for (name, bytes) in opened {
|
||||
app.open_from_host(name.as_deref(), &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// After each frame: tell the host when unsaved changes appear or go away (the crate does not
|
||||
/// repeat a state it already reported).
|
||||
pub fn report_dirty(&self, app: &SlideApp) {
|
||||
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
}
|
||||
|
||||
/// Hear `deckcraft:open` and queue the file for the next frame.
|
||||
fn listen(&self, ctx: egui::Context) {
|
||||
let opened = self.opened.clone();
|
||||
let host = self.host.clone();
|
||||
self.host.listen(move |message| {
|
||||
let Message::Open { name, data } = message else { return };
|
||||
if data.size() >= MAX_OPEN_BYTES {
|
||||
log::warn!("host file {name} is 2 GiB or larger; not opened");
|
||||
let _ = host.post_failed(&format!("{name} is 2 GiB or larger, which DeckCraft's web build cannot open"), None);
|
||||
return;
|
||||
}
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; DeckCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
// No name is no file: the presentation opens untitled, never under an invented name.
|
||||
let name = (name != NAMELESS).then_some(name);
|
||||
opened.borrow_mut().push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,24 @@
|
||||
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
|
||||
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
|
||||
//! that started the pick (a dismissed picker or a closed document drops the file);
|
||||
//! - Save and Export trigger a browser download;
|
||||
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - preferences are not persisted.
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
|
||||
//! empty presentation instead of the sample deck; `?show` starts the slide show.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
|
||||
//! for comments.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
|
||||
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::VecDeque,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
|
||||
const MAX_COMMANDS: usize = 64;
|
||||
const PREFIX: &str = "#deckcraft-presenter=";
|
||||
fn error(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
fn origin() -> Result<String, String> {
|
||||
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
|
||||
}
|
||||
pub fn token() -> Option<String> {
|
||||
let hash = web_sys::window()?.location().hash().ok()?;
|
||||
let token = hash.strip_prefix(PREFIX)?;
|
||||
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
|
||||
}
|
||||
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
|
||||
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
|
||||
}
|
||||
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
|
||||
if text.len() > MAX_MESSAGE {
|
||||
return Err("Presenter message is too large".into());
|
||||
}
|
||||
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
|
||||
}
|
||||
struct State {
|
||||
popup: Option<web_sys::Window>,
|
||||
token: String,
|
||||
commands: VecDeque<Command>,
|
||||
ready: bool,
|
||||
refresh_images: bool,
|
||||
}
|
||||
pub struct BrowserHost {
|
||||
state: Rc<RefCell<State>>,
|
||||
origin: String,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
|
||||
images: DeltaCache,
|
||||
}
|
||||
impl BrowserHost {
|
||||
pub fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let origin = origin()?;
|
||||
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
|
||||
let weak = Rc::downgrade(&state);
|
||||
let expected = origin.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
let Ok(mut state) = state.try_borrow_mut() else { return };
|
||||
let Some(popup) = state.popup.as_ref() else { return };
|
||||
if !same_source(&event, popup, &expected) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
|
||||
return;
|
||||
}
|
||||
match value.get("kind").and_then(|v| v.as_str()) {
|
||||
Some("ready") => {
|
||||
state.ready = true;
|
||||
state.refresh_images = true;
|
||||
}
|
||||
Some("command") if state.commands.len() < MAX_COMMANDS => {
|
||||
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
state.commands.push_back(command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
|
||||
}
|
||||
fn remove_button(&mut self) {
|
||||
if let Some((button, callback)) = self.button.take() {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
fn recovery_button(&mut self) {
|
||||
if self.button.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(body) = document.body() else { return };
|
||||
let Ok(element) = document.create_element("button") else { return };
|
||||
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
|
||||
button.set_text_content(Some("Open presenter window"));
|
||||
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
|
||||
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
|
||||
let button_copy = button.clone();
|
||||
let callback = Closure::new(move |_event: web_sys::Event| {
|
||||
if let Some(state) = weak.upgrade() {
|
||||
match open_popup(&state) {
|
||||
Ok(()) => {
|
||||
button_copy.set_text_content(Some("Opening presenter window…"));
|
||||
}
|
||||
Err(_) => {
|
||||
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
|
||||
self.button = Some((button, callback));
|
||||
} else {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
|
||||
if let Some(popup) = &state.popup
|
||||
&& !popup.closed().unwrap_or(true)
|
||||
{
|
||||
let _ = popup.focus();
|
||||
return Ok(());
|
||||
}
|
||||
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
|
||||
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
|
||||
url.set_hash(&format!("{PREFIX}{token}"));
|
||||
let popup = window
|
||||
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
|
||||
.map_err(error)?
|
||||
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
|
||||
if popup.closed().unwrap_or(true) {
|
||||
return Err("Browser isolation prevented connecting the presenter window".into());
|
||||
}
|
||||
state.popup = Some(popup);
|
||||
state.token = token;
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
Ok(())
|
||||
}
|
||||
impl Host for BrowserHost {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.close();
|
||||
let result = open_popup(&self.state);
|
||||
if result.is_err() {
|
||||
self.recovery_button();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
self.state.borrow_mut().commands.drain(..).collect()
|
||||
}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
if !self.is_open() {
|
||||
self.recovery_button();
|
||||
return;
|
||||
}
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if !state.ready {
|
||||
return;
|
||||
}
|
||||
if let Some(popup) = state.popup.clone() {
|
||||
let token = state.token.clone();
|
||||
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
|
||||
send(
|
||||
&popup,
|
||||
&self.origin,
|
||||
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
|
||||
"currentChanged":current_changed, "nextChanged":next_changed}),
|
||||
)
|
||||
});
|
||||
if sent.is_ok() {
|
||||
state.refresh_images = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(window) = state.popup.take() {
|
||||
let _ = window.close();
|
||||
}
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
state.token.clear();
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserHost {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup {
|
||||
token: String,
|
||||
origin: String,
|
||||
opener: web_sys::Window,
|
||||
snapshot: Rc<RefCell<Option<Snapshot>>>,
|
||||
last_received: Rc<RefCell<f64>>,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
view: View,
|
||||
}
|
||||
impl Popup {
|
||||
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
|
||||
// realms. The browser API supplies a WindowProxy or null, and messages still verify
|
||||
// exact source identity and origin before any data is accepted.
|
||||
let opener = window.opener().map_err(error)?;
|
||||
if opener.is_null() || opener.is_undefined() {
|
||||
return Err("The audience window is unavailable".into());
|
||||
}
|
||||
let opener: web_sys::Window = opener.unchecked_into();
|
||||
let origin = origin()?;
|
||||
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
|
||||
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
|
||||
let received = last_received.clone();
|
||||
let target = snapshot.clone();
|
||||
let expected_origin = origin.clone();
|
||||
let expected_opener = opener.clone();
|
||||
let expected_token = token.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
if !same_source(&event, &expected_opener, &expected_origin) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|
||||
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
|
||||
if let Some(previous) = target.borrow().as_ref() {
|
||||
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.current = previous.current.clone();
|
||||
}
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
if data.slides.is_none() {
|
||||
data.slides = previous.slides.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
if let Some(document) = window.document() {
|
||||
document.set_title("Presenter View — DeckCraft");
|
||||
}
|
||||
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
|
||||
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
|
||||
Ok(popup)
|
||||
}
|
||||
}
|
||||
impl eframe::App for Popup {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let snapshot = self.snapshot.borrow();
|
||||
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
|
||||
if !self.view.prepare(ui.ctx(), notes) {
|
||||
return;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
if self.opener.closed().unwrap_or(true) {
|
||||
ui.heading("The audience window has closed.");
|
||||
return;
|
||||
}
|
||||
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
|
||||
ui.heading("The audience window is not responding.");
|
||||
ui.label("Return to the audience window to resume the connection.");
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
if let Some(snapshot) = snapshot.as_ref() {
|
||||
for command in self.view.ui(ui, snapshot) {
|
||||
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
|
||||
}
|
||||
} else {
|
||||
ui.label("Waiting for the audience window…");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Drop for Popup {
|
||||
fn drop(&mut self) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,18 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing DeckCraft,
|
||||
// opens presentations in it and stores what it saves (`host`); `data-host="page"` on the
|
||||
// canvas opts a page in that runs DeckCraft in its own document. The presenter popup is
|
||||
// never the framed page.
|
||||
let hosted = crate::presenter::token().is_none();
|
||||
let bridge = if hosted { crate::host::Bridge::detect(&canvas) } else { None };
|
||||
if hosted && bridge.is_none() && query_param("host").as_deref() == Some("parent") {
|
||||
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
|
||||
}
|
||||
let app_bridge = bridge.clone();
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
if query_has("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
@@ -39,23 +49,48 @@ pub fn start() {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
if let Some(token) = crate::presenter::token() {
|
||||
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
|
||||
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
if !q.contains("blank") {
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
// In host mode the parent sends the presentation to edit; no sample meanwhile.
|
||||
if app_bridge.is_some() {
|
||||
if let Err(e) = app.start_for_host() {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
} else if !query_has("blank") {
|
||||
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
log::error!("sample deck: {e}");
|
||||
}
|
||||
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
if q.contains("show") {
|
||||
if query_has("show") {
|
||||
app.start_show(0, false);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
crate::api::install(&mut app, cc.egui_ctx.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -69,17 +104,41 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
|
||||
/// `?author=` is free text.
|
||||
fn query_has(key: &str) -> bool {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
app: SlideApp,
|
||||
inbox: Inbox,
|
||||
/// The parent page that keeps the files (`?host=parent`).
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
|
||||
/// page sent first and telling it about unsaved changes after.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.open_files(&mut self.app);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.report_dirty(&self.app);
|
||||
}
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
host.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
@@ -118,7 +177,15 @@ impl eframe::App for WebShell {
|
||||
/// back to `open_async` (browser picker → inbox) and `download`.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
|
||||
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
|
||||
Err(error) => {
|
||||
log::error!("Presenter window: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Services {
|
||||
presenter,
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -14,7 +14,9 @@ workspace = true
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
# `clipboard` is the OS clipboard (Ctrl+C/X/V with other apps). Without it egui simulates one inside
|
||||
# the app; the egui 0.37 migration (#1350) dropped it from this list.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -148,7 +148,19 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// Messages can embed document-controlled text (a part name, a relationship id): control
|
||||
// characters other than tab are escaped and each continuation line is marked, so such text
|
||||
// cannot start a line that looks like a record of its own.
|
||||
let mut text = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => text.push_str("\n | "),
|
||||
'\t' => text.push(c),
|
||||
c if c.is_control() => text.extend(c.escape_default()),
|
||||
c => text.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {text}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -446,8 +458,13 @@ mod tests {
|
||||
),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// A multi-line message keeps its lines, each continuation marked; the record still ends in
|
||||
// exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n | b\n"));
|
||||
// Injected line breaks and escape sequences cannot forge a record.
|
||||
let line = format_line("t", log::Level::Warn, "w", "x", "part a\r\n2026 ERROR [m] y: forged\x1b[2J");
|
||||
assert_eq!(line.matches('\n').count(), 2);
|
||||
assert!(!line.contains('\r') && !line.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -51,6 +51,9 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
// The window is closing for good: a ready update's helper starts now and installs it once
|
||||
// the process has exited.
|
||||
self.0.start_pending_update();
|
||||
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
|
||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
||||
@@ -60,6 +63,8 @@ impl eframe::App for App {
|
||||
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
|
||||
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
|
||||
#[cfg(target_os = "macos")]
|
||||
self.0.cleanup_print_previews();
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +106,13 @@ fn save_prefs(app: &SlideApp) {
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
/// The desktop services. `native_wayland`: the window is a Wayland client (not Xwayland).
|
||||
fn services(native_wayland: bool) -> Services {
|
||||
// On native Wayland, egui-winit owns the clipboard through winit's data device, which carries
|
||||
// text and PNG but no HTML. `pasteboard` (arboard, built for X11 only) would put a copy on the
|
||||
// Xwayland selection instead and skip egui's Wayland claim, so Ctrl+V would read something
|
||||
// else. There, text goes through egui and pictures arrive as `PasteImage` events.
|
||||
let pasteboard = !native_wayland;
|
||||
Services {
|
||||
pick_open: Some(Box::new(|purpose: &str| {
|
||||
let d = rfd::FileDialog::new();
|
||||
@@ -124,9 +135,9 @@ fn services() -> Services {
|
||||
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
clipboard_image: Some(Box::new(pasteboard::image_png)),
|
||||
clipboard_html: Some(Box::new(pasteboard::html)),
|
||||
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
|
||||
clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box<dyn FnMut() -> Option<Vec<u8>>>),
|
||||
clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box<dyn FnMut() -> Option<String>>),
|
||||
clipboard_set_html: pasteboard.then(|| Box::new(pasteboard::set_html) as Box<dyn FnMut(&str, &str) -> bool>),
|
||||
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
|
||||
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
|
||||
.is_none()
|
||||
@@ -135,6 +146,14 @@ fn services() -> Services {
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays winit lists for `window`, counting an output Wayland lists twice once. `None` when it
|
||||
/// lists none: Presenter View then stays as it was.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn display_count(window: &winit::window::Window) -> Option<usize> {
|
||||
let n = window.available_monitors().collect::<std::collections::BTreeSet<_>>().len();
|
||||
(n > 0).then_some(n)
|
||||
}
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.deckcraft";
|
||||
|
||||
const USAGE: &str = "\
|
||||
@@ -187,7 +206,7 @@ fn main() -> eframe::Result {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Session) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(done) = craft_update::self_update::finish(&deckcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("DeckCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -245,6 +264,11 @@ fn main() -> eframe::Result {
|
||||
false
|
||||
}
|
||||
};
|
||||
// `Default` lets winit choose, and winit takes Wayland whenever `WAYLAND_DISPLAY` is set.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let native_wayland = !x11 && std::env::var_os("WAYLAND_DISPLAY").is_some_and(|w| !w.is_empty());
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let native_wayland = false;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
@@ -266,7 +290,12 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let mut app = SlideApp::new(Session::new(), services());
|
||||
let mut app = SlideApp::new(Session::new(), services(native_wayland));
|
||||
// Presenter View asks winit how many displays there are when a show starts.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if let Some(window) = cc.winit_window().cloned() {
|
||||
app.services.displays = Some(Box::new(move || display_count(&window)));
|
||||
}
|
||||
load_prefs(&mut app);
|
||||
app.enable_update_checks();
|
||||
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
|
||||
@@ -333,6 +362,26 @@ mod tests {
|
||||
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
|
||||
}
|
||||
|
||||
/// The egui 0.37 migration (#1350) left `clipboard` out of eframe's features. egui then
|
||||
/// simulated a clipboard inside the app: nothing pasted from other apps, and Ctrl+C/Ctrl+V
|
||||
/// lost text that went out as HTML. Keep the OS clipboard on.
|
||||
#[test]
|
||||
fn eframe_uses_the_os_clipboard() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let eframe = manifest.lines().find(|l| l.starts_with("eframe = ")).unwrap_or_default();
|
||||
assert!(eframe.contains("\"clipboard\""), "eframe must enable the OS clipboard: {eframe}");
|
||||
}
|
||||
|
||||
/// On native Wayland, the X11-only pasteboard would bypass egui's Wayland clipboard; elsewhere
|
||||
/// it carries HTML and pictures beside egui's text.
|
||||
#[test]
|
||||
fn the_pasteboard_stays_off_native_wayland() {
|
||||
let s = services(true);
|
||||
assert!(s.clipboard_html.is_none() && s.clipboard_set_html.is_none() && s.clipboard_image.is_none());
|
||||
let s = services(false);
|
||||
assert!(s.clipboard_html.is_some() && s.clipboard_set_html.is_some() && s.clipboard_image.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_exits_instead_of_opening_a_file() {
|
||||
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -68,3 +65,11 @@ sync_github_name = true
|
||||
|
||||
[people.SofiaAFigueredo]
|
||||
real_name = "Sofia Alves Figueredo"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marshiyar]
|
||||
display_name = "Arshiya"
|
||||
real_name = "Arshiya Rahgozar"
|
||||
sync_github_name = false
|
||||
|
||||
+59
-29
@@ -1,5 +1,7 @@
|
||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
|
||||
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
|
||||
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
|
||||
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
|
||||
/// once; top-level shapes only (groups move whole).
|
||||
///
|
||||
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
|
||||
/// pass: matching costs the sum of the two shape lists, not their product.
|
||||
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
||||
let mut used_a = vec![false; a.shapes.len()];
|
||||
let mut used_b = vec![false; b.shapes.len()];
|
||||
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
|
||||
&|x, y| x.name.starts_with("!!") && x.name == y.name,
|
||||
&|x, y| !x.name.is_empty() && x.name == y.name,
|
||||
&|x, y| {
|
||||
if x.kind_name() != y.kind_name() {
|
||||
return false;
|
||||
// Each shape's text, worked out once rather than inside every comparison.
|
||||
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
|
||||
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
|
||||
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
|
||||
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
|
||||
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
|
||||
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
|
||||
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
|
||||
_ => None,
|
||||
};
|
||||
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
|
||||
for key in passes {
|
||||
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
|
||||
// match, which is the one a search through `b` would find.
|
||||
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
|
||||
for (j, sb) in b.shapes.iter().enumerate() {
|
||||
if used_b.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let tx = text_of(x);
|
||||
!tx.trim().is_empty() && tx == text_of(y)
|
||||
},
|
||||
&|x, y| {
|
||||
x.kind_name() == y.kind_name()
|
||||
&& x.geom.preset_name().is_some()
|
||||
&& x.geom.preset_name() == y.geom.preset_name()
|
||||
&& text_of(x).trim().is_empty()
|
||||
&& text_of(y).trim().is_empty()
|
||||
},
|
||||
];
|
||||
for pass in passes {
|
||||
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
|
||||
index.entry(k).or_default().push_back(j);
|
||||
}
|
||||
}
|
||||
if index.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, sa) in a.shapes.iter().enumerate() {
|
||||
if used_a.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
|
||||
if let Some((j, sb)) = found {
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
|
||||
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
|
||||
let Some(sb) = b.shapes.get(j) else { continue };
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// What one [`morph_pairs`] pass matches shapes on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum MorphKey {
|
||||
Name(String),
|
||||
Text(&'static str, String),
|
||||
Geom(&'static str, String),
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||
let v = a + (b - a) * t;
|
||||
if v.is_finite() {
|
||||
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
f.shapes.push((sh, old));
|
||||
id
|
||||
};
|
||||
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
|
||||
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
|
||||
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
|
||||
for (ia, ib) in &pairs {
|
||||
partner.entry(*ib).or_insert(*ia);
|
||||
}
|
||||
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
|
||||
for x in &a.shapes {
|
||||
a_by_id.entry(x.id).or_insert(x);
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
||||
if !paired_a.contains(&sa.id) {
|
||||
push(sa.clone(), true, 1.0 - s);
|
||||
}
|
||||
}
|
||||
for sb in &b.shapes {
|
||||
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
|
||||
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
|
||||
let Some(sa) = pair else {
|
||||
if !hidden_b(sb.id) {
|
||||
push(sb.clone(), false, s);
|
||||
|
||||
@@ -58,6 +58,9 @@ pub struct ShowState {
|
||||
pub playing: bool,
|
||||
/// On the end screen.
|
||||
pub ended: bool,
|
||||
/// The current slide was reached by going back (← / Backspace), so the show plays the
|
||||
/// transition of the slide it left, in reverse, as PowerPoint does (#1531).
|
||||
pub backward: bool,
|
||||
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
|
||||
active: bool,
|
||||
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
|
||||
@@ -208,6 +211,7 @@ impl ShowState {
|
||||
self.active = true;
|
||||
self.slide = i;
|
||||
self.ended = false;
|
||||
self.backward = false;
|
||||
// Time spent elsewhere never counts for this slide's triggered animations.
|
||||
self.last_now = None;
|
||||
let steps = ShowState::steps(pres, i);
|
||||
@@ -309,6 +313,7 @@ impl ShowState {
|
||||
}
|
||||
self.step = ShowState::steps(pres, self.slide);
|
||||
self.playing = false;
|
||||
self.backward = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
@@ -337,6 +342,7 @@ impl ShowState {
|
||||
self.step = ShowState::steps(pres, i);
|
||||
self.playing = false;
|
||||
}
|
||||
self.backward = true;
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
None => ShowAction::None,
|
||||
|
||||
@@ -763,6 +763,26 @@ fn show_skips_hidden_slides() {
|
||||
assert_eq!(s.goto(&p, 99), ShowAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_remembers_a_slide_was_reached_by_going_back() {
|
||||
// #1531: the UI plays the left slide's transition reversed only for Back.
|
||||
let p = pres(3);
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0));
|
||||
assert!(s.backward);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.goto(&p, 0), ShowAction::Slide(0));
|
||||
assert!(!s.backward, "a jump is not Back");
|
||||
// Back from the end screen shows the last slide again, without a transition to reverse.
|
||||
assert_eq!(s.goto(&p, 2), ShowAction::Slide(2));
|
||||
assert_eq!(s.next(&p), ShowAction::End);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(2));
|
||||
assert!(!s.backward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_loops() {
|
||||
let mut p = pres(3);
|
||||
@@ -1135,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
|
||||
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
|
||||
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
|
||||
// the shape of `b` left over.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_of_large_slides_stays_linear() {
|
||||
let n = 20_000u32;
|
||||
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
|
||||
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
|
||||
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
|
||||
// One named pair at opposite ends.
|
||||
a.shapes[0].name = "Logo".into();
|
||||
b.shapes[n as usize - 1].name = "Logo".into();
|
||||
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
|
||||
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
|
||||
assert_eq!(f.shapes.len(), 2 * n as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_states_of_a_large_build_stay_linear() {
|
||||
let n = 20_000usize;
|
||||
let mut s = slide_with(vec![]);
|
||||
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
|
||||
// One first-level paragraph owning every other paragraph.
|
||||
for p in body.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
}
|
||||
s.shapes[0].text = Some(body);
|
||||
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
s.animations = vec![a];
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
|
||||
for p in 0..n {
|
||||
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
|
||||
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
|
||||
}
|
||||
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
|
||||
}
|
||||
|
||||
+47
-12
@@ -103,6 +103,41 @@ pub struct Timeline {
|
||||
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
|
||||
/// (shape, member paragraph, owner paragraph).
|
||||
groups: Vec<(ShapeId, usize, usize)>,
|
||||
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
|
||||
/// rather than a scan of every effect, group and colour on the slide.
|
||||
index: TimelineIndex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TimelineIndex {
|
||||
/// Effects (indices into `effects`, in order) by target shape and paragraph.
|
||||
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
|
||||
/// Effects (indices into `effects`, in order) by target shape.
|
||||
by_shape: HashMap<ShapeId, Vec<usize>>,
|
||||
/// Owner paragraph of each member paragraph (the first group recorded wins).
|
||||
owner: HashMap<(ShapeId, usize), usize>,
|
||||
/// Groups (member, owner) by shape, in order.
|
||||
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
|
||||
/// Base colour by shape (the first recorded wins).
|
||||
color: HashMap<ShapeId, Rgba>,
|
||||
}
|
||||
|
||||
impl TimelineIndex {
|
||||
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
|
||||
let mut ix = TimelineIndex::default();
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
|
||||
ix.by_shape.entry(e.timing.shape).or_default().push(i);
|
||||
}
|
||||
for &(shape, member, owner) in groups {
|
||||
ix.owner.entry((shape, member)).or_insert(owner);
|
||||
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
|
||||
}
|
||||
for &(shape, c) in colors {
|
||||
ix.color.entry(shape).or_insert(c);
|
||||
}
|
||||
ix
|
||||
}
|
||||
}
|
||||
|
||||
enum Status {
|
||||
@@ -324,6 +359,7 @@ impl Timeline {
|
||||
};
|
||||
tl.colors.push((id, c));
|
||||
}
|
||||
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
|
||||
tl
|
||||
}
|
||||
|
||||
@@ -413,8 +449,12 @@ impl Timeline {
|
||||
|
||||
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
||||
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
|
||||
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
|
||||
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
|
||||
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
|
||||
let owners: HashSet<usize> = v.iter().copied().collect();
|
||||
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
|
||||
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
|
||||
}
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
@@ -422,13 +462,8 @@ impl Timeline {
|
||||
|
||||
/// Shapes with any effect.
|
||||
pub fn animated_shapes(&self) -> Vec<ShapeId> {
|
||||
let mut v: Vec<ShapeId> = Vec::new();
|
||||
for e in &self.effects {
|
||||
if !v.contains(&e.timing.shape) {
|
||||
v.push(e.timing.shape);
|
||||
}
|
||||
}
|
||||
v
|
||||
let mut seen: HashSet<ShapeId> = HashSet::new();
|
||||
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
|
||||
}
|
||||
|
||||
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
|
||||
@@ -456,9 +491,9 @@ impl Timeline {
|
||||
triggers: &[TriggerPlay],
|
||||
stamps: &[StepStamp],
|
||||
) -> AnimState {
|
||||
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
|
||||
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
|
||||
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
|
||||
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
|
||||
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let mut st = AnimState::default();
|
||||
// Initial visibility comes from the first effect in the Animation Pane order.
|
||||
let mut ordered: Vec<&Effect> = mine.collect();
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "deckcraft-av1"
|
||||
description = "Clean-room AV1 decoder (AV1 Bitstream & Decoding Process Specification v1.0.0 with Errata 1)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = ["threads"]
|
||||
threads = ["dep:rayon"]
|
||||
|
||||
[dependencies]
|
||||
deckcraft-bitstream = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rayon = { workspace = true, optional = true }
|
||||
@@ -1,124 +0,0 @@
|
||||
//! MSB-first bit reader for OBU headers and the uncompressed frame header (spec 4.10 descriptors).
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
/// Position in bits.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
BitReader { data, pos: 0 }
|
||||
}
|
||||
|
||||
/// `get_position()`: bits consumed.
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
pub fn byte_pos(&self) -> usize {
|
||||
self.pos.div_ceil(8)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn bit(&mut self) -> Result<u32> {
|
||||
let byte = *self.data.get(self.pos >> 3).ok_or(Error::Truncated)?;
|
||||
let b = (byte >> (7 - (self.pos & 7))) & 1;
|
||||
self.pos += 1;
|
||||
Ok(b as u32)
|
||||
}
|
||||
|
||||
/// `f(n)`, n ≤ 32.
|
||||
pub fn f(&mut self, n: u32) -> Result<u32> {
|
||||
let mut x: u64 = 0;
|
||||
for _ in 0..n {
|
||||
x = (x << 1) | self.bit()? as u64;
|
||||
}
|
||||
Ok(x as u32)
|
||||
}
|
||||
|
||||
pub fn flag(&mut self) -> Result<bool> {
|
||||
Ok(self.bit()? == 1)
|
||||
}
|
||||
|
||||
/// `uvlc()`
|
||||
pub fn uvlc(&mut self) -> Result<u32> {
|
||||
let mut leading = 0u32;
|
||||
while self.bit()? == 0 {
|
||||
leading += 1;
|
||||
if leading > 40 {
|
||||
return Err(Error::Invalid("uvlc"));
|
||||
}
|
||||
}
|
||||
if leading >= 32 {
|
||||
return Ok(u32::MAX);
|
||||
}
|
||||
let v = self.f(leading)?;
|
||||
Ok(v + ((1u64 << leading) - 1) as u32)
|
||||
}
|
||||
|
||||
/// `le(n)`
|
||||
pub fn le(&mut self, n: u32) -> Result<u32> {
|
||||
let mut t = 0u32;
|
||||
for i in 0..n {
|
||||
t |= self.f(8)? << (8 * i);
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// `leb128()`
|
||||
pub fn leb128(&mut self) -> Result<u64> {
|
||||
let mut v = 0u64;
|
||||
for i in 0..8 {
|
||||
let b = self.f(8)? as u64;
|
||||
v |= (b & 0x7f) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// `su(n)`
|
||||
pub fn su(&mut self, n: u32) -> Result<i32> {
|
||||
let v = self.f(n)? as i64;
|
||||
let sign = 1i64 << (n - 1);
|
||||
Ok(if v & sign != 0 { (v - 2 * sign) as i32 } else { v as i32 })
|
||||
}
|
||||
|
||||
/// `ns(n)`
|
||||
pub fn ns(&mut self, n: u32) -> Result<u32> {
|
||||
if n <= 1 {
|
||||
return Ok(0);
|
||||
}
|
||||
let w = 32 - n.leading_zeros(); // FloorLog2(n) + 1
|
||||
let m = (1u32 << w) - n;
|
||||
let v = self.f(w - 1)?;
|
||||
if v < m {
|
||||
return Ok(v);
|
||||
}
|
||||
let extra = self.bit()?;
|
||||
Ok((v << 1) - m + extra)
|
||||
}
|
||||
|
||||
/// `byte_alignment()`
|
||||
pub fn byte_align(&mut self) {
|
||||
self.pos = self.pos.div_ceil(8) * 8;
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a leb128 from a byte slice; returns (value, bytes used).
|
||||
pub(crate) fn leb128(data: &[u8]) -> Result<(u64, usize)> {
|
||||
let mut v = 0u64;
|
||||
for i in 0..8 {
|
||||
let b = *data.get(i).ok_or(Error::Truncated)? as u64;
|
||||
v |= (b & 0x7f) << (7 * i);
|
||||
if b & 0x80 == 0 {
|
||||
return Ok((v, i + 1));
|
||||
}
|
||||
}
|
||||
Ok((v, 8))
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
|
||||
/// equals CurrFrame).
|
||||
pub(crate) fn apply(fs: &FrameState) -> Option<FrameBuf> {
|
||||
let fh = &fs.fh;
|
||||
if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef {
|
||||
return None;
|
||||
}
|
||||
let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize);
|
||||
let mut out = fs.cur.clone();
|
||||
let mut any = false;
|
||||
let mut r = 0;
|
||||
while r < mi_rows {
|
||||
let mut c = 0;
|
||||
while c < mi_cols {
|
||||
let idx = fs.cdef_idx(r & !15, c & !15);
|
||||
if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) {
|
||||
any = true;
|
||||
}
|
||||
c += 2;
|
||||
}
|
||||
r += 2;
|
||||
}
|
||||
any.then_some(out)
|
||||
}
|
||||
|
||||
fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool {
|
||||
let mi = &fs.mi;
|
||||
let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } };
|
||||
let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1);
|
||||
if skip {
|
||||
return false;
|
||||
}
|
||||
let coeff_shift = fs.bit_depth - 8;
|
||||
let (y_dir, var) = direction(fs, r, c);
|
||||
let cp = &fs.fh.cdef;
|
||||
let mut pri = (cp.y_pri[idx] << coeff_shift) as i32;
|
||||
let sec = (cp.y_sec[idx] << coeff_shift) as i32;
|
||||
let dir = if pri == 0 { 0 } else { y_dir };
|
||||
let var_str = if (var >> 6) != 0 { (31 - ((var >> 6) as u32).leading_zeros()).min(12) as i32 } else { 0 };
|
||||
pri = if var != 0 { (pri * (4 + var_str) + 8) >> 4 } else { 0 };
|
||||
let damping = cp.damping as i32 + coeff_shift as i32;
|
||||
filter(fs, out, 0, (r, c), (pri, sec), damping, dir);
|
||||
if fs.num_planes == 1 {
|
||||
return true;
|
||||
}
|
||||
let pri = (cp.uv_pri[idx] << coeff_shift) as i32;
|
||||
let sec = (cp.uv_sec[idx] << coeff_shift) as i32;
|
||||
let dir = if pri == 0 { 0 } else { CDEF_UV_DIR[fs.ssx][fs.ssy][y_dir] as usize };
|
||||
let damping = cp.damping as i32 + coeff_shift as i32 - 1;
|
||||
filter(fs, out, 1, (r, c), (pri, sec), damping, dir);
|
||||
filter(fs, out, 2, (r, c), (pri, sec), damping, dir);
|
||||
true
|
||||
}
|
||||
|
||||
fn direction(fs: &FrameState, r: usize, c: usize) -> (usize, i32) {
|
||||
let mut cost = [0i32; 8];
|
||||
let mut partial = [[0i32; 15]; 8];
|
||||
let x0 = c << 2;
|
||||
let y0 = r << 2;
|
||||
let pl = &fs.cur.planes[0];
|
||||
let sh = fs.bit_depth - 8;
|
||||
for i in 0..8 {
|
||||
let row = pl.row(y0 + i);
|
||||
for j in 0..8 {
|
||||
let x = (row[x0 + j] as i32 >> sh) - 128;
|
||||
partial[0][i + j] += x;
|
||||
partial[1][i + j / 2] += x;
|
||||
partial[2][i] += x;
|
||||
partial[3][3 + i - j / 2] += x;
|
||||
partial[4][7 + i - j] += x;
|
||||
partial[5][3 - i / 2 + j] += x;
|
||||
partial[6][j] += x;
|
||||
partial[7][i / 2 + j] += x;
|
||||
}
|
||||
}
|
||||
for i in 0..8 {
|
||||
cost[2] += partial[2][i] * partial[2][i];
|
||||
cost[6] += partial[6][i] * partial[6][i];
|
||||
}
|
||||
cost[2] *= DIV_TABLE[8] as i32;
|
||||
cost[6] *= DIV_TABLE[8] as i32;
|
||||
for i in 0..7 {
|
||||
cost[0] += (partial[0][i] * partial[0][i] + partial[0][14 - i] * partial[0][14 - i]) * DIV_TABLE[i + 1] as i32;
|
||||
cost[4] += (partial[4][i] * partial[4][i] + partial[4][14 - i] * partial[4][14 - i]) * DIV_TABLE[i + 1] as i32;
|
||||
}
|
||||
cost[0] += partial[0][7] * partial[0][7] * DIV_TABLE[8] as i32;
|
||||
cost[4] += partial[4][7] * partial[4][7] * DIV_TABLE[8] as i32;
|
||||
let mut i = 1;
|
||||
while i < 8 {
|
||||
for j in 0..5 {
|
||||
cost[i] += partial[i][3 + j] * partial[i][3 + j];
|
||||
}
|
||||
cost[i] *= DIV_TABLE[8] as i32;
|
||||
for j in 0..3 {
|
||||
cost[i] += (partial[i][j] * partial[i][j] + partial[i][10 - j] * partial[i][10 - j]) * DIV_TABLE[2 * j + 2] as i32;
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
let mut best = 0;
|
||||
let mut y_dir = 0;
|
||||
for (i, &c) in cost.iter().enumerate() {
|
||||
if c > best {
|
||||
best = c;
|
||||
y_dir = i;
|
||||
}
|
||||
}
|
||||
(y_dir, (best - cost[(y_dir + 4) & 7]) >> 10)
|
||||
}
|
||||
|
||||
/// Marks a sample outside the frame (CdefAvailable = 0) in the padded block.
|
||||
const CDEF_NA: i32 = i32::MIN;
|
||||
/// Padded block: 8x8 plus 2 samples on every side, row stride 12.
|
||||
const PAD_STRIDE: usize = 12;
|
||||
|
||||
#[inline(always)]
|
||||
fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
|
||||
let mag = (threshold - (diff.abs() >> adj)).clamp(0, diff.abs());
|
||||
if diff < 0 { -mag } else { mag }
|
||||
}
|
||||
|
||||
/// Tap offsets, weights and strengths of one block.
|
||||
struct Taps {
|
||||
pri: i32,
|
||||
sec: i32,
|
||||
pri_adj: u32,
|
||||
sec_adj: u32,
|
||||
pri_off: [isize; 2],
|
||||
sec_off: [[isize; 2]; 2],
|
||||
pri_taps: [i32; 2],
|
||||
sec_taps: [i32; 2],
|
||||
}
|
||||
|
||||
impl Taps {
|
||||
fn new(coeff_shift: u32, (pri, sec): (i32, i32), damping: i32, dir: usize) -> Taps {
|
||||
let pri_tap = ((pri >> coeff_shift) & 1) as usize;
|
||||
let off = |d: usize, k: usize| CDEF_DIRECTIONS[d][k][0] as isize * PAD_STRIDE as isize + CDEF_DIRECTIONS[d][k][1] as isize;
|
||||
let pri_off = [off(dir, 0), off(dir, 1)];
|
||||
let d_lo = (dir + 6) & 7;
|
||||
let d_hi = (dir + 2) & 7;
|
||||
let sec_off = [[off(d_lo, 0), off(d_hi, 0)], [off(d_lo, 1), off(d_hi, 1)]];
|
||||
let pri_taps = [CDEF_PRI_TAPS[pri_tap][0] as i32, CDEF_PRI_TAPS[pri_tap][1] as i32];
|
||||
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
|
||||
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
|
||||
Taps { pri, sec, pri_adj: adj(pri), sec_adj: adj(sec), pri_off, sec_off, pri_taps, sec_taps }
|
||||
}
|
||||
|
||||
/// The 12 (offset, weight, strength, adjustment) taps in filtering order.
|
||||
fn tap_list(&self) -> [(isize, i32, i32, u32); 12] {
|
||||
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
|
||||
let mut n = 0;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
taps[n] = (sign * self.pri_off[k], self.pri_taps[k], self.pri, self.pri_adj);
|
||||
taps[n + 1] = (sign * self.sec_off[k][0], self.sec_taps[k], self.sec, self.sec_adj);
|
||||
taps[n + 2] = (sign * self.sec_off[k][1], self.sec_taps[k], self.sec, self.sec_adj);
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
taps
|
||||
}
|
||||
}
|
||||
|
||||
/// Position and size of a block within its plane.
|
||||
#[derive(Clone, Copy)]
|
||||
struct BlockRect {
|
||||
x0: usize,
|
||||
y0: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
}
|
||||
|
||||
/// The block with two samples of padding on every side, plus whether every sample is available
|
||||
/// (CdefAvailable: its 4x4 block is inside the frame).
|
||||
fn padded_block(fs: &FrameState, plane: usize, src: &Plane, rect: BlockRect) -> ([i32; PAD_STRIDE * PAD_STRIDE], bool) {
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
|
||||
let (mi_rows, mi_cols) = (fs.fh.mi_rows as isize, fs.fh.mi_cols as isize);
|
||||
let mut buf = [CDEF_NA; PAD_STRIDE * PAD_STRIDE];
|
||||
let mut all_avail = true;
|
||||
for i in 0..rect.h + 4 {
|
||||
let y = (rect.y0 + i) as isize - 2;
|
||||
let cand_r = (y << sub_y) >> 2;
|
||||
if y < 0 || cand_r >= mi_rows {
|
||||
all_avail = false;
|
||||
continue;
|
||||
}
|
||||
let row = src.row(y as usize);
|
||||
for j in 0..rect.w + 4 {
|
||||
let x = (rect.x0 + j) as isize - 2;
|
||||
let cand_c = (x << sub_x) >> 2;
|
||||
if x >= 0 && cand_c < mi_cols {
|
||||
buf[i * PAD_STRIDE + j] = row[x as usize] as i32;
|
||||
} else {
|
||||
all_avail = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
(buf, all_avail)
|
||||
}
|
||||
|
||||
/// Filters one sample whose taps may be unavailable (CDEF_NA).
|
||||
fn filter_sample(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &Taps, ci: isize, x: i32) -> u16 {
|
||||
let mut sum = 0i32;
|
||||
let mut max = x;
|
||||
let mut min = x;
|
||||
for k in 0..2 {
|
||||
for sign in [-1isize, 1] {
|
||||
let p = buf[(ci + sign * taps.pri_off[k]) as usize];
|
||||
if p != CDEF_NA {
|
||||
if taps.pri != 0 {
|
||||
sum += taps.pri_taps[k] * constrain(p - x, taps.pri, taps.pri_adj);
|
||||
}
|
||||
max = max.max(p);
|
||||
min = min.min(p);
|
||||
}
|
||||
for s in 0..2 {
|
||||
let q = buf[(ci + sign * taps.sec_off[k][s]) as usize];
|
||||
if q != CDEF_NA {
|
||||
if taps.sec != 0 {
|
||||
sum += taps.sec_taps[k] * constrain(q - x, taps.sec, taps.sec_adj);
|
||||
}
|
||||
max = max.max(q);
|
||||
min = min.min(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(x + ((8 + sum - (sum < 0) as i32) >> 4)).clamp(min, max) as u16
|
||||
}
|
||||
|
||||
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, (r, c): (usize, usize), strengths: (i32, i32), damping: i32, dir: usize) {
|
||||
let (pri, sec) = strengths;
|
||||
if pri == 0 && sec == 0 {
|
||||
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
|
||||
return;
|
||||
}
|
||||
let coeff_shift = fs.bit_depth - 8;
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
|
||||
let rect = BlockRect { x0: (c * 4) >> sub_x, y0: (r * 4) >> sub_y, w: 8 >> sub_x, h: 8 >> sub_y };
|
||||
let src = &fs.cur.planes[plane];
|
||||
let (buf, all_avail) = padded_block(fs, plane, src, rect);
|
||||
let taps = Taps::new(coeff_shift, strengths, damping, dir);
|
||||
let dst = &mut out.planes[plane];
|
||||
if all_avail {
|
||||
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
|
||||
let list = taps.tap_list();
|
||||
let rows = (rect.y0, rect.x0, rect.h);
|
||||
match rect.w {
|
||||
8 => filter_rows::<8>(&buf, &list, dst, rows),
|
||||
_ => filter_rows::<4>(&buf, &list, dst, rows),
|
||||
}
|
||||
return;
|
||||
}
|
||||
for i in 0..rect.h {
|
||||
// The centre sample is read even where CdefAvailable is 0 (8x8 blocks straddling an odd
|
||||
// MiRows / MiCols edge).
|
||||
let srow = &src.row(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
|
||||
let drow = &mut dst.row_mut(rect.y0 + i)[rect.x0..rect.x0 + rect.w];
|
||||
for j in 0..rect.w {
|
||||
let ci = ((i + 2) * PAD_STRIDE + j + 2) as isize;
|
||||
drow[j] = filter_sample(&buf, &taps, ci, srow[j] as i32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12
|
||||
/// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width.
|
||||
#[inline(always)]
|
||||
fn filter_rows<const W: usize>(
|
||||
buf: &[i32; PAD_STRIDE * PAD_STRIDE],
|
||||
taps: &[(isize, i32, i32, u32); 12],
|
||||
dst: &mut Plane,
|
||||
(y0, x0, h): (usize, usize, usize),
|
||||
) {
|
||||
let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE];
|
||||
for (d, &s) in b16.iter_mut().zip(buf.iter()) {
|
||||
*d = s as i16;
|
||||
}
|
||||
for i in 0..h {
|
||||
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
|
||||
for &(off, weight, thr, adj) in taps {
|
||||
let (weight, thr) = (weight as i16, thr as i16);
|
||||
let start = (ci + off) as usize;
|
||||
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
// constrain(): .max / .min instead of clamp (whose bound check panics), and
|
||||
// the sign applied branch-free, so the loop vectorises.
|
||||
let d = p[j] - x[j];
|
||||
let ad = d.abs();
|
||||
let mag = (thr - (ad >> adj)).max(0).min(ad);
|
||||
let sign = d >> 15;
|
||||
sum[j] += weight * ((mag ^ sign) - sign);
|
||||
max[j] = max[j].max(p[j]);
|
||||
min[j] = min[j].min(p[j]);
|
||||
}
|
||||
}
|
||||
let drow = &mut dst.row_mut(y0 + i)[x0..x0 + W];
|
||||
for j in 0..W {
|
||||
let s = sum[j];
|
||||
drow[j] = (x[j] + ((8 + s - (s < 0) as i16) >> 4)).max(min[j]).min(max[j]) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
|
||||
|
||||
use crate::spec_tables::{
|
||||
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
|
||||
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
|
||||
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
|
||||
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
|
||||
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
|
||||
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
|
||||
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
|
||||
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
|
||||
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
|
||||
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
|
||||
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
|
||||
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
|
||||
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
|
||||
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
|
||||
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
|
||||
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
|
||||
};
|
||||
|
||||
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
|
||||
/// N cumulative values (the last is 32768) plus the adaptation counter.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Cdfs {
|
||||
pub y_mode: [[u16; 14]; 4],
|
||||
pub uv_mode_cfl_not_allowed: [[u16; 14]; 13],
|
||||
pub uv_mode_cfl_allowed: [[u16; 15]; 13],
|
||||
pub angle_delta: [[u16; 8]; 8],
|
||||
pub intrabc: [u16; 3],
|
||||
pub partition_w8: [[u16; 5]; 4],
|
||||
pub partition_w16: [[u16; 11]; 4],
|
||||
pub partition_w32: [[u16; 11]; 4],
|
||||
pub partition_w64: [[u16; 11]; 4],
|
||||
pub partition_w128: [[u16; 9]; 4],
|
||||
pub segment_id: [[u16; 9]; 3],
|
||||
pub segment_id_predicted: [[u16; 3]; 3],
|
||||
pub tx_8x8: [[u16; 3]; 3],
|
||||
pub tx_16x16: [[u16; 4]; 3],
|
||||
pub tx_32x32: [[u16; 4]; 3],
|
||||
pub tx_64x64: [[u16; 4]; 3],
|
||||
pub txfm_split: [[u16; 3]; 21],
|
||||
pub filter_intra_mode: [u16; 6],
|
||||
pub filter_intra: [[u16; 3]; 22],
|
||||
pub interp_filter: [[u16; 4]; 16],
|
||||
pub motion_mode: [[u16; 4]; 22],
|
||||
pub new_mv: [[u16; 3]; 6],
|
||||
pub zero_mv: [[u16; 3]; 2],
|
||||
pub ref_mv: [[u16; 3]; 6],
|
||||
pub compound_mode: [[u16; 9]; 8],
|
||||
pub drl_mode: [[u16; 3]; 3],
|
||||
pub is_inter: [[u16; 3]; 4],
|
||||
pub comp_mode: [[u16; 3]; 5],
|
||||
pub skip_mode: [[u16; 3]; 3],
|
||||
pub skip: [[u16; 3]; 3],
|
||||
pub comp_ref: [[[u16; 3]; 3]; 3],
|
||||
pub comp_bwd_ref: [[[u16; 3]; 2]; 3],
|
||||
pub single_ref: [[[u16; 3]; 6]; 3],
|
||||
pub mv_joint: [[u16; 5]; MV_CONTEXTS],
|
||||
pub mv_class: [[[u16; 12]; 2]; MV_CONTEXTS],
|
||||
pub mv_class0_bit: [[[u16; 3]; 2]; MV_CONTEXTS],
|
||||
pub mv_fr: [[[u16; 5]; 2]; MV_CONTEXTS],
|
||||
pub mv_class0_fr: [[[[u16; 5]; 2]; 2]; MV_CONTEXTS],
|
||||
pub mv_class0_hp: [[[u16; 3]; 2]; MV_CONTEXTS],
|
||||
pub mv_sign: [[[u16; 3]; 2]; MV_CONTEXTS],
|
||||
pub mv_bit: [[[[u16; 3]; 10]; 2]; MV_CONTEXTS],
|
||||
pub mv_hp: [[[u16; 3]; 2]; MV_CONTEXTS],
|
||||
pub palette_y_mode: [[[u16; 3]; 3]; 7],
|
||||
pub palette_uv_mode: [[u16; 3]; 2],
|
||||
pub palette_y_size: [[u16; 8]; 7],
|
||||
pub palette_uv_size: [[u16; 8]; 7],
|
||||
pub palette_2_y_color: [[u16; 3]; 5],
|
||||
pub palette_2_uv_color: [[u16; 3]; 5],
|
||||
pub palette_3_y_color: [[u16; 4]; 5],
|
||||
pub palette_3_uv_color: [[u16; 4]; 5],
|
||||
pub palette_4_y_color: [[u16; 5]; 5],
|
||||
pub palette_4_uv_color: [[u16; 5]; 5],
|
||||
pub palette_5_y_color: [[u16; 6]; 5],
|
||||
pub palette_5_uv_color: [[u16; 6]; 5],
|
||||
pub palette_6_y_color: [[u16; 7]; 5],
|
||||
pub palette_6_uv_color: [[u16; 7]; 5],
|
||||
pub palette_7_y_color: [[u16; 8]; 5],
|
||||
pub palette_7_uv_color: [[u16; 8]; 5],
|
||||
pub palette_8_y_color: [[u16; 9]; 5],
|
||||
pub palette_8_uv_color: [[u16; 9]; 5],
|
||||
pub delta_q: [u16; 5],
|
||||
pub delta_lf: [u16; 5],
|
||||
pub delta_lf_multi: [[u16; 5]; FRAME_LF_COUNT],
|
||||
pub intra_tx_type_set1: [[[u16; 8]; 13]; 2],
|
||||
pub intra_tx_type_set2: [[[u16; 6]; 13]; 3],
|
||||
pub inter_tx_type_set1: [[u16; 17]; 2],
|
||||
pub inter_tx_type_set2: [u16; 13],
|
||||
pub inter_tx_type_set3: [[u16; 3]; 4],
|
||||
pub use_obmc: [[u16; 3]; 22],
|
||||
pub inter_intra: [[u16; 3]; 3],
|
||||
pub comp_ref_type: [[u16; 3]; 5],
|
||||
pub cfl_sign: [u16; 9],
|
||||
pub uni_comp_ref: [[[u16; 3]; 3]; 3],
|
||||
pub wedge_inter_intra: [[u16; 3]; 22],
|
||||
pub comp_group_idx: [[u16; 3]; 6],
|
||||
pub compound_idx: [[u16; 3]; 6],
|
||||
pub compound_type: [[u16; 3]; 22],
|
||||
pub inter_intra_mode: [[u16; 5]; 3],
|
||||
pub wedge_index: [[u16; 17]; 22],
|
||||
pub cfl_alpha: [[u16; 17]; 6],
|
||||
pub use_wiener: [u16; 3],
|
||||
pub use_sgrproj: [u16; 3],
|
||||
pub restoration_type: [u16; 4],
|
||||
// coefficient CDFs
|
||||
pub txb_skip: [[[u16; 3]; 13]; 5],
|
||||
pub eob_pt_16: [[[u16; 6]; 2]; 2],
|
||||
pub eob_pt_32: [[[u16; 7]; 2]; 2],
|
||||
pub eob_pt_64: [[[u16; 8]; 2]; 2],
|
||||
pub eob_pt_128: [[[u16; 9]; 2]; 2],
|
||||
pub eob_pt_256: [[[u16; 10]; 2]; 2],
|
||||
pub eob_pt_512: [[u16; 11]; 2],
|
||||
pub eob_pt_1024: [[u16; 12]; 2],
|
||||
pub eob_extra: [[[[u16; 3]; 9]; 2]; 5],
|
||||
pub dc_sign: [[[u16; 3]; 3]; 2],
|
||||
pub coeff_base_eob: [[[[u16; 4]; 4]; 2]; 5],
|
||||
pub coeff_base: [[[[u16; 5]; 42]; 2]; 5],
|
||||
pub coeff_br: [[[[u16; 5]; 21]; 2]; 5],
|
||||
}
|
||||
|
||||
impl Cdfs {
|
||||
/// init_non_coeff_cdfs( ) followed by init_coeff_cdfs( ) for `base_q_idx`.
|
||||
pub fn new(base_q_idx: u32) -> Box<Cdfs> {
|
||||
let mut c = Box::new(Cdfs {
|
||||
y_mode: DEFAULT_Y_MODE_CDF,
|
||||
uv_mode_cfl_not_allowed: DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF,
|
||||
uv_mode_cfl_allowed: DEFAULT_UV_MODE_CFL_ALLOWED_CDF,
|
||||
angle_delta: DEFAULT_ANGLE_DELTA_CDF,
|
||||
intrabc: DEFAULT_INTRABC_CDF,
|
||||
partition_w8: DEFAULT_PARTITION_W8_CDF,
|
||||
partition_w16: DEFAULT_PARTITION_W16_CDF,
|
||||
partition_w32: DEFAULT_PARTITION_W32_CDF,
|
||||
partition_w64: DEFAULT_PARTITION_W64_CDF,
|
||||
partition_w128: DEFAULT_PARTITION_W128_CDF,
|
||||
segment_id: DEFAULT_SEGMENT_ID_CDF,
|
||||
segment_id_predicted: DEFAULT_SEGMENT_ID_PREDICTED_CDF,
|
||||
tx_8x8: DEFAULT_TX_8X8_CDF,
|
||||
tx_16x16: DEFAULT_TX_16X16_CDF,
|
||||
tx_32x32: DEFAULT_TX_32X32_CDF,
|
||||
tx_64x64: DEFAULT_TX_64X64_CDF,
|
||||
txfm_split: DEFAULT_TXFM_SPLIT_CDF,
|
||||
filter_intra_mode: DEFAULT_FILTER_INTRA_MODE_CDF,
|
||||
filter_intra: DEFAULT_FILTER_INTRA_CDF,
|
||||
interp_filter: DEFAULT_INTERP_FILTER_CDF,
|
||||
motion_mode: DEFAULT_MOTION_MODE_CDF,
|
||||
new_mv: DEFAULT_NEW_MV_CDF,
|
||||
zero_mv: DEFAULT_ZERO_MV_CDF,
|
||||
ref_mv: DEFAULT_REF_MV_CDF,
|
||||
compound_mode: DEFAULT_COMPOUND_MODE_CDF,
|
||||
drl_mode: DEFAULT_DRL_MODE_CDF,
|
||||
is_inter: DEFAULT_IS_INTER_CDF,
|
||||
comp_mode: DEFAULT_COMP_MODE_CDF,
|
||||
skip_mode: DEFAULT_SKIP_MODE_CDF,
|
||||
skip: DEFAULT_SKIP_CDF,
|
||||
comp_ref: DEFAULT_COMP_REF_CDF,
|
||||
comp_bwd_ref: DEFAULT_COMP_BWD_REF_CDF,
|
||||
single_ref: DEFAULT_SINGLE_REF_CDF,
|
||||
mv_joint: [DEFAULT_MV_JOINT_CDF; MV_CONTEXTS],
|
||||
mv_class: [DEFAULT_MV_CLASS_CDF; MV_CONTEXTS],
|
||||
mv_class0_bit: [[DEFAULT_MV_CLASS0_BIT_CDF; 2]; MV_CONTEXTS],
|
||||
mv_fr: [DEFAULT_MV_FR_CDF; MV_CONTEXTS],
|
||||
mv_class0_fr: [DEFAULT_MV_CLASS0_FR_CDF; MV_CONTEXTS],
|
||||
mv_class0_hp: [[DEFAULT_MV_CLASS0_HP_CDF; 2]; MV_CONTEXTS],
|
||||
mv_sign: [[DEFAULT_MV_SIGN_CDF; 2]; MV_CONTEXTS],
|
||||
mv_bit: [[DEFAULT_MV_BIT_CDF; 2]; MV_CONTEXTS],
|
||||
mv_hp: [[DEFAULT_MV_HP_CDF; 2]; MV_CONTEXTS],
|
||||
palette_y_mode: DEFAULT_PALETTE_Y_MODE_CDF,
|
||||
palette_uv_mode: DEFAULT_PALETTE_UV_MODE_CDF,
|
||||
palette_y_size: DEFAULT_PALETTE_Y_SIZE_CDF,
|
||||
palette_uv_size: DEFAULT_PALETTE_UV_SIZE_CDF,
|
||||
palette_2_y_color: DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF,
|
||||
palette_2_uv_color: DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF,
|
||||
palette_3_y_color: DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
|
||||
palette_3_uv_color: DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF,
|
||||
palette_4_y_color: DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF,
|
||||
palette_4_uv_color: DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF,
|
||||
palette_5_y_color: DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
|
||||
palette_5_uv_color: DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF,
|
||||
palette_6_y_color: DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF,
|
||||
palette_6_uv_color: DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF,
|
||||
palette_7_y_color: DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
|
||||
palette_7_uv_color: DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF,
|
||||
palette_8_y_color: DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF,
|
||||
palette_8_uv_color: DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF,
|
||||
delta_q: DEFAULT_DELTA_Q_CDF,
|
||||
delta_lf: DEFAULT_DELTA_LF_CDF,
|
||||
delta_lf_multi: [DEFAULT_DELTA_LF_CDF; FRAME_LF_COUNT],
|
||||
intra_tx_type_set1: DEFAULT_INTRA_TX_TYPE_SET1_CDF,
|
||||
intra_tx_type_set2: DEFAULT_INTRA_TX_TYPE_SET2_CDF,
|
||||
inter_tx_type_set1: DEFAULT_INTER_TX_TYPE_SET1_CDF,
|
||||
inter_tx_type_set2: DEFAULT_INTER_TX_TYPE_SET2_CDF,
|
||||
inter_tx_type_set3: DEFAULT_INTER_TX_TYPE_SET3_CDF,
|
||||
use_obmc: DEFAULT_USE_OBMC_CDF,
|
||||
inter_intra: DEFAULT_INTER_INTRA_CDF,
|
||||
comp_ref_type: DEFAULT_COMP_REF_TYPE_CDF,
|
||||
cfl_sign: DEFAULT_CFL_SIGN_CDF,
|
||||
uni_comp_ref: DEFAULT_UNI_COMP_REF_CDF,
|
||||
wedge_inter_intra: DEFAULT_WEDGE_INTER_INTRA_CDF,
|
||||
comp_group_idx: DEFAULT_COMP_GROUP_IDX_CDF,
|
||||
compound_idx: DEFAULT_COMPOUND_IDX_CDF,
|
||||
compound_type: DEFAULT_COMPOUND_TYPE_CDF,
|
||||
inter_intra_mode: DEFAULT_INTER_INTRA_MODE_CDF,
|
||||
wedge_index: DEFAULT_WEDGE_INDEX_CDF,
|
||||
cfl_alpha: DEFAULT_CFL_ALPHA_CDF,
|
||||
use_wiener: DEFAULT_USE_WIENER_CDF,
|
||||
use_sgrproj: DEFAULT_USE_SGRPROJ_CDF,
|
||||
restoration_type: DEFAULT_RESTORATION_TYPE_CDF,
|
||||
txb_skip: DEFAULT_TXB_SKIP_CDF[0],
|
||||
eob_pt_16: DEFAULT_EOB_PT_16_CDF[0],
|
||||
eob_pt_32: DEFAULT_EOB_PT_32_CDF[0],
|
||||
eob_pt_64: DEFAULT_EOB_PT_64_CDF[0],
|
||||
eob_pt_128: DEFAULT_EOB_PT_128_CDF[0],
|
||||
eob_pt_256: DEFAULT_EOB_PT_256_CDF[0],
|
||||
eob_pt_512: DEFAULT_EOB_PT_512_CDF[0],
|
||||
eob_pt_1024: DEFAULT_EOB_PT_1024_CDF[0],
|
||||
eob_extra: DEFAULT_EOB_EXTRA_CDF[0],
|
||||
dc_sign: DEFAULT_DC_SIGN_CDF[0],
|
||||
coeff_base_eob: DEFAULT_COEFF_BASE_EOB_CDF[0],
|
||||
coeff_base: DEFAULT_COEFF_BASE_CDF[0],
|
||||
coeff_br: DEFAULT_COEFF_BR_CDF[0],
|
||||
});
|
||||
c.init_coeff(base_q_idx);
|
||||
c
|
||||
}
|
||||
|
||||
/// init_coeff_cdfs( )
|
||||
pub fn init_coeff(&mut self, base_q_idx: u32) {
|
||||
let idx = match base_q_idx {
|
||||
0..=20 => 0,
|
||||
21..=60 => 1,
|
||||
61..=120 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
self.txb_skip = DEFAULT_TXB_SKIP_CDF[idx];
|
||||
self.eob_pt_16 = DEFAULT_EOB_PT_16_CDF[idx];
|
||||
self.eob_pt_32 = DEFAULT_EOB_PT_32_CDF[idx];
|
||||
self.eob_pt_64 = DEFAULT_EOB_PT_64_CDF[idx];
|
||||
self.eob_pt_128 = DEFAULT_EOB_PT_128_CDF[idx];
|
||||
self.eob_pt_256 = DEFAULT_EOB_PT_256_CDF[idx];
|
||||
self.eob_pt_512 = DEFAULT_EOB_PT_512_CDF[idx];
|
||||
self.eob_pt_1024 = DEFAULT_EOB_PT_1024_CDF[idx];
|
||||
self.eob_extra = DEFAULT_EOB_EXTRA_CDF[idx];
|
||||
self.dc_sign = DEFAULT_DC_SIGN_CDF[idx];
|
||||
self.coeff_base_eob = DEFAULT_COEFF_BASE_EOB_CDF[idx];
|
||||
self.coeff_base = DEFAULT_COEFF_BASE_CDF[idx];
|
||||
self.coeff_br = DEFAULT_COEFF_BR_CDF[idx];
|
||||
}
|
||||
|
||||
/// The counter reset of load_cdfs( ): the last entry of every CDF array is set to 0.
|
||||
pub fn clear_counts(&mut self) {
|
||||
macro_rules! clr {
|
||||
($($f:ident),*) => { $( self.$f.clear_count(); )* };
|
||||
}
|
||||
clr!(
|
||||
y_mode,
|
||||
uv_mode_cfl_not_allowed,
|
||||
uv_mode_cfl_allowed,
|
||||
angle_delta,
|
||||
intrabc,
|
||||
partition_w8,
|
||||
partition_w16,
|
||||
partition_w32,
|
||||
partition_w64,
|
||||
partition_w128,
|
||||
segment_id,
|
||||
segment_id_predicted,
|
||||
tx_8x8,
|
||||
tx_16x16,
|
||||
tx_32x32,
|
||||
tx_64x64,
|
||||
txfm_split,
|
||||
filter_intra_mode,
|
||||
filter_intra,
|
||||
interp_filter,
|
||||
motion_mode,
|
||||
new_mv,
|
||||
zero_mv,
|
||||
ref_mv,
|
||||
compound_mode,
|
||||
drl_mode,
|
||||
is_inter,
|
||||
comp_mode,
|
||||
skip_mode,
|
||||
skip,
|
||||
comp_ref,
|
||||
comp_bwd_ref,
|
||||
single_ref,
|
||||
mv_joint,
|
||||
mv_class,
|
||||
mv_class0_bit,
|
||||
mv_fr,
|
||||
mv_class0_fr,
|
||||
mv_class0_hp,
|
||||
mv_sign,
|
||||
mv_bit,
|
||||
mv_hp,
|
||||
palette_y_mode,
|
||||
palette_uv_mode,
|
||||
palette_y_size,
|
||||
palette_uv_size,
|
||||
palette_2_y_color,
|
||||
palette_2_uv_color,
|
||||
palette_3_y_color,
|
||||
palette_3_uv_color,
|
||||
palette_4_y_color,
|
||||
palette_4_uv_color,
|
||||
palette_5_y_color,
|
||||
palette_5_uv_color,
|
||||
palette_6_y_color,
|
||||
palette_6_uv_color,
|
||||
palette_7_y_color,
|
||||
palette_7_uv_color,
|
||||
palette_8_y_color,
|
||||
palette_8_uv_color,
|
||||
delta_q,
|
||||
delta_lf,
|
||||
delta_lf_multi,
|
||||
intra_tx_type_set1,
|
||||
intra_tx_type_set2,
|
||||
inter_tx_type_set1,
|
||||
inter_tx_type_set2,
|
||||
inter_tx_type_set3,
|
||||
use_obmc,
|
||||
inter_intra,
|
||||
comp_ref_type,
|
||||
cfl_sign,
|
||||
uni_comp_ref,
|
||||
wedge_inter_intra,
|
||||
comp_group_idx,
|
||||
compound_idx,
|
||||
compound_type,
|
||||
inter_intra_mode,
|
||||
wedge_index,
|
||||
cfl_alpha,
|
||||
use_wiener,
|
||||
use_sgrproj,
|
||||
restoration_type,
|
||||
txb_skip,
|
||||
eob_pt_16,
|
||||
eob_pt_32,
|
||||
eob_pt_64,
|
||||
eob_pt_128,
|
||||
eob_pt_256,
|
||||
eob_pt_512,
|
||||
eob_pt_1024,
|
||||
eob_extra,
|
||||
dc_sign,
|
||||
coeff_base_eob,
|
||||
coeff_base,
|
||||
coeff_br
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeroes the adaptation counter (last element) of every innermost CDF array.
|
||||
trait ClearCount {
|
||||
fn clear_count(&mut self);
|
||||
}
|
||||
|
||||
impl<const N: usize> ClearCount for [u16; N] {
|
||||
fn clear_count(&mut self) {
|
||||
self[N - 1] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ClearCount, const M: usize> ClearCount for [T; M] {
|
||||
fn clear_count(&mut self) {
|
||||
for e in self.iter_mut() {
|
||||
e.clear_count();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,287 +0,0 @@
|
||||
//! Frame sample buffers and per-4x4 mode info storage.
|
||||
//!
|
||||
//! Tiles decode in parallel into private buffers that cover only the tile's area: a [`Plane`]
|
||||
//! or [`MiInfo`] may hold a region of the frame (origin `ox`, `oy`), addressed with frame
|
||||
//! coordinates throughout.
|
||||
|
||||
/// Recycled plane allocations: every frame allocates (and every evicted reference frees)
|
||||
/// megabytes of planes, and fresh large allocations cost page faults and kernel calls. Buffers
|
||||
/// are zeroed again on reuse, so contents never leak between frames.
|
||||
static PLANE_POOL: std::sync::Mutex<Vec<Vec<u16>>> = std::sync::Mutex::new(Vec::new());
|
||||
const POOL_MIN_LEN: usize = 1 << 16;
|
||||
/// Samples kept at most (96 M samples = 192 MB, a few 4K frames).
|
||||
const POOL_MAX_SAMPLES: usize = 96 << 20;
|
||||
|
||||
/// A zeroed buffer of `len` samples (recycled memory for large ones).
|
||||
fn zeroed(len: usize) -> Vec<u16> {
|
||||
if len >= POOL_MIN_LEN {
|
||||
let found = {
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
|
||||
};
|
||||
if let Some(mut v) = found {
|
||||
v.clear();
|
||||
v.resize(len, 0);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
vec![0; len]
|
||||
}
|
||||
|
||||
/// One plane of samples (u16 at every bit depth), or a rectangular region of one.
|
||||
#[derive(Default)]
|
||||
pub struct Plane {
|
||||
pub data: Vec<u16>,
|
||||
pub stride: usize,
|
||||
/// Allocated rows.
|
||||
pub rows: usize,
|
||||
/// Frame coordinates of `data[0]` (0 for whole planes).
|
||||
pub ox: usize,
|
||||
pub oy: usize,
|
||||
}
|
||||
|
||||
impl Clone for Plane {
|
||||
fn clone(&self) -> Plane {
|
||||
let mut data = zeroed(self.data.len());
|
||||
data.copy_from_slice(&self.data);
|
||||
Plane { data, stride: self.stride, rows: self.rows, ox: self.ox, oy: self.oy }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Plane {
|
||||
fn drop(&mut self) {
|
||||
if self.data.capacity() >= POOL_MIN_LEN {
|
||||
let v = std::mem::take(&mut self.data);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let kept: usize = pool.iter().map(Vec::capacity).sum();
|
||||
if kept + v.capacity() <= POOL_MAX_SAMPLES {
|
||||
pool.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Plane {
|
||||
pub fn new(width: usize, height: usize) -> Plane {
|
||||
Plane { data: zeroed(width * height), stride: width, rows: height, ox: 0, oy: 0 }
|
||||
}
|
||||
/// A `width` x `height` region with its top-left sample at frame position (ox, oy).
|
||||
pub fn region(ox: usize, oy: usize, width: usize, height: usize) -> Plane {
|
||||
Plane { data: zeroed(width * height), stride: width, rows: height, ox, oy }
|
||||
}
|
||||
#[inline(always)]
|
||||
pub fn at(&self, x: usize, y: usize) -> u16 {
|
||||
self.data[(y - self.oy) * self.stride + x - self.ox]
|
||||
}
|
||||
#[inline(always)]
|
||||
pub fn set(&mut self, x: usize, y: usize, v: u16) {
|
||||
self.data[(y - self.oy) * self.stride + x - self.ox] = v;
|
||||
}
|
||||
/// Row `y`; index 0 is column `ox` (whole planes: column 0).
|
||||
#[inline(always)]
|
||||
pub fn row(&self, y: usize) -> &[u16] {
|
||||
let o = (y - self.oy) * self.stride;
|
||||
&self.data[o..o + self.stride]
|
||||
}
|
||||
#[inline(always)]
|
||||
pub fn row_mut(&mut self, y: usize) -> &mut [u16] {
|
||||
let o = (y - self.oy) * self.stride;
|
||||
&mut self.data[o..o + self.stride]
|
||||
}
|
||||
/// Row `y` from column `x` (frame coordinates) to the end of the stored row.
|
||||
#[inline(always)]
|
||||
pub fn row_from(&self, y: usize, x: usize) -> &[u16] {
|
||||
let o = (y - self.oy) * self.stride;
|
||||
&self.data[o + x - self.ox..o + self.stride]
|
||||
}
|
||||
#[inline(always)]
|
||||
pub fn row_from_mut(&mut self, y: usize, x: usize) -> &mut [u16] {
|
||||
let o = (y - self.oy) * self.stride;
|
||||
&mut self.data[o + x - self.ox..o + self.stride]
|
||||
}
|
||||
/// Copy this region into the whole plane `dst` (same frame coordinates).
|
||||
pub fn copy_into(&self, dst: &mut Plane) {
|
||||
for y in 0..self.rows {
|
||||
let s = &self.data[y * self.stride..(y + 1) * self.stride];
|
||||
dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame's three planes, sized to the macroblock-aligned coded area plus margins.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct FrameBuf {
|
||||
pub planes: [Plane; 3],
|
||||
pub num_planes: usize,
|
||||
pub subsampling_x: usize,
|
||||
pub subsampling_y: usize,
|
||||
pub bit_depth: u8,
|
||||
/// Frame dimensions (luma) the samples are valid for.
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
}
|
||||
|
||||
impl FrameBuf {
|
||||
/// Allocate a frame for a coded area of `width` x `height` luma samples (rounded up to the
|
||||
/// 128x128 superblock grid plus 160 samples of slack for blocks extending past the edge).
|
||||
pub fn new(width: usize, height: usize, num_planes: usize, subsampling_x: usize, subsampling_y: usize, bit_depth: u8) -> FrameBuf {
|
||||
let aw = width.div_ceil(128) * 128 + 160;
|
||||
let ah = height.div_ceil(128) * 128 + 160;
|
||||
let mut planes: [Plane; 3] = Default::default();
|
||||
planes[0] = Plane::new(aw, ah);
|
||||
for p in planes.iter_mut().take(num_planes).skip(1) {
|
||||
*p = Plane::new(aw >> subsampling_x, ah >> subsampling_y);
|
||||
}
|
||||
FrameBuf { planes, num_planes, subsampling_x, subsampling_y, bit_depth, width, height }
|
||||
}
|
||||
|
||||
/// The region of a frame of this geometry covering luma samples [x0, x1) x [y0, y1)
|
||||
/// (chroma scaled down); the bounds are multiples of 8, or the allocated size.
|
||||
pub fn region_like(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> FrameBuf {
|
||||
let mut planes: [Plane; 3] = Default::default();
|
||||
for (p, pl) in planes.iter_mut().enumerate().take(self.num_planes) {
|
||||
let (sx, sy) = if p == 0 { (0, 0) } else { (self.subsampling_x, self.subsampling_y) };
|
||||
let full = &self.planes[p];
|
||||
let (px0, py0) = (x0 >> sx, y0 >> sy);
|
||||
let px1 = (x1 >> sx).min(full.stride);
|
||||
let py1 = (y1 >> sy).min(full.rows);
|
||||
*pl = Plane::region(px0, py0, px1 - px0, py1 - py0);
|
||||
}
|
||||
FrameBuf {
|
||||
planes,
|
||||
num_planes: self.num_planes,
|
||||
subsampling_x: self.subsampling_x,
|
||||
subsampling_y: self.subsampling_y,
|
||||
bit_depth: self.bit_depth,
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plane_width(&self, plane: usize) -> usize {
|
||||
if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x }
|
||||
}
|
||||
|
||||
pub fn plane_height(&self, plane: usize) -> usize {
|
||||
if plane == 0 { self.height } else { (self.height + self.subsampling_y) >> self.subsampling_y }
|
||||
}
|
||||
}
|
||||
|
||||
/// A motion vector (row, col) in 1/8 sample units.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Mv {
|
||||
pub row: i16,
|
||||
pub col: i16,
|
||||
}
|
||||
|
||||
impl Mv {
|
||||
pub const ZERO: Mv = Mv { row: 0, col: 0 };
|
||||
pub fn new(row: i32, col: i32) -> Mv {
|
||||
Mv { row: row as i16, col: col as i16 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Mode info stored for every 4x4 luma position (the arrays the spec indexes by
|
||||
/// `[ row ][ col ]` in mode-info units).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MiInfo {
|
||||
/// Frame size in mode info units.
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
/// Stored region: origin and width (the whole frame unless a tile's private copy).
|
||||
pub ox: usize,
|
||||
pub oy: usize,
|
||||
pub stride: usize,
|
||||
pub region_rows: usize,
|
||||
pub y_mode: Vec<u8>,
|
||||
pub uv_mode: Vec<u8>,
|
||||
pub ref_frame: Vec<[i8; 2]>,
|
||||
pub mv: Vec<[Mv; 2]>,
|
||||
pub is_inter: Vec<bool>,
|
||||
pub skip_mode: Vec<bool>,
|
||||
pub skip: Vec<bool>,
|
||||
pub tx_size: Vec<u8>,
|
||||
pub inter_tx_size: Vec<u8>,
|
||||
pub mi_size: Vec<u8>,
|
||||
pub segment_id: Vec<u8>,
|
||||
pub palette_size: [Vec<u8>; 2],
|
||||
pub palette_colors: [Vec<[u16; 8]>; 2],
|
||||
pub delta_lf: Vec<[i8; 4]>,
|
||||
pub comp_group_idx: Vec<u8>,
|
||||
pub compound_idx: Vec<u8>,
|
||||
pub interp_filter: Vec<[u8; 2]>,
|
||||
pub motion_mode: Vec<u8>,
|
||||
/// TxTypes[ row ][ col ] (luma 4x4 units).
|
||||
pub tx_type: Vec<u8>,
|
||||
/// RefFrames[ row ][ col ] has been written for this frame.
|
||||
pub written: Vec<bool>,
|
||||
}
|
||||
|
||||
impl MiInfo {
|
||||
/// `palette`: whether palette mode can occur (allow_screen_content_tools); without it the
|
||||
/// palette arrays (40 bytes per unit) stay empty.
|
||||
pub fn new(cols: usize, rows: usize, palette: bool) -> MiInfo {
|
||||
MiInfo::region(cols, rows, 0, 0, cols, rows, palette)
|
||||
}
|
||||
|
||||
/// Storage for the w x h mode info units at (ox, oy) of a cols x rows frame.
|
||||
pub fn region(cols: usize, rows: usize, ox: usize, oy: usize, w: usize, h: usize, palette: bool) -> MiInfo {
|
||||
let n = w * h;
|
||||
let np = if palette { n } else { 0 };
|
||||
MiInfo {
|
||||
cols,
|
||||
rows,
|
||||
ox,
|
||||
oy,
|
||||
stride: w,
|
||||
region_rows: h,
|
||||
y_mode: vec![0; n],
|
||||
uv_mode: vec![0; n],
|
||||
ref_frame: vec![[0, -1]; n],
|
||||
mv: vec![[Mv::ZERO; 2]; n],
|
||||
is_inter: vec![false; n],
|
||||
skip_mode: vec![false; n],
|
||||
skip: vec![false; n],
|
||||
tx_size: vec![0; n],
|
||||
inter_tx_size: vec![0; n],
|
||||
mi_size: vec![0; n],
|
||||
segment_id: vec![0; n],
|
||||
palette_size: [vec![0; np], vec![0; np]],
|
||||
palette_colors: [vec![[0; 8]; np], vec![[0; 8]; np]],
|
||||
delta_lf: vec![[0; 4]; n],
|
||||
comp_group_idx: vec![0; n],
|
||||
compound_idx: vec![0; n],
|
||||
interp_filter: vec![[0; 2]; n],
|
||||
motion_mode: vec![0; n],
|
||||
tx_type: vec![0; n],
|
||||
written: vec![false; n],
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn idx(&self, row: usize, col: usize) -> usize {
|
||||
debug_assert!(col >= self.ox && col < self.ox + self.stride && row >= self.oy, "mode info ({row}, {col}) outside the stored region");
|
||||
(row - self.oy) * self.stride + col - self.ox
|
||||
}
|
||||
|
||||
/// Copy this region into the whole-frame `dst`.
|
||||
pub fn copy_into(&self, dst: &mut MiInfo) {
|
||||
let (w, h) = (self.stride, self.region_rows);
|
||||
macro_rules! copy {
|
||||
($($f:ident)*) => {$(
|
||||
for y in 0..h {
|
||||
let d = dst.idx(self.oy + y, self.ox);
|
||||
dst.$f[d..d + w].copy_from_slice(&self.$f[y * w..(y + 1) * w]);
|
||||
}
|
||||
)*};
|
||||
}
|
||||
copy!(y_mode uv_mode ref_frame mv is_inter skip_mode skip tx_size inter_tx_size mi_size segment_id delta_lf comp_group_idx compound_idx interp_filter motion_mode tx_type written);
|
||||
for k in (0..2).filter(|&k| !self.palette_size[k].is_empty()) {
|
||||
for y in 0..h {
|
||||
let d = dst.idx(self.oy + y, self.ox);
|
||||
dst.palette_size[k][d..d + w].copy_from_slice(&self.palette_size[k][y * w..(y + 1) * w]);
|
||||
dst.palette_colors[k][d..d + w].copy_from_slice(&self.palette_colors[k][y * w..(y + 1) * w]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
//! Film grain synthesis (7.18.3), applied to output pictures only (references stay clean).
|
||||
|
||||
use crate::Picture;
|
||||
use crate::header::{FilmGrainParams, SequenceHeader};
|
||||
use crate::spec_tables::{GAUSSIAN_SEQUENCE, MC_IDENTITY};
|
||||
|
||||
#[inline]
|
||||
/// One plane's scaling lookup (7.18.3.4) from its `n` points. The header rejects points whose
|
||||
/// values don't strictly increase; a pair that still doesn't (defence in depth) is skipped
|
||||
/// instead of dividing by zero. No points: all zero.
|
||||
fn scaling_lut(n: usize, xs: &[u8], ys: &[u8]) -> [i32; 256] {
|
||||
let mut l = [0i32; 256];
|
||||
let n = n.min(xs.len()).min(ys.len());
|
||||
let (Some(&x0), Some(&y0)) = (xs.first(), ys.first()) else { return l };
|
||||
if n == 0 {
|
||||
return l;
|
||||
}
|
||||
for v in l.iter_mut().take(x0 as usize) {
|
||||
*v = y0 as i32;
|
||||
}
|
||||
for i in 0..n - 1 {
|
||||
let (xa, xb, ya, yb) = (xs[i] as i32, xs[i + 1] as i32, ys[i] as i32, ys[i + 1] as i32);
|
||||
let dx = xb - xa;
|
||||
if dx <= 0 {
|
||||
continue;
|
||||
}
|
||||
let delta = (yb - ya) * ((65536 + (dx >> 1)) / dx);
|
||||
for x in 0..dx {
|
||||
if let Some(v) = l.get_mut((xa + x) as usize) {
|
||||
*v = ya + ((x * delta + 32768) >> 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (xl, yl) = (xs[n - 1], ys[n - 1]);
|
||||
for v in l.iter_mut().skip(xl as usize) {
|
||||
*v = yl as i32;
|
||||
}
|
||||
l
|
||||
}
|
||||
|
||||
fn round2(x: i32, n: u32) -> i32 {
|
||||
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
|
||||
}
|
||||
|
||||
struct Rng(u32);
|
||||
|
||||
impl Rng {
|
||||
/// get_random_number( bits ) (7.18.3.2)
|
||||
fn next(&mut self, bits: u32) -> i32 {
|
||||
let r = self.0;
|
||||
let bit = (r ^ (r >> 1) ^ (r >> 3) ^ (r >> 12)) & 1;
|
||||
let r = (r >> 1) | (bit << 15);
|
||||
self.0 = r;
|
||||
((r >> (16 - bits)) & ((1 << bits) - 1)) as i32
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture-level parameters shared by the grain synthesis stages.
|
||||
struct GrainCtx<'a> {
|
||||
g: &'a FilmGrainParams,
|
||||
bd: u32,
|
||||
mono: bool,
|
||||
sub_x: usize,
|
||||
sub_y: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
grain_min: i32,
|
||||
grain_max: i32,
|
||||
}
|
||||
|
||||
impl GrainCtx<'_> {
|
||||
fn num_planes(&self) -> usize {
|
||||
if self.mono { 1 } else { 3 }
|
||||
}
|
||||
|
||||
fn grain_shift(&self) -> u32 {
|
||||
12 - self.bd + self.g.grain_scale_shift
|
||||
}
|
||||
|
||||
fn ar_shift(&self) -> u32 {
|
||||
self.g.ar_coeff_shift_minus_6 + 6
|
||||
}
|
||||
|
||||
fn lag(&self) -> i32 {
|
||||
self.g.ar_coeff_lag as i32
|
||||
}
|
||||
|
||||
fn stripe_w(&self) -> usize {
|
||||
self.w + 64
|
||||
}
|
||||
}
|
||||
|
||||
type GrainTemplate = Vec<[i32; 82]>;
|
||||
|
||||
/// The luma, Cb and Cr grain templates (7.18.3.3).
|
||||
struct Templates {
|
||||
luma: GrainTemplate,
|
||||
cb: GrainTemplate,
|
||||
cr: GrainTemplate,
|
||||
}
|
||||
|
||||
impl Templates {
|
||||
fn plane(&self, plane: usize) -> &GrainTemplate {
|
||||
match plane {
|
||||
0 => &self.luma,
|
||||
1 => &self.cb,
|
||||
_ => &self.cr,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn luma_ar_sum(g: &FilmGrainParams, luma: &[[i32; 82]], lag: i32, y: usize, x: usize) -> i32 {
|
||||
let mut s = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
if dr == 0 && dc == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
let c = g.ar_coeffs_y_plus_128[pos] as i32 - 128;
|
||||
s += luma[(y as i32 + dr) as usize][(x as i32 + dc) as usize] * c;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn luma_grain(c: &GrainCtx, rng: &mut Rng) -> GrainTemplate {
|
||||
let g = c.g;
|
||||
let shift = c.grain_shift();
|
||||
let mut luma = vec![[0i32; 82]; 73];
|
||||
for row in luma.iter_mut() {
|
||||
for v in row.iter_mut() {
|
||||
let gv = if g.num_y_points > 0 { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
let ar_shift = c.ar_shift();
|
||||
let lag = c.lag();
|
||||
for y in 3..73 {
|
||||
for x in 3..82 - 3 {
|
||||
let s = luma_ar_sum(g, &luma, lag, y, x);
|
||||
luma[y][x] = (luma[y][x] + round2(s, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
}
|
||||
luma
|
||||
}
|
||||
|
||||
/// The luma contribution to the chroma autoregressive filter at chroma position (y, x).
|
||||
fn chroma_luma_term(c: &GrainCtx, luma: &[[i32; 82]], y: usize, x: usize) -> i32 {
|
||||
let mut l = 0;
|
||||
let lx = ((x - 3) << c.sub_x) + 3;
|
||||
let ly = ((y - 3) << c.sub_y) + 3;
|
||||
for i in 0..=c.sub_y {
|
||||
for j in 0..=c.sub_x {
|
||||
l += luma[ly + i][lx + j];
|
||||
}
|
||||
}
|
||||
round2(l, (c.sub_x + c.sub_y) as u32)
|
||||
}
|
||||
|
||||
fn chroma_ar_sums(c: &GrainCtx, t: &Templates, y: usize, x: usize) -> (i32, i32) {
|
||||
let g = c.g;
|
||||
let lag = c.lag();
|
||||
let mut s0 = 0;
|
||||
let mut s1 = 0;
|
||||
let mut pos = 0;
|
||||
'outer: for dr in -lag..=0 {
|
||||
for dc in -lag..=lag {
|
||||
let c0 = g.ar_coeffs_cb_plus_128[pos] as i32 - 128;
|
||||
let c1 = g.ar_coeffs_cr_plus_128[pos] as i32 - 128;
|
||||
if dr == 0 && dc == 0 {
|
||||
if g.num_y_points > 0 {
|
||||
let l = chroma_luma_term(c, &t.luma, y, x);
|
||||
s0 += l * c0;
|
||||
s1 += l * c1;
|
||||
}
|
||||
break 'outer;
|
||||
}
|
||||
let (yy, xx) = ((y as i32 + dr) as usize, (x as i32 + dc) as usize);
|
||||
s0 += t.cb[yy][xx] * c0;
|
||||
s1 += t.cr[yy][xx] * c1;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
(s0, s1)
|
||||
}
|
||||
|
||||
/// Chroma grain templates; zero for monochrome pictures.
|
||||
fn chroma_grain(c: &GrainCtx, rng: &mut Rng, luma: GrainTemplate) -> Templates {
|
||||
let g = c.g;
|
||||
let mut t = Templates { luma, cb: vec![[0i32; 82]; 73], cr: vec![[0i32; 82]; 73] };
|
||||
if c.mono {
|
||||
return t;
|
||||
}
|
||||
let shift = c.grain_shift();
|
||||
let ar_shift = c.ar_shift();
|
||||
let chroma_w = if c.sub_x != 0 { 44 } else { 82 };
|
||||
let chroma_h = if c.sub_y != 0 { 38 } else { 73 };
|
||||
for (arr, seed, on) in [(&mut t.cb, 0xb524u32, g.num_cb_points > 0), (&mut t.cr, 0x49d8, g.num_cr_points > 0)] {
|
||||
rng.0 = g.grain_seed ^ seed;
|
||||
for row in arr.iter_mut().take(chroma_h) {
|
||||
for v in row.iter_mut().take(chroma_w) {
|
||||
let gv = if on || g.chroma_scaling_from_luma { GAUSSIAN_SEQUENCE[rng.next(11) as usize] as i32 } else { 0 };
|
||||
*v = round2(gv, shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in 3..chroma_h {
|
||||
for x in 3..chroma_w - 3 {
|
||||
let (s0, s1) = chroma_ar_sums(c, &t, y, x);
|
||||
t.cb[y][x] = (t.cb[y][x] + round2(s0, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
t.cr[y][x] = (t.cr[y][x] + round2(s1, ar_shift)).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Scaling lookup tables (7.18.3.4), one per plane.
|
||||
fn scaling_luts(g: &FilmGrainParams, num_planes: usize) -> [[i32; 256]; 3] {
|
||||
let mut lut = [[0i32; 256]; 3];
|
||||
for (plane, l) in lut.iter_mut().enumerate().take(num_planes) {
|
||||
let (n, xs, ys) = if plane == 0 || g.chroma_scaling_from_luma {
|
||||
(g.num_y_points, &g.point_y_value, &g.point_y_scaling)
|
||||
} else if plane == 1 {
|
||||
(g.num_cb_points, &g.point_cb_value, &g.point_cb_scaling)
|
||||
} else {
|
||||
(g.num_cr_points, &g.point_cr_value, &g.point_cr_scaling)
|
||||
};
|
||||
*l = scaling_lut(n, xs, ys);
|
||||
}
|
||||
lut
|
||||
}
|
||||
|
||||
fn scale_lookup(lut: &[[i32; 256]; 3], bd: u32, plane: usize, index: i32) -> i32 {
|
||||
let sh = bd - 8;
|
||||
let x = index >> sh;
|
||||
let rem = index - (x << sh);
|
||||
if bd == 8 || x == 255 {
|
||||
lut[plane][x as usize]
|
||||
} else {
|
||||
let start = lut[plane][x as usize];
|
||||
let end = lut[plane][x as usize + 1];
|
||||
start + round2((end - start) * rem, sh)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills one plane of a noise stripe from the grain template at stripe column `x`.
|
||||
fn fill_stripe_plane(c: &GrainCtx, src: &GrainTemplate, s: &mut [i32], plane: usize, offsets: (usize, usize), x: usize) {
|
||||
let (off_x, off_y) = offsets;
|
||||
let stripe_w = c.stripe_w();
|
||||
let overlap = c.g.overlap_flag;
|
||||
let psx = if plane > 0 { c.sub_x } else { 0 };
|
||||
let psy = if plane > 0 { c.sub_y } else { 0 };
|
||||
let pox = if psx != 0 { 6 + off_x } else { 9 + off_x * 2 };
|
||||
let poy = if psy != 0 { 6 + off_y } else { 9 + off_y * 2 };
|
||||
for i in 0..(34 >> psy) {
|
||||
for j in 0..(34 >> psx) {
|
||||
let mut gv = src[poy + i][pox + j];
|
||||
let (idx, blend) = if psx == 0 { (i * stripe_w + x * 2 + j, j < 2) } else { (i * stripe_w + x + j, j == 0) };
|
||||
if blend && overlap && x > 0 {
|
||||
let old = s[idx];
|
||||
gv = if psx != 0 {
|
||||
old * 23 + gv * 22
|
||||
} else if j == 0 {
|
||||
old * 27 + gv * 17
|
||||
} else {
|
||||
old * 17 + gv * 27
|
||||
};
|
||||
gv = round2(gv, 5).clamp(c.grain_min, c.grain_max);
|
||||
}
|
||||
s[idx] = gv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Noise stripes (7.18.3.5).
|
||||
struct Stripes {
|
||||
data: Vec<[Vec<i32>; 3]>,
|
||||
stripe_w: usize,
|
||||
overlap: bool,
|
||||
sub_y: usize,
|
||||
grain_min: i32,
|
||||
grain_max: i32,
|
||||
}
|
||||
|
||||
impl Stripes {
|
||||
fn build(c: &GrainCtx, t: &Templates, rng: &mut Rng) -> Stripes {
|
||||
let g = c.g;
|
||||
let stripe_w = c.stripe_w();
|
||||
let n_stripes = (c.h.div_ceil(2)).div_ceil(16).max(1);
|
||||
let mut data: Vec<[Vec<i32>; 3]> = (0..n_stripes).map(|_| [vec![0; 34 * stripe_w], vec![0; 34 * stripe_w], vec![0; 34 * stripe_w]]).collect();
|
||||
for (luma_num, stripe) in data.iter_mut().enumerate() {
|
||||
let mut r = g.grain_seed;
|
||||
r ^= (((luma_num * 37 + 178) & 255) << 8) as u32;
|
||||
r ^= ((luma_num * 173 + 105) & 255) as u32;
|
||||
rng.0 = r;
|
||||
let mut x = 0;
|
||||
while x < c.w.div_ceil(2) {
|
||||
let rand = rng.next(8) as usize;
|
||||
let offsets = (rand >> 4, rand & 15);
|
||||
for (plane, s) in stripe.iter_mut().enumerate().take(c.num_planes()) {
|
||||
fill_stripe_plane(c, t.plane(plane), s, plane, offsets, x);
|
||||
}
|
||||
x += 16;
|
||||
}
|
||||
}
|
||||
Stripes { data, stripe_w, overlap: g.overlap_flag, sub_y: c.sub_y, grain_min: c.grain_min, grain_max: c.grain_max }
|
||||
}
|
||||
|
||||
fn noise_at(&self, plane: usize, y: usize, x: usize) -> i32 {
|
||||
let psy = if plane > 0 { self.sub_y } else { 0 };
|
||||
let luma_num = y >> (5 - psy);
|
||||
let i = y - (luma_num << (5 - psy));
|
||||
let mut gv = self.data[luma_num][plane][i * self.stripe_w + x];
|
||||
if psy == 0 {
|
||||
if i < 2 && luma_num > 0 && self.overlap {
|
||||
let old = self.data[luma_num - 1][plane][(i + 32) * self.stripe_w + x];
|
||||
gv = if i == 0 { old * 27 + gv * 17 } else { old * 17 + gv * 27 };
|
||||
gv = round2(gv, 5).clamp(self.grain_min, self.grain_max);
|
||||
}
|
||||
} else if i < 1 && luma_num > 0 && self.overlap {
|
||||
let old = self.data[luma_num - 1][plane][(i + 16) * self.stripe_w + x];
|
||||
gv = round2(old * 23 + gv * 22, 5).clamp(self.grain_min, self.grain_max);
|
||||
}
|
||||
gv
|
||||
}
|
||||
}
|
||||
|
||||
/// Output clipping range and scaling shift for the blend step (7.18.3.5).
|
||||
struct BlendRange {
|
||||
min_value: i32,
|
||||
max_luma: i32,
|
||||
max_chroma: i32,
|
||||
pix_max: i32,
|
||||
scaling_shift: u32,
|
||||
}
|
||||
|
||||
impl BlendRange {
|
||||
fn new(c: &GrainCtx, matrix_coefficients: usize) -> BlendRange {
|
||||
let bd = c.bd;
|
||||
let (min_value, max_luma, max_chroma) = if c.g.clip_to_restricted_range {
|
||||
let ml = 235 << (bd - 8);
|
||||
(16 << (bd - 8), ml, if matrix_coefficients == MC_IDENTITY { ml } else { 240 << (bd - 8) })
|
||||
} else {
|
||||
let m = (256 << (bd - 8)) - 1;
|
||||
(0, m, m)
|
||||
};
|
||||
BlendRange { min_value, max_luma, max_chroma, pix_max: (1i32 << bd) - 1, scaling_shift: c.g.grain_scaling_minus_8 + 8 }
|
||||
}
|
||||
}
|
||||
|
||||
struct BlendEnv<'a> {
|
||||
c: &'a GrainCtx<'a>,
|
||||
lut: &'a [[i32; 256]; 3],
|
||||
stripes: &'a Stripes,
|
||||
range: &'a BlendRange,
|
||||
}
|
||||
|
||||
impl BlendEnv<'_> {
|
||||
fn blend_chroma(&self, py: &[u16], pu: &mut [u16], pv: &mut [u16]) {
|
||||
let c = self.c;
|
||||
let g = c.g;
|
||||
let (w, h, sub_x, sub_y, bd) = (c.w, c.h, c.sub_x, c.sub_y, c.bd);
|
||||
let cw = (w + sub_x) >> sub_x;
|
||||
let ch = (h + sub_y) >> sub_y;
|
||||
for y in 0..ch {
|
||||
for x in 0..cw {
|
||||
let lx = x << sub_x;
|
||||
let ly = y << sub_y;
|
||||
let lnx = (lx + 1).min(w - 1);
|
||||
let avg = if sub_x != 0 { round2(py[ly * w + lx] as i32 + py[ly * w + lnx] as i32, 1) } else { py[ly * w + lx] as i32 };
|
||||
for (plane, out, on, luma_mult, mult, offset) in [
|
||||
(1usize, &mut *pu, g.num_cb_points > 0, g.cb_luma_mult, g.cb_mult, g.cb_offset),
|
||||
(2, &mut *pv, g.num_cr_points > 0, g.cr_luma_mult, g.cr_mult, g.cr_offset),
|
||||
] {
|
||||
if !(on || g.chroma_scaling_from_luma) {
|
||||
continue;
|
||||
}
|
||||
let orig = out[y * cw + x] as i32;
|
||||
let merged = if g.chroma_scaling_from_luma {
|
||||
avg
|
||||
} else {
|
||||
let combined = avg * (luma_mult as i32 - 128) + orig * (mult as i32 - 128);
|
||||
((combined >> 6) + ((offset as i32 - 256) << (bd - 8))).clamp(0, self.range.pix_max)
|
||||
};
|
||||
let noise = round2(scale_lookup(self.lut, bd, plane, merged) * self.stripes.noise_at(plane, y, x), self.range.scaling_shift);
|
||||
out[y * cw + x] = (orig + noise).clamp(self.range.min_value, self.range.max_chroma) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn blend_luma(&self, py: &mut [u16]) {
|
||||
let (w, h) = (self.c.w, self.c.h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let orig = py[y * w + x] as i32;
|
||||
let noise = round2(scale_lookup(self.lut, self.c.bd, 0, orig) * self.stripes.noise_at(0, y, x), self.range.scaling_shift);
|
||||
py[y * w + x] = (orig + noise).clamp(self.range.min_value, self.range.max_luma) as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply(pic: &mut Picture, g: &FilmGrainParams, seq: &SequenceHeader) {
|
||||
let _ = seq;
|
||||
let bd = pic.bit_depth as u32;
|
||||
let grain_center = 128i32 << (bd - 8);
|
||||
let c = GrainCtx {
|
||||
g,
|
||||
bd,
|
||||
mono: pic.mono_chrome,
|
||||
sub_x: pic.subsampling_x as usize,
|
||||
sub_y: pic.subsampling_y as usize,
|
||||
w: pic.width as usize,
|
||||
h: pic.height as usize,
|
||||
grain_min: -grain_center,
|
||||
grain_max: (256i32 << (bd - 8)) - 1 - grain_center,
|
||||
};
|
||||
|
||||
// Generate grain (7.18.3.3).
|
||||
let mut rng = Rng(g.grain_seed);
|
||||
let luma = luma_grain(&c, &mut rng);
|
||||
let templates = chroma_grain(&c, &mut rng, luma);
|
||||
let lut = scaling_luts(g, c.num_planes());
|
||||
let stripes = Stripes::build(&c, &templates, &mut rng);
|
||||
|
||||
// Blend (7.18.3.5).
|
||||
let range = BlendRange::new(&c, pic.matrix_coefficients as usize);
|
||||
let env = BlendEnv { c: &c, lut: &lut, stripes: &stripes, range: &range };
|
||||
if !c.mono {
|
||||
let [py, pu, pv] = &mut pic.planes;
|
||||
env.blend_chroma(py, pu, pv);
|
||||
}
|
||||
if g.num_y_points > 0 {
|
||||
env.blend_luma(&mut pic.planes[0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaling_lut_interpolates_and_tolerates_duplicate_points() {
|
||||
let l = scaling_lut(2, &[0, 255], &[0, 255]);
|
||||
assert_eq!((l[0], l[128], l[255]), (0, 128, 255));
|
||||
// Duplicate and descending x values don't divide by zero.
|
||||
let l = scaling_lut(4, &[10, 10, 5, 200], &[20, 40, 60, 80]);
|
||||
assert_eq!(l[0], 20);
|
||||
assert_eq!(l[255], 80);
|
||||
assert_eq!(scaling_lut(0, &[], &[]), [0; 256]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,839 +0,0 @@
|
||||
//! Inter prediction (spec 7.11.3): motion vector scaling, block inter prediction, warped
|
||||
//! motion, masks (wedge, difference weight, inter-intra), distance weights and OBMC.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::mvpred::round2_signed64;
|
||||
use crate::spec_tables::{
|
||||
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
|
||||
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
|
||||
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
|
||||
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
|
||||
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
|
||||
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
|
||||
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
const PRED_STRIDE: usize = 128;
|
||||
|
||||
#[inline(always)]
|
||||
fn round2(x: i32, n: u32) -> i32 {
|
||||
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
|
||||
}
|
||||
|
||||
/// Rounding variables (7.11.3.2): (InterRound0, InterRound1, InterPostRound).
|
||||
pub(crate) fn rounding(is_compound: bool, bd: u32) -> (u32, u32, u32) {
|
||||
let mut r0 = 3;
|
||||
let mut r1 = if is_compound { 7 } else { 11 };
|
||||
if bd == 12 {
|
||||
r0 += 2;
|
||||
if !is_compound {
|
||||
r1 -= 2;
|
||||
}
|
||||
}
|
||||
(r0, r1, 2 * FILTER_BITS as u32 - (r0 + r1))
|
||||
}
|
||||
|
||||
/// Resolve divisor process (7.11.3.7): (divShift, divFactor).
|
||||
fn resolve_divisor(d: i64) -> (i32, i64) {
|
||||
let a = d.unsigned_abs();
|
||||
let n = 63 - a.leading_zeros() as i32;
|
||||
let e = a as i64 - (1i64 << n);
|
||||
let f = if n > DIV_LUT_BITS as i32 { round2_signed64(e, (n - DIV_LUT_BITS as i32) as u32) } else { e << (DIV_LUT_BITS as i32 - n) };
|
||||
let shift = n + DIV_LUT_PREC_BITS as i32;
|
||||
let factor = DIV_LUT[f as usize] as i64;
|
||||
(shift, if d < 0 { -factor } else { factor })
|
||||
}
|
||||
|
||||
/// Setup shear process (7.11.3.6): (warpValid, alpha, beta, gamma, delta).
|
||||
pub(crate) fn setup_shear(p: &[i32; 6]) -> (bool, i32, i32, i32, i32) {
|
||||
let prec = 1i64 << WARPEDMODEL_PREC_BITS;
|
||||
let alpha0 = (p[2] as i64 - prec).clamp(-32768, 32767);
|
||||
let beta0 = (p[3] as i64).clamp(-32768, 32767);
|
||||
let (div_shift, div_factor) = resolve_divisor(p[2] as i64);
|
||||
let v = (p[4] as i64) << WARPEDMODEL_PREC_BITS;
|
||||
let gamma0 = round2_signed_wide(v as i128 * div_factor as i128, div_shift as u32).clamp(-32768, 32767);
|
||||
let w = p[3] as i64 * p[4] as i64;
|
||||
let delta0 = (p[5] as i64 - round2_signed_wide(w as i128 * div_factor as i128, div_shift as u32) - prec).clamp(-32768, 32767);
|
||||
let rb = WARP_PARAM_REDUCE_BITS as u32;
|
||||
let red = |x: i64| (round2_signed64(x, rb) << rb) as i32;
|
||||
let (alpha, beta, gamma, delta) = (red(alpha0), red(beta0), red(gamma0), red(delta0));
|
||||
let valid =
|
||||
!(4 * alpha.abs() + 7 * beta.abs() >= (1 << WARPEDMODEL_PREC_BITS) || 4 * gamma.abs() + 4 * delta.abs() >= (1 << WARPEDMODEL_PREC_BITS));
|
||||
(valid, alpha, beta, gamma, delta)
|
||||
}
|
||||
|
||||
fn round2_signed_wide(x: i128, n: u32) -> i64 {
|
||||
if n == 0 {
|
||||
return x as i64;
|
||||
}
|
||||
(if x >= 0 { (x + (1 << (n - 1))) >> n } else { -((-x + (1 << (n - 1))) >> n) }) as i64
|
||||
}
|
||||
|
||||
/// WedgeMasks[ bsize ][ flipSign ][ wedge ] (7.11.3.11), stored as w*h bytes each.
|
||||
fn wedge_masks() -> &'static Vec<Vec<[Vec<u8>; 2]>> {
|
||||
static M: OnceLock<Vec<Vec<[Vec<u8>; 2]>>> = OnceLock::new();
|
||||
M.get_or_init(|| {
|
||||
let n = MASK_MASTER_SIZE;
|
||||
let mut master = vec![[[0u8; 64]; 64]; 6];
|
||||
for j in 0..n {
|
||||
let mut shift = (n / 4) as i32;
|
||||
let mut i = 0;
|
||||
while i < n {
|
||||
master[WEDGE_OBLIQUE63][i][j] = WEDGE_MASTER_OBLIQUE_EVEN[(j as i32 - shift).clamp(0, n as i32 - 1) as usize];
|
||||
shift -= 1;
|
||||
master[WEDGE_OBLIQUE63][i + 1][j] = WEDGE_MASTER_OBLIQUE_ODD[(j as i32 - shift).clamp(0, n as i32 - 1) as usize];
|
||||
master[WEDGE_VERTICAL][i][j] = WEDGE_MASTER_VERTICAL[j];
|
||||
master[WEDGE_VERTICAL][i + 1][j] = WEDGE_MASTER_VERTICAL[j];
|
||||
i += 2;
|
||||
}
|
||||
}
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let msk = master[WEDGE_OBLIQUE63][i][j];
|
||||
master[WEDGE_OBLIQUE27][j][i] = msk;
|
||||
master[WEDGE_OBLIQUE117][i][n - 1 - j] = 64 - msk;
|
||||
master[WEDGE_OBLIQUE153][n - 1 - j][i] = 64 - msk;
|
||||
master[WEDGE_HORIZONTAL][j][i] = master[WEDGE_VERTICAL][i][j];
|
||||
}
|
||||
}
|
||||
let mut out = vec![Vec::new(); BLOCK_SIZES];
|
||||
for bsize in BLOCK_8X8..BLOCK_SIZES {
|
||||
if WEDGE_BITS[bsize] == 0 {
|
||||
continue;
|
||||
}
|
||||
let w = 4 * NUM_4X4_BLOCKS_WIDE[bsize] as usize;
|
||||
let h = 4 * NUM_4X4_BLOCKS_HIGH[bsize] as usize;
|
||||
let w4 = NUM_4X4_BLOCKS_WIDE[bsize];
|
||||
let h4 = NUM_4X4_BLOCKS_HIGH[bsize];
|
||||
let shape = if h4 > w4 {
|
||||
0
|
||||
} else if h4 < w4 {
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
for wedge in 0..WEDGE_TYPES {
|
||||
let cb = &WEDGE_CODEBOOK[shape][wedge];
|
||||
let dir = cb[0] as usize;
|
||||
let xoff = n / 2 - ((cb[1] as usize * w) >> 3);
|
||||
let yoff = n / 2 - ((cb[2] as usize * h) >> 3);
|
||||
let mut sum = 0usize;
|
||||
for i in 0..w {
|
||||
sum += master[dir][yoff][xoff + i] as usize;
|
||||
}
|
||||
for i in 1..h {
|
||||
sum += master[dir][yoff + i][xoff] as usize;
|
||||
}
|
||||
let avg = (sum + (w + h - 1) / 2) / (w + h - 1);
|
||||
let flip = (avg < 32) as usize;
|
||||
let mut a = vec![0u8; w * h];
|
||||
let mut b = vec![0u8; w * h];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let m = master[dir][yoff + i][xoff + j];
|
||||
a[i * w + j] = m;
|
||||
b[i * w + j] = 64 - m;
|
||||
}
|
||||
}
|
||||
let pair = if flip == 0 { [a, b] } else { [b, a] };
|
||||
out[bsize].push(pair);
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
impl TileDecoder<'_, '_> {
|
||||
/// Reference plane and its (lastX, lastY) for refIdx (-1: the current frame for intra BC).
|
||||
fn ref_plane_dims(&self, ref_idx: i32, plane: usize) -> (i32, i32) {
|
||||
let (sx, sy) = if plane > 0 { (self.fs.ssx as i32, self.fs.ssy as i32) } else { (0, 0) };
|
||||
let (uw, fh) = if ref_idx < 0 {
|
||||
((self.fs.fh.mi_cols * 4) as i32, (self.fs.fh.mi_rows * 4) as i32)
|
||||
} else {
|
||||
let ri = &self.fs.ref_info[ref_idx as usize];
|
||||
(ri.upscaled_width as i32, ri.frame_height as i32)
|
||||
};
|
||||
(((uw + sx) >> sx) - 1, ((fh + sy) >> sy) - 1)
|
||||
}
|
||||
|
||||
/// Motion vector scaling process (7.11.3.3): (startX, startY, stepX, stepY).
|
||||
fn mv_scaling(&self, plane: usize, ref_idx: i32, x: i32, y: i32, mv: [i32; 2]) -> (i32, i32, i32, i32) {
|
||||
let fh = &self.fs.fh;
|
||||
let (ref_uw, ref_h) = if ref_idx < 0 {
|
||||
(fh.upscaled_width as i64, fh.frame_height as i64)
|
||||
} else {
|
||||
let ri = &self.fs.ref_info[ref_idx as usize];
|
||||
(ri.upscaled_width as i64, ri.frame_height as i64)
|
||||
};
|
||||
let fw = fh.frame_width as i64;
|
||||
let fhh = fh.frame_height as i64;
|
||||
let rs = REF_SCALE_SHIFT as i64;
|
||||
let x_scale = ((ref_uw << rs) + fw / 2) / fw;
|
||||
let y_scale = ((ref_h << rs) + fhh / 2) / fhh;
|
||||
let (sx, sy) = if plane > 0 { (self.fs.ssx as i32, self.fs.ssy as i32) } else { (0, 0) };
|
||||
let half = 1i64 << (SUBPEL_BITS - 1);
|
||||
let orig_x = ((x as i64) << SUBPEL_BITS) + ((2 * mv[1]) >> sx) as i64 + half;
|
||||
let orig_y = ((y as i64) << SUBPEL_BITS) + ((2 * mv[0]) >> sy) as i64 + half;
|
||||
let base_x = orig_x * x_scale - (half << rs);
|
||||
let base_y = orig_y * y_scale - (half << rs);
|
||||
let off = (1i64 << (SCALE_SUBPEL_BITS - SUBPEL_BITS)) / 2;
|
||||
let sh = (REF_SCALE_SHIFT + SUBPEL_BITS - SCALE_SUBPEL_BITS) as u32;
|
||||
let start_x = round2_signed64(base_x, sh) + off;
|
||||
let start_y = round2_signed64(base_y, sh) + off;
|
||||
let step_x = round2_signed64(x_scale, (REF_SCALE_SHIFT - SCALE_SUBPEL_BITS) as u32);
|
||||
let step_y = round2_signed64(y_scale, (REF_SCALE_SHIFT - SCALE_SUBPEL_BITS) as u32);
|
||||
(start_x as i32, start_y as i32, step_x as i32, step_y as i32)
|
||||
}
|
||||
|
||||
fn ref_plane(&self, ref_idx: i32, plane: usize) -> &Plane {
|
||||
// A missing reference (rejected earlier for valid streams) predicts from the current frame.
|
||||
match usize::try_from(ref_idx).ok().and_then(|i| self.fs.refs.get(i)).and_then(|r| r.as_ref()) {
|
||||
Some(r) => &r.buf.planes[plane],
|
||||
None => &self.t.cur.planes[plane],
|
||||
}
|
||||
}
|
||||
|
||||
/// Block inter prediction process (7.11.3.4) into `pred` (stride PRED_STRIDE); `tmp` holds
|
||||
/// the intermediate (horizontally filtered) rows.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn block_inter_prediction(
|
||||
&self,
|
||||
plane: usize,
|
||||
ref_idx: i32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
x_step: i32,
|
||||
y_step: i32,
|
||||
w: usize,
|
||||
h: usize,
|
||||
filters: [u8; 2],
|
||||
rnd: (u32, u32, u32),
|
||||
pred: &mut [i32],
|
||||
tmp: &mut [i32],
|
||||
) {
|
||||
let refp = self.ref_plane(ref_idx, plane);
|
||||
let (last_x, last_y) = self.ref_plane_dims(ref_idx, plane);
|
||||
let mut fh = filters[1] as usize;
|
||||
if w <= 4 {
|
||||
if fh == EIGHTTAP || fh == EIGHTTAP_SHARP {
|
||||
fh = 4;
|
||||
} else if fh == EIGHTTAP_SMOOTH {
|
||||
fh = 5;
|
||||
}
|
||||
}
|
||||
let mut fv = filters[0] as usize;
|
||||
if h <= 4 {
|
||||
if fv == EIGHTTAP || fv == EIGHTTAP_SHARP {
|
||||
fv = 4;
|
||||
} else if fv == EIGHTTAP_SMOOTH {
|
||||
fv = 5;
|
||||
}
|
||||
}
|
||||
let unit = 1 << SCALE_SUBPEL_BITS;
|
||||
if x_step == unit && y_step == unit {
|
||||
mc_unscaled(
|
||||
refp,
|
||||
last_x,
|
||||
last_y,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
&SUBPEL_FILTERS[fh][((x >> 6) & SUBPEL_MASK as i32) as usize],
|
||||
&SUBPEL_FILTERS[fv][((y >> 6) & SUBPEL_MASK as i32) as usize],
|
||||
rnd,
|
||||
pred,
|
||||
tmp,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let inter_h = ((((h as i32 - 1) * y_step + (1 << SCALE_SUBPEL_BITS) - 1) >> SCALE_SUBPEL_BITS) + 8) as usize;
|
||||
for r in 0..inter_h {
|
||||
let ry = ((y >> 10) + r as i32 - 3).clamp(0, last_y) as usize;
|
||||
let row = refp.row(ry);
|
||||
for c in 0..w {
|
||||
let p = x + x_step * c as i32;
|
||||
let filt = &SUBPEL_FILTERS[fh][((p >> 6) & SUBPEL_MASK as i32) as usize];
|
||||
let mut s = 0i32;
|
||||
let bx = (p >> 10) - 3;
|
||||
for t in 0..8 {
|
||||
s += filt[t] as i32 * row[(bx + t as i32).clamp(0, last_x) as usize] as i32;
|
||||
}
|
||||
tmp[r * w + c] = round2(s, rnd.0);
|
||||
}
|
||||
}
|
||||
for r in 0..h {
|
||||
for c in 0..w {
|
||||
let p = (y & 1023) + y_step * r as i32;
|
||||
let filt = &SUBPEL_FILTERS[fv][((p >> 6) & SUBPEL_MASK as i32) as usize];
|
||||
let base = (p >> 10) as usize;
|
||||
let mut s = 0i32;
|
||||
for t in 0..8 {
|
||||
s += filt[t] as i32 * tmp[(base + t) * w + c];
|
||||
}
|
||||
pred[r * PRED_STRIDE + c] = round2(s, rnd.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Block warp process (7.11.3.5) for the 8x8 section (i8, j8).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn block_warp(
|
||||
&self,
|
||||
params: &[i32; 6],
|
||||
plane: usize,
|
||||
ref_idx: i32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
i8: usize,
|
||||
j8: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
rnd: (u32, u32, u32),
|
||||
pred: &mut [i32],
|
||||
) {
|
||||
let refp = self.ref_plane(ref_idx, plane);
|
||||
let (last_x, last_y) = self.ref_plane_dims(ref_idx, plane);
|
||||
let (sx, sy) = if plane > 0 { (self.fs.ssx as i32, self.fs.ssy as i32) } else { (0, 0) };
|
||||
let src_x = (x + j8 as i32 * 8 + 4) << sx;
|
||||
let src_y = (y + i8 as i32 * 8 + 4) << sy;
|
||||
let dst_x = params[2] as i64 * src_x as i64 + params[3] as i64 * src_y as i64 + params[0] as i64;
|
||||
let dst_y = params[4] as i64 * src_x as i64 + params[5] as i64 * src_y as i64 + params[1] as i64;
|
||||
let (_, alpha, beta, gamma, delta) = setup_shear(params);
|
||||
let x4 = dst_x >> sx;
|
||||
let y4 = dst_y >> sy;
|
||||
let ix4 = (x4 >> WARPEDMODEL_PREC_BITS) as i32;
|
||||
let sx4 = (x4 & ((1 << WARPEDMODEL_PREC_BITS) - 1)) as i32;
|
||||
let iy4 = (y4 >> WARPEDMODEL_PREC_BITS) as i32;
|
||||
let sy4 = (y4 & ((1 << WARPEDMODEL_PREC_BITS) - 1)) as i32;
|
||||
let mut inter = [[0i32; 8]; 15];
|
||||
for i1 in -7..8i32 {
|
||||
let row = refp.row((iy4 + i1).clamp(0, last_y) as usize);
|
||||
for i2 in -4..4i32 {
|
||||
let sxv = sx4 + alpha * i2 + beta * i1;
|
||||
let offs = (round2(sxv, WARPEDDIFF_PREC_BITS as u32) + WARPEDPIXEL_PREC_SHIFTS as i32) as usize;
|
||||
let mut s = 0i32;
|
||||
for i3 in 0..8i32 {
|
||||
s += WARPED_FILTERS[offs][i3 as usize] as i32 * row[(ix4 + i2 - 3 + i3).clamp(0, last_x) as usize] as i32;
|
||||
}
|
||||
inter[(i1 + 7) as usize][(i2 + 4) as usize] = round2(s, rnd.0);
|
||||
}
|
||||
}
|
||||
let lim_i = 4.min(h as i32 - i8 as i32 * 8 - 4);
|
||||
let lim_j = 4.min(w as i32 - j8 as i32 * 8 - 4);
|
||||
for i1 in -4..lim_i {
|
||||
for i2 in -4..lim_j {
|
||||
let syv = sy4 + gamma * i2 + delta * i1;
|
||||
let offs = (round2(syv, WARPEDDIFF_PREC_BITS as u32) + WARPEDPIXEL_PREC_SHIFTS as i32) as usize;
|
||||
let mut s = 0i32;
|
||||
for i3 in 0..8 {
|
||||
s += WARPED_FILTERS[offs][i3] as i32 * inter[(i1 + i3 as i32 + 4) as usize][(i2 + 4) as usize];
|
||||
}
|
||||
pred[(i8 * 8 + (i1 + 4) as usize) * PRED_STRIDE + j8 * 8 + (i2 + 4) as usize] = round2(s, rnd.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp estimation process (7.11.3.8): sets LocalWarpParams / LocalValid.
|
||||
pub(crate) fn warp_estimation(&mut self) {
|
||||
let mut a = [[0i64; 2]; 2];
|
||||
let mut bx = [0i64; 2];
|
||||
let mut by = [0i64; 2];
|
||||
let mid_y = self.b.mi_row as i64 * 4 + self.b.bh4 as i64 * 2 - 1;
|
||||
let mid_x = self.b.mi_col as i64 * 4 + self.b.bw4 as i64 * 2 - 1;
|
||||
let suy = mid_y * 8;
|
||||
let sux = mid_x * 8;
|
||||
let duy = suy + self.b.mv_i[0][0] as i64;
|
||||
let dux = sux + self.b.mv_i[0][1] as i64;
|
||||
let ls = |a: i64, b: i64| ((a * b) >> 2) + (a + b);
|
||||
for i in 0..self.mvs.num_samples {
|
||||
let c = self.mvs.cand_list[i];
|
||||
let sy = c[0] as i64 - suy;
|
||||
let sx = c[1] as i64 - sux;
|
||||
let dy = c[2] as i64 - duy;
|
||||
let dx = c[3] as i64 - dux;
|
||||
if (sx - dx).abs() < LS_MV_MAX as i64 && (sy - dy).abs() < LS_MV_MAX as i64 {
|
||||
a[0][0] += ls(sx, sx) + 8;
|
||||
a[0][1] += ls(sx, sy) + 4;
|
||||
a[1][1] += ls(sy, sy) + 8;
|
||||
bx[0] += ls(sx, dx) + 8;
|
||||
bx[1] += ls(sy, dx) + 4;
|
||||
by[0] += ls(sx, dy) + 4;
|
||||
by[1] += ls(sy, dy) + 8;
|
||||
}
|
||||
}
|
||||
let det = a[0][0] * a[1][1] - a[0][1] * a[0][1];
|
||||
self.b.local_valid = det != 0;
|
||||
if det == 0 {
|
||||
return;
|
||||
}
|
||||
let (mut div_shift, mut div_factor) = resolve_divisor(det);
|
||||
div_shift -= WARPEDMODEL_PREC_BITS as i32;
|
||||
if div_shift < 0 {
|
||||
div_factor <<= -div_shift;
|
||||
div_shift = 0;
|
||||
}
|
||||
let prec = 1i64 << WARPEDMODEL_PREC_BITS;
|
||||
let cl = WARPEDMODEL_NONDIAGAFFINE_CLAMP as i64;
|
||||
let nondiag = |v: i64| round2_signed_wide(v as i128 * div_factor as i128, div_shift as u32).clamp(-cl + 1, cl - 1);
|
||||
let diag = |v: i64| round2_signed_wide(v as i128 * div_factor as i128, div_shift as u32).clamp(prec - cl + 1, prec + cl - 1);
|
||||
let mut p = [0i64; 6];
|
||||
p[2] = diag(a[1][1] * bx[0] - a[0][1] * bx[1]);
|
||||
p[3] = nondiag(-a[0][1] * bx[0] + a[0][0] * bx[1]);
|
||||
p[4] = nondiag(a[1][1] * by[0] - a[0][1] * by[1]);
|
||||
p[5] = diag(-a[0][1] * by[0] + a[0][0] * by[1]);
|
||||
let mvx = self.b.mv_i[0][1] as i64;
|
||||
let mvy = self.b.mv_i[0][0] as i64;
|
||||
let vx = mvx * (1 << (WARPEDMODEL_PREC_BITS - 3)) - (mid_x * (p[2] - prec) + mid_y * p[3]);
|
||||
let vy = mvy * (1 << (WARPEDMODEL_PREC_BITS - 3)) - (mid_x * p[4] + mid_y * (p[5] - prec));
|
||||
let tc = WARPEDMODEL_TRANS_CLAMP as i64;
|
||||
p[0] = vx.clamp(-tc, tc - 1);
|
||||
p[1] = vy.clamp(-tc, tc - 1);
|
||||
for i in 0..6 {
|
||||
self.b.local_warp_params[i] = p[i] as i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Inter prediction process (7.11.3.1) for a w x h region of `plane` at (x, y).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn predict_inter(&mut self, plane: usize, x: usize, y: usize, w: usize, h: usize, cand_row: usize, cand_col: usize) {
|
||||
let bd = self.fs.bit_depth;
|
||||
let mi_i = self.t.mi.idx(cand_row, cand_col);
|
||||
let cand_refs = self.t.mi.ref_frame[mi_i];
|
||||
let cand_mvs = self.t.mi.mv[mi_i];
|
||||
let filters = self.t.mi.interp_filter[mi_i];
|
||||
let is_compound = cand_refs[1] > INTRA_FRAME as i8;
|
||||
let rnd = rounding(is_compound, bd);
|
||||
if plane == 0 && self.b.motion_mode as usize == LOCALWARP {
|
||||
self.warp_estimation();
|
||||
if self.b.local_valid {
|
||||
let p = self.b.local_warp_params;
|
||||
self.b.local_valid = setup_shear(&p).0;
|
||||
}
|
||||
}
|
||||
let mut preds = [std::mem::take(&mut self.pred_buf[0]), std::mem::take(&mut self.pred_buf[1])];
|
||||
let mut tmp = std::mem::take(&mut self.mc_tmp);
|
||||
let fh = &self.fs.fh;
|
||||
for ref_list in 0..1 + is_compound as usize {
|
||||
let ref_frame = cand_refs[ref_list];
|
||||
let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 };
|
||||
let use_warp = self.warp_mode(ref_frame, ref_idx, w, h);
|
||||
let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32];
|
||||
if use_warp != 0 {
|
||||
let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] };
|
||||
for i8 in 0..=((h - 1) >> 3) {
|
||||
for j8 in 0..=((w - 1) >> 3) {
|
||||
self.block_warp(¶ms, plane, ref_idx, x as i32, y as i32, i8, j8, w, h, rnd, &mut preds[ref_list]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let (sx, sy, stx, sty) = self.mv_scaling(plane, ref_idx, x as i32, y as i32, mv);
|
||||
self.block_inter_prediction(plane, ref_idx, sx, sy, stx, sty, w, h, filters, rnd, &mut preds[ref_list], &mut tmp);
|
||||
}
|
||||
}
|
||||
let ct = self.b.compound_type;
|
||||
self.fill_blend_mask(plane, w, h, &preds, rnd.2);
|
||||
let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) };
|
||||
let max = (1i32 << bd) - 1;
|
||||
let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8;
|
||||
if !is_compound && !is_inter_intra {
|
||||
let pl = &mut self.t.cur.planes[plane];
|
||||
for i in 0..h {
|
||||
let row = &mut pl.row_from_mut(y + i, x)[..w];
|
||||
for j in 0..w {
|
||||
row[j] = preds[0][i * PRED_STRIDE + j].clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_AVERAGE {
|
||||
let pl = &mut self.t.cur.planes[plane];
|
||||
for i in 0..h {
|
||||
let row = &mut pl.row_from_mut(y + i, x)[..w];
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
row[j] = round2(preds[0][k] + preds[1][k], 1 + rnd.2).clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DISTANCE {
|
||||
let pl = &mut self.t.cur.planes[plane];
|
||||
for i in 0..h {
|
||||
let row = &mut pl.row_from_mut(y + i, x)[..w];
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
row[j] = round2(fwd * preds[0][k] + bck * preds[1][k], 4 + rnd.2).clamp(0, max) as u16;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.mask_blend(&preds, plane, x, y, w, h, rnd.2, max);
|
||||
}
|
||||
self.pred_buf = preds;
|
||||
self.mc_tmp = tmp;
|
||||
if self.b.motion_mode as usize == OBMC {
|
||||
self.overlapped_motion_compensation(plane, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp selection for one reference: 0 none, 1 local warp, 2 global warp.
|
||||
fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 {
|
||||
let fh = &self.fs.fh;
|
||||
let ym = self.b.y_mode;
|
||||
let global =
|
||||
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
|
||||
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
|
||||
if w < 8 || h < 8 || fh.force_integer_mv {
|
||||
0
|
||||
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
|
||||
1
|
||||
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type.
|
||||
fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec<i32>; 2], round_post: u32) {
|
||||
let bd = self.fs.bit_depth;
|
||||
let ct = self.b.compound_type;
|
||||
if ct == COMPOUND_WEDGE && plane == 0 {
|
||||
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_INTRA {
|
||||
let size_scale = MAX_SB_SIZE / h.max(w);
|
||||
let im = self.b.interintra_mode;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
|
||||
II_WEIGHTS_1D[i * size_scale] as i32
|
||||
} else if im == II_H_PRED {
|
||||
II_WEIGHTS_1D[j * size_scale] as i32
|
||||
} else if im == II_SMOOTH_PRED {
|
||||
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
|
||||
} else {
|
||||
32
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
let diff = (preds[0][k] - preds[1][k]).abs();
|
||||
let diff = round2(diff, (bd - 8) + round_post);
|
||||
let m = (38 + diff / 16).clamp(0, 64);
|
||||
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_scaled_ref(&self, ref_idx: i32) -> bool {
|
||||
if ref_idx < 0 {
|
||||
return false;
|
||||
}
|
||||
let fh = &self.fs.fh;
|
||||
let ri = &self.fs.ref_info[ref_idx as usize];
|
||||
let xs = ((ri.upscaled_width << REF_SCALE_SHIFT) + fh.frame_width / 2) / fh.frame_width;
|
||||
let ys = ((ri.frame_height << REF_SCALE_SHIFT) + fh.frame_height / 2) / fh.frame_height;
|
||||
xs != 1 << REF_SCALE_SHIFT || ys != 1 << REF_SCALE_SHIFT
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mask_blend(&mut self, preds: &[Vec<i32>; 2], plane: usize, dx: usize, dy: usize, w: usize, h: usize, post: u32, max: i32) {
|
||||
let (sx, sy) = if plane > 0 { (self.fs.ssx, self.fs.ssy) } else { (0, 0) };
|
||||
let interintra = self.b.interintra;
|
||||
let wedge_ii = self.b.wedge_interintra;
|
||||
for yy in 0..h {
|
||||
for xx in 0..w {
|
||||
let mk = |r: usize, c: usize| self.mask[r * PRED_STRIDE + c];
|
||||
let m = if (sx == 0 && sy == 0) || (interintra && !wedge_ii) {
|
||||
mk(yy, xx)
|
||||
} else if sx == 1 && sy == 0 {
|
||||
round2(mk(yy, 2 * xx) + mk(yy, 2 * xx + 1), 1)
|
||||
} else {
|
||||
round2(mk(2 * yy, 2 * xx) + mk(2 * yy, 2 * xx + 1) + mk(2 * yy + 1, 2 * xx) + mk(2 * yy + 1, 2 * xx + 1), 2)
|
||||
};
|
||||
let k = yy * PRED_STRIDE + xx;
|
||||
let pl = &mut self.t.cur.planes[plane];
|
||||
if interintra {
|
||||
let p0 = round2(preds[0][k], post).clamp(0, max);
|
||||
let p1 = pl.at(dx + xx, dy + yy) as i32;
|
||||
pl.set(dx + xx, dy + yy, round2(m * p1 + (64 - m) * p0, 6) as u16);
|
||||
} else {
|
||||
let v = round2(m * preds[0][k] + (64 - m) * preds[1][k], 6 + post).clamp(0, max);
|
||||
pl.set(dx + xx, dy + yy, v as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn distance_weights(&self, refs: [i8; 2]) -> (i32, i32) {
|
||||
let fh = &self.fs.fh;
|
||||
let mut dist = [0i32; 2];
|
||||
for (rl, d) in dist.iter_mut().enumerate() {
|
||||
let h = fh.order_hints[refs[rl] as usize];
|
||||
*d = crate::header::relative_dist(&self.fs.seq, h, fh.order_hint).abs().clamp(0, MAX_FRAME_DISTANCE as i32);
|
||||
}
|
||||
let d0 = dist[1];
|
||||
let d1 = dist[0];
|
||||
let order = (d0 <= d1) as usize;
|
||||
if d0 == 0 || d1 == 0 {
|
||||
return (QUANT_DIST_LOOKUP[3][order] as i32, QUANT_DIST_LOOKUP[3][1 - order] as i32);
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < 3 {
|
||||
let c0 = QUANT_DIST_WEIGHT[i][order] as i32;
|
||||
let c1 = QUANT_DIST_WEIGHT[i][1 - order] as i32;
|
||||
if order == 1 {
|
||||
if d0 * c0 > d1 * c1 {
|
||||
break;
|
||||
}
|
||||
} else if d0 * c0 < d1 * c1 {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
(QUANT_DIST_LOOKUP[i][order] as i32, QUANT_DIST_LOOKUP[i][1 - order] as i32)
|
||||
}
|
||||
|
||||
/// Overlapped motion compensation (7.11.3.9).
|
||||
fn overlapped_motion_compensation(&mut self, plane: usize, w: usize, h: usize) {
|
||||
let (sx, sy) = if plane > 0 { (self.fs.ssx, self.fs.ssy) } else { (0, 0) };
|
||||
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as usize, self.fs.fh.mi_cols as usize);
|
||||
let bd = self.fs.bit_depth;
|
||||
let max = (1i32 << bd) - 1;
|
||||
let mut obmc = std::mem::take(&mut self.pred_buf[0]);
|
||||
let mut tmp = std::mem::take(&mut self.mc_tmp);
|
||||
let get_mask = |len: usize| -> &'static [u8] {
|
||||
match len {
|
||||
2 => &OBMC_MASK_2,
|
||||
4 => &OBMC_MASK_4,
|
||||
8 => &OBMC_MASK_8,
|
||||
16 => &OBMC_MASK_16,
|
||||
_ => &OBMC_MASK_32,
|
||||
}
|
||||
};
|
||||
let mut jobs: Vec<(usize, usize, usize, usize, usize, usize, usize)> = Vec::new(); // (pass, cand_row, cand_col, x4, y4, predW, predH)
|
||||
if self.b.avail_u && self.fs.plane_residual_size(self.b.mi_size, plane) >= BLOCK_8X8 {
|
||||
let w4 = self.b.bw4;
|
||||
let mut x4 = self.b.mi_col;
|
||||
let y4 = self.b.mi_row;
|
||||
let mut n = 0;
|
||||
let limit = 4.min(MI_WIDTH_LOG2[self.b.mi_size] as usize);
|
||||
while n < limit && x4 < mi_cols.min(self.b.mi_col + w4) {
|
||||
let cr = self.b.mi_row - 1;
|
||||
let cc = x4 | 1;
|
||||
let mi = &self.t.mi;
|
||||
let ci = mi.idx(cr, cc);
|
||||
let step4 = (NUM_4X4_BLOCKS_WIDE[mi.mi_size[ci] as usize] as usize).clamp(2, 16);
|
||||
if mi.ref_frame[ci][0] > INTRA_FRAME as i8 {
|
||||
n += 1;
|
||||
let pw = w.min((step4 * 4) >> sx);
|
||||
let ph = (h >> 1).min(32 >> sy);
|
||||
jobs.push((0, cr, cc, x4, y4, pw, ph));
|
||||
}
|
||||
x4 += step4;
|
||||
}
|
||||
}
|
||||
if self.b.avail_l {
|
||||
let h4 = self.b.bh4;
|
||||
let x4 = self.b.mi_col;
|
||||
let mut y4 = self.b.mi_row;
|
||||
let mut n = 0;
|
||||
let limit = 4.min(MI_HEIGHT_LOG2[self.b.mi_size] as usize);
|
||||
while n < limit && y4 < mi_rows.min(self.b.mi_row + h4) {
|
||||
let cc = self.b.mi_col - 1;
|
||||
let cr = y4 | 1;
|
||||
let mi = &self.t.mi;
|
||||
let ci = mi.idx(cr, cc);
|
||||
let step4 = (NUM_4X4_BLOCKS_HIGH[mi.mi_size[ci] as usize] as usize).clamp(2, 16);
|
||||
if mi.ref_frame[ci][0] > INTRA_FRAME as i8 {
|
||||
n += 1;
|
||||
let pw = (w >> 1).min(32 >> sx);
|
||||
let ph = h.min((step4 * 4) >> sy);
|
||||
jobs.push((1, cr, cc, x4, y4, pw, ph));
|
||||
}
|
||||
y4 += step4;
|
||||
}
|
||||
}
|
||||
// the above pass completes before the left pass reads samples; jobs keep that order
|
||||
for (pass, cr, cc, x4, y4, pw, ph) in jobs {
|
||||
let ci = self.t.mi.idx(cr, cc);
|
||||
let m = self.t.mi.mv[ci][0];
|
||||
let mv = [m.row as i32, m.col as i32];
|
||||
let ref_idx = self.fs.fh.ref_frame_idx[self.t.mi.ref_frame[ci][0] as usize - LAST_FRAME] as i32;
|
||||
let px = (x4 * 4) >> sx;
|
||||
let py = (y4 * 4) >> sy;
|
||||
let (stx, sty, sxs, sys) = self.mv_scaling(plane, ref_idx, px as i32, py as i32, mv);
|
||||
let filters = self.t.mi.interp_filter[ci];
|
||||
let rnd = rounding(false, bd);
|
||||
self.block_inter_prediction(plane, ref_idx, stx, sty, sxs, sys, pw, ph, filters, rnd, &mut obmc, &mut tmp);
|
||||
let mask = get_mask(if pass == 0 { ph } else { pw });
|
||||
let pl = &mut self.t.cur.planes[plane];
|
||||
for i in 0..ph {
|
||||
for j in 0..pw {
|
||||
let mm = if pass == 0 { mask[i] } else { mask[j] } as i32;
|
||||
let o = obmc[i * PRED_STRIDE + j].clamp(0, max);
|
||||
let c = pl.at(px + j, py + i) as i32;
|
||||
pl.set(px + j, py + i, round2(mm * c + (64 - mm) * o, 6) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pred_buf[0] = obmc;
|
||||
self.mc_tmp = tmp;
|
||||
}
|
||||
}
|
||||
|
||||
const IDENTITY_TAPS: [i16; 8] = [0, 0, 0, 128, 0, 0, 0, 0];
|
||||
|
||||
/// Horizontal pass of one row for a block `W` wide: taps outer, columns inner, fixed width so
|
||||
/// the row stays in registers.
|
||||
#[inline(always)]
|
||||
fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32, sh: u32) {
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
acc[j] += f * p[j] as i32;
|
||||
}
|
||||
}
|
||||
let Some(o) = out.first_chunk_mut::<W>() else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
o[j] = acc[j] >> sh;
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical pass of one output row from 8 intermediate rows (`rows`, stride `W`).
|
||||
#[inline(always)]
|
||||
fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32, sh: u32) {
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
acc[j] += f * p[j];
|
||||
}
|
||||
}
|
||||
let Some(o) = out.first_chunk_mut::<W>() else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
o[j] = acc[j] >> sh;
|
||||
}
|
||||
}
|
||||
|
||||
/// Block inter prediction without reference scaling: every column / row uses the same filter
|
||||
/// phase, so the filters run over whole rows (bit-identical to the per-sample process).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mc_unscaled(
|
||||
refp: &Plane,
|
||||
last_x: i32,
|
||||
last_y: i32,
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: usize,
|
||||
h: usize,
|
||||
hf: &[i16; 8],
|
||||
vf: &[i16; 8],
|
||||
rnd: (u32, u32, u32),
|
||||
pred: &mut [i32],
|
||||
tmp: &mut [i32],
|
||||
) {
|
||||
let x0 = (x >> 10) - 3;
|
||||
let y0 = (y >> 10) - 3;
|
||||
let h_copy = *hf == IDENTITY_TAPS;
|
||||
let v_copy = *vf == IDENTITY_TAPS;
|
||||
// With the identity vertical phase only intermediate rows 3 .. 3 + h have a non-zero tap.
|
||||
let (r_lo, r_hi) = if v_copy { (3, 3 + h) } else { (0, h + 7) };
|
||||
let inside = x0 >= 0 && x0 + w as i32 + 7 <= last_x + 1;
|
||||
let mut edge = [0u16; 128 + 8];
|
||||
let mut acc = [0i32; 128];
|
||||
let (r0, r1) = (rnd.0, rnd.1);
|
||||
let (half0, half1) = (1i32 << (r0 - 1), 1i32 << (r1 - 1));
|
||||
for r in r_lo..r_hi {
|
||||
let ry = (y0 + r as i32).clamp(0, last_y) as usize;
|
||||
let row = refp.row(ry);
|
||||
let src: &[u16] = if inside {
|
||||
&row[x0 as usize..x0 as usize + w + 7]
|
||||
} else {
|
||||
for (k, e) in edge[..w + 7].iter_mut().enumerate() {
|
||||
*e = row[(x0 + k as i32).clamp(0, last_x) as usize];
|
||||
}
|
||||
&edge[..w + 7]
|
||||
};
|
||||
let out = &mut tmp[r * w..r * w + w];
|
||||
if h_copy {
|
||||
// Round2(128 * v, InterRound0) is exact.
|
||||
for (o, &v) in out.iter_mut().zip(&src[3..3 + w]) {
|
||||
*o = (v as i32) << (7 - r0);
|
||||
}
|
||||
} else {
|
||||
match w {
|
||||
2 => hpass::<2>(src, hf, out, half0, r0),
|
||||
4 => hpass::<4>(src, hf, out, half0, r0),
|
||||
8 => hpass::<8>(src, hf, out, half0, r0),
|
||||
16 => hpass::<16>(src, hf, out, half0, r0),
|
||||
32 => hpass::<32>(src, hf, out, half0, r0),
|
||||
64 => hpass::<64>(src, hf, out, half0, r0),
|
||||
_ => {
|
||||
let acc = &mut acc[..w];
|
||||
acc.fill(0);
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
for (a, &v) in acc.iter_mut().zip(&src[t..t + w]) {
|
||||
*a += f * v as i32;
|
||||
}
|
||||
}
|
||||
for (o, &a) in out.iter_mut().zip(acc.iter()) {
|
||||
*o = (a + half0) >> r0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in 0..h {
|
||||
let out = &mut pred[r * PRED_STRIDE..r * PRED_STRIDE + w];
|
||||
if v_copy {
|
||||
for (o, &v) in out.iter_mut().zip(&tmp[(r + 3) * w..(r + 4) * w]) {
|
||||
*o = (128 * v + half1) >> r1;
|
||||
}
|
||||
} else {
|
||||
let rows = &tmp[r * w..(r + 8) * w];
|
||||
match w {
|
||||
2 => vpass::<2>(rows, vf, out, half1, r1),
|
||||
4 => vpass::<4>(rows, vf, out, half1, r1),
|
||||
8 => vpass::<8>(rows, vf, out, half1, r1),
|
||||
16 => vpass::<16>(rows, vf, out, half1, r1),
|
||||
32 => vpass::<32>(rows, vf, out, half1, r1),
|
||||
64 => vpass::<64>(rows, vf, out, half1, r1),
|
||||
_ => {
|
||||
let acc = &mut acc[..w];
|
||||
acc.fill(0);
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
for (a, &v) in acc.iter_mut().zip(&tmp[(r + t) * w..(r + t + 1) * w]) {
|
||||
*a += f * v;
|
||||
}
|
||||
}
|
||||
for (o, &a) in out.iter_mut().zip(acc.iter()) {
|
||||
*o = (a + half1) >> r1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,777 +0,0 @@
|
||||
//! Inter frame mode info syntax (5.11.7 / 5.11.18 – 5.11.33) with its CDF selection (8.3.2).
|
||||
|
||||
use crate::Result;
|
||||
use crate::header::{EIGHTTAP, SWITCHABLE};
|
||||
use crate::mvpred::MvP;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
|
||||
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
|
||||
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
|
||||
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
|
||||
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
|
||||
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
fn block_width(bs: usize) -> usize {
|
||||
4 * NUM_4X4_BLOCKS_WIDE[bs] as usize
|
||||
}
|
||||
fn block_height(bs: usize) -> usize {
|
||||
4 * NUM_4X4_BLOCKS_HIGH[bs] as usize
|
||||
}
|
||||
|
||||
/// Neighbour reference frame summary (LeftRefFrame / AboveRefFrame and the derived flags).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct Neighbours {
|
||||
pub left: [i8; 2],
|
||||
pub above: [i8; 2],
|
||||
pub left_intra: bool,
|
||||
pub above_intra: bool,
|
||||
pub left_single: bool,
|
||||
pub above_single: bool,
|
||||
}
|
||||
|
||||
impl TileDecoder<'_, '_> {
|
||||
pub(crate) fn inter_frame_mode_info_impl(&mut self) -> Result<()> {
|
||||
self.b.use_intrabc = false;
|
||||
let (r, c) = (self.b.mi_row, self.b.mi_col);
|
||||
let mi = &self.t.mi;
|
||||
let mut n = Neighbours {
|
||||
left: if self.b.avail_l { mi.ref_frame[mi.idx(r, c - 1)] } else { [INTRA_FRAME as i8, -1] },
|
||||
above: if self.b.avail_u { mi.ref_frame[mi.idx(r - 1, c)] } else { [INTRA_FRAME as i8, -1] },
|
||||
..Default::default()
|
||||
};
|
||||
if !self.b.avail_l {
|
||||
n.left[1] = -1;
|
||||
}
|
||||
if !self.b.avail_u {
|
||||
n.above[1] = -1;
|
||||
}
|
||||
n.left_intra = n.left[0] <= INTRA_FRAME as i8;
|
||||
n.above_intra = n.above[0] <= INTRA_FRAME as i8;
|
||||
n.left_single = n.left[1] <= INTRA_FRAME as i8;
|
||||
n.above_single = n.above[1] <= INTRA_FRAME as i8;
|
||||
self.nb = n;
|
||||
self.b.skip = false;
|
||||
self.inter_segment_id(true);
|
||||
self.read_skip_mode();
|
||||
if self.b.skip_mode {
|
||||
self.b.skip = true;
|
||||
} else {
|
||||
self.read_skip();
|
||||
}
|
||||
if !self.fs.fh.seg.seg_id_pre_skip {
|
||||
self.inter_segment_id(false);
|
||||
}
|
||||
self.b.lossless = self.fs.fh.lossless_array[self.b.segment_id];
|
||||
self.read_cdef();
|
||||
self.read_delta_qindex();
|
||||
self.read_delta_lf();
|
||||
self.read_deltas = false;
|
||||
self.read_is_inter();
|
||||
if self.b.is_inter { self.inter_block_mode_info() } else { self.intra_block_mode_info() }
|
||||
}
|
||||
|
||||
fn get_segment_id(&self) -> usize {
|
||||
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as usize, self.fs.fh.mi_cols as usize);
|
||||
let x_mis = (mi_cols - self.b.mi_col).min(self.b.bw4);
|
||||
let y_mis = (mi_rows - self.b.mi_row).min(self.b.bh4);
|
||||
let mut seg = 7u8;
|
||||
for y in 0..y_mis {
|
||||
for x in 0..x_mis {
|
||||
let i = (self.b.mi_row + y) * self.t.mi.cols + self.b.mi_col + x;
|
||||
seg = seg.min(self.fs.prev_segment_ids[i]);
|
||||
}
|
||||
}
|
||||
seg as usize
|
||||
}
|
||||
|
||||
fn set_seg_pred_context(&mut self, v: u8) {
|
||||
for i in 0..self.b.bw4 {
|
||||
if self.b.mi_col + i < self.t.above_seg_pred.len() {
|
||||
self.t.above_seg_pred[self.b.mi_col + i] = v;
|
||||
}
|
||||
}
|
||||
for i in 0..self.b.bh4 {
|
||||
if self.b.mi_row + i < self.t.left_seg_pred.len() {
|
||||
self.t.left_seg_pred[self.b.mi_row + i] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inter_segment_id(&mut self, pre_skip: bool) {
|
||||
let seg = &self.fs.fh.seg;
|
||||
if !seg.enabled {
|
||||
self.b.segment_id = 0;
|
||||
return;
|
||||
}
|
||||
let predicted = self.get_segment_id();
|
||||
if seg.update_map {
|
||||
if pre_skip && !seg.seg_id_pre_skip {
|
||||
self.b.segment_id = 0;
|
||||
return;
|
||||
}
|
||||
if !pre_skip && self.b.skip {
|
||||
self.set_seg_pred_context(0);
|
||||
self.read_segment_id();
|
||||
return;
|
||||
}
|
||||
if seg.temporal_update {
|
||||
let ctx = self.t.left_seg_pred[self.b.mi_row] as usize + self.t.above_seg_pred[self.b.mi_col] as usize;
|
||||
let pred_flag = self.sd.read_symbol(&mut self.cdf.segment_id_predicted[ctx]);
|
||||
if pred_flag == 1 {
|
||||
self.b.segment_id = predicted;
|
||||
} else {
|
||||
self.read_segment_id();
|
||||
}
|
||||
self.set_seg_pred_context(pred_flag as u8);
|
||||
} else {
|
||||
self.read_segment_id();
|
||||
}
|
||||
} else {
|
||||
self.b.segment_id = predicted;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_skip_mode(&mut self) {
|
||||
let ms = self.b.mi_size;
|
||||
if self.seg_active(SEG_LVL_SKIP)
|
||||
|| self.seg_active(SEG_LVL_REF_FRAME)
|
||||
|| self.seg_active(SEG_LVL_GLOBALMV)
|
||||
|| !self.fs.fh.skip_mode_present
|
||||
|| block_width(ms) < 8
|
||||
|| block_height(ms) < 8
|
||||
{
|
||||
self.b.skip_mode = false;
|
||||
} else {
|
||||
let (r, c) = (self.b.mi_row, self.b.mi_col);
|
||||
let mi = &self.t.mi;
|
||||
let mut ctx = 0;
|
||||
if self.b.avail_u {
|
||||
ctx += mi.skip_mode[mi.idx(r - 1, c)] as usize;
|
||||
}
|
||||
if self.b.avail_l {
|
||||
ctx += mi.skip_mode[mi.idx(r, c - 1)] as usize;
|
||||
}
|
||||
self.b.skip_mode = self.sd.read_symbol(&mut self.cdf.skip_mode[ctx]) == 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn seg_active(&self, feature: usize) -> bool {
|
||||
self.fs.fh.seg.enabled && self.fs.fh.seg.features.enabled[self.b.segment_id][feature]
|
||||
}
|
||||
|
||||
fn read_is_inter(&mut self) {
|
||||
if self.b.skip_mode {
|
||||
self.b.is_inter = true;
|
||||
} else if self.seg_active(SEG_LVL_REF_FRAME) {
|
||||
self.b.is_inter = self.fs.fh.seg.features.data[self.b.segment_id][SEG_LVL_REF_FRAME] != INTRA_FRAME as i32;
|
||||
} else if self.seg_active(SEG_LVL_GLOBALMV) {
|
||||
self.b.is_inter = true;
|
||||
} else {
|
||||
let n = self.nb;
|
||||
let ctx = if self.b.avail_u && self.b.avail_l {
|
||||
if n.left_intra && n.above_intra { 3 } else { (n.left_intra || n.above_intra) as usize }
|
||||
} else if self.b.avail_u || self.b.avail_l {
|
||||
2 * (if self.b.avail_u { n.above_intra } else { n.left_intra }) as usize
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.b.is_inter = self.sd.read_symbol(&mut self.cdf.is_inter[ctx]) == 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn intra_block_mode_info(&mut self) -> Result<()> {
|
||||
self.b.ref_frame = [INTRA_FRAME as i8, -1];
|
||||
let ctx = SIZE_GROUP[self.b.mi_size] as usize;
|
||||
self.b.y_mode = self.sd.read_symbol(&mut self.cdf.y_mode[ctx]);
|
||||
self.intra_angle_info_y();
|
||||
if self.b.has_chroma {
|
||||
self.read_uv_mode();
|
||||
if self.b.uv_mode == UV_CFL_PRED {
|
||||
self.read_cfl_alphas();
|
||||
}
|
||||
self.intra_angle_info_uv();
|
||||
}
|
||||
self.b.palette_size_y = 0;
|
||||
self.b.palette_size_uv = 0;
|
||||
let ms = self.b.mi_size;
|
||||
if ms >= BLOCK_8X8 && block_width(ms) <= 64 && block_height(ms) <= 64 && self.fs.fh.allow_screen_content_tools {
|
||||
self.palette_mode_info();
|
||||
}
|
||||
self.filter_intra_mode_info();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn count_refs(&self, frame_type: i8) -> usize {
|
||||
let n = self.nb;
|
||||
let mut c = 0;
|
||||
if self.b.avail_u {
|
||||
c += (n.above[0] == frame_type) as usize + (n.above[1] == frame_type) as usize;
|
||||
}
|
||||
if self.b.avail_l {
|
||||
c += (n.left[0] == frame_type) as usize + (n.left[1] == frame_type) as usize;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn ref_count_ctx(c0: usize, c1: usize) -> usize {
|
||||
match c0.cmp(&c1) {
|
||||
std::cmp::Ordering::Less => 0,
|
||||
std::cmp::Ordering::Equal => 1,
|
||||
std::cmp::Ordering::Greater => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_comp_ref(&self) -> usize {
|
||||
Self::ref_count_ctx(
|
||||
self.count_refs(LAST_FRAME as i8) + self.count_refs(LAST2_FRAME as i8),
|
||||
self.count_refs(LAST3_FRAME as i8) + self.count_refs(GOLDEN_FRAME as i8),
|
||||
)
|
||||
}
|
||||
fn ctx_comp_ref_p1(&self) -> usize {
|
||||
Self::ref_count_ctx(self.count_refs(LAST_FRAME as i8), self.count_refs(LAST2_FRAME as i8))
|
||||
}
|
||||
fn ctx_comp_ref_p2(&self) -> usize {
|
||||
Self::ref_count_ctx(self.count_refs(LAST3_FRAME as i8), self.count_refs(GOLDEN_FRAME as i8))
|
||||
}
|
||||
fn ctx_comp_bwdref(&self) -> usize {
|
||||
Self::ref_count_ctx(self.count_refs(BWDREF_FRAME as i8) + self.count_refs(ALTREF2_FRAME as i8), self.count_refs(ALTREF_FRAME as i8))
|
||||
}
|
||||
fn ctx_comp_bwdref_p1(&self) -> usize {
|
||||
Self::ref_count_ctx(self.count_refs(BWDREF_FRAME as i8), self.count_refs(ALTREF2_FRAME as i8))
|
||||
}
|
||||
fn ctx_single_ref_p1(&self) -> usize {
|
||||
let fwd = self.count_refs(LAST_FRAME as i8)
|
||||
+ self.count_refs(LAST2_FRAME as i8)
|
||||
+ self.count_refs(LAST3_FRAME as i8)
|
||||
+ self.count_refs(GOLDEN_FRAME as i8);
|
||||
let bwd = self.count_refs(BWDREF_FRAME as i8) + self.count_refs(ALTREF2_FRAME as i8) + self.count_refs(ALTREF_FRAME as i8);
|
||||
Self::ref_count_ctx(fwd, bwd)
|
||||
}
|
||||
|
||||
fn read_ref_frames(&mut self) {
|
||||
if self.b.skip_mode {
|
||||
let f = self.fs.fh.skip_mode_frame;
|
||||
self.b.ref_frame = [f[0] as i8, f[1] as i8];
|
||||
} else if self.seg_active(SEG_LVL_REF_FRAME) {
|
||||
self.b.ref_frame = [self.fs.fh.seg.features.data[self.b.segment_id][SEG_LVL_REF_FRAME] as i8, -1];
|
||||
} else if self.seg_active(SEG_LVL_SKIP) || self.seg_active(SEG_LVL_GLOBALMV) {
|
||||
self.b.ref_frame = [LAST_FRAME as i8, -1];
|
||||
} else {
|
||||
let comp_mode = if self.fs.fh.reference_select && self.b.bw4.min(self.b.bh4) >= 2 {
|
||||
let ctx = self.comp_mode_ctx();
|
||||
self.sd.read_symbol(&mut self.cdf.comp_mode[ctx])
|
||||
} else {
|
||||
SINGLE_REFERENCE
|
||||
};
|
||||
if comp_mode == COMPOUND_REFERENCE {
|
||||
let ctx = self.comp_ref_type_ctx();
|
||||
let comp_ref_type = self.sd.read_symbol(&mut self.cdf.comp_ref_type[ctx]);
|
||||
if comp_ref_type == UNIDIR_COMP_REFERENCE {
|
||||
let ctx = self.ctx_single_ref_p1();
|
||||
if self.sd.read_symbol(&mut self.cdf.uni_comp_ref[ctx][0]) == 1 {
|
||||
self.b.ref_frame = [BWDREF_FRAME as i8, ALTREF_FRAME as i8];
|
||||
} else {
|
||||
let ctx1 = Self::ref_count_ctx(
|
||||
self.count_refs(LAST2_FRAME as i8),
|
||||
self.count_refs(LAST3_FRAME as i8) + self.count_refs(GOLDEN_FRAME as i8),
|
||||
);
|
||||
if self.sd.read_symbol(&mut self.cdf.uni_comp_ref[ctx1][1]) == 1 {
|
||||
let ctx2 = self.ctx_comp_ref_p2();
|
||||
if self.sd.read_symbol(&mut self.cdf.uni_comp_ref[ctx2][2]) == 1 {
|
||||
self.b.ref_frame = [LAST_FRAME as i8, GOLDEN_FRAME as i8];
|
||||
} else {
|
||||
self.b.ref_frame = [LAST_FRAME as i8, LAST3_FRAME as i8];
|
||||
}
|
||||
} else {
|
||||
self.b.ref_frame = [LAST_FRAME as i8, LAST2_FRAME as i8];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let ctx = self.ctx_comp_ref();
|
||||
if self.sd.read_symbol(&mut self.cdf.comp_ref[ctx][0]) == 0 {
|
||||
let ctx = self.ctx_comp_ref_p1();
|
||||
let p1 = self.sd.read_symbol(&mut self.cdf.comp_ref[ctx][1]);
|
||||
self.b.ref_frame[0] = if p1 == 1 { LAST2_FRAME } else { LAST_FRAME } as i8;
|
||||
} else {
|
||||
let ctx = self.ctx_comp_ref_p2();
|
||||
let p2 = self.sd.read_symbol(&mut self.cdf.comp_ref[ctx][2]);
|
||||
self.b.ref_frame[0] = if p2 == 1 { GOLDEN_FRAME } else { LAST3_FRAME } as i8;
|
||||
}
|
||||
let ctx = self.ctx_comp_bwdref();
|
||||
if self.sd.read_symbol(&mut self.cdf.comp_bwd_ref[ctx][0]) == 0 {
|
||||
let ctx = self.ctx_comp_bwdref_p1();
|
||||
let p1 = self.sd.read_symbol(&mut self.cdf.comp_bwd_ref[ctx][1]);
|
||||
self.b.ref_frame[1] = if p1 == 1 { ALTREF2_FRAME } else { BWDREF_FRAME } as i8;
|
||||
} else {
|
||||
self.b.ref_frame[1] = ALTREF_FRAME as i8;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let ctx = self.ctx_single_ref_p1();
|
||||
if self.sd.read_symbol(&mut self.cdf.single_ref[ctx][0]) == 1 {
|
||||
let ctx = self.ctx_comp_bwdref();
|
||||
if self.sd.read_symbol(&mut self.cdf.single_ref[ctx][1]) == 0 {
|
||||
let ctx = self.ctx_comp_bwdref_p1();
|
||||
let p6 = self.sd.read_symbol(&mut self.cdf.single_ref[ctx][5]);
|
||||
self.b.ref_frame[0] = if p6 == 1 { ALTREF2_FRAME } else { BWDREF_FRAME } as i8;
|
||||
} else {
|
||||
self.b.ref_frame[0] = ALTREF_FRAME as i8;
|
||||
}
|
||||
} else {
|
||||
let ctx = self.ctx_comp_ref();
|
||||
if self.sd.read_symbol(&mut self.cdf.single_ref[ctx][2]) == 1 {
|
||||
let ctx = self.ctx_comp_ref_p2();
|
||||
let p5 = self.sd.read_symbol(&mut self.cdf.single_ref[ctx][4]);
|
||||
self.b.ref_frame[0] = if p5 == 1 { GOLDEN_FRAME } else { LAST3_FRAME } as i8;
|
||||
} else {
|
||||
let ctx = self.ctx_comp_ref_p1();
|
||||
let p4 = self.sd.read_symbol(&mut self.cdf.single_ref[ctx][3]);
|
||||
self.b.ref_frame[0] = if p4 == 1 { LAST2_FRAME } else { LAST_FRAME } as i8;
|
||||
}
|
||||
}
|
||||
self.b.ref_frame[1] = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_backward(r: i8) -> bool {
|
||||
r >= BWDREF_FRAME as i8 && r <= ALTREF_FRAME as i8
|
||||
}
|
||||
|
||||
fn comp_mode_ctx(&self) -> usize {
|
||||
let n = self.nb;
|
||||
let (au, al) = (self.b.avail_u, self.b.avail_l);
|
||||
if au && al {
|
||||
if n.above_single && n.left_single {
|
||||
(Self::check_backward(n.above[0]) ^ Self::check_backward(n.left[0])) as usize
|
||||
} else if n.above_single {
|
||||
2 + (Self::check_backward(n.above[0]) || n.above_intra) as usize
|
||||
} else if n.left_single {
|
||||
2 + (Self::check_backward(n.left[0]) || n.left_intra) as usize
|
||||
} else {
|
||||
4
|
||||
}
|
||||
} else if au {
|
||||
if n.above_single { Self::check_backward(n.above[0]) as usize } else { 3 }
|
||||
} else if al {
|
||||
if n.left_single { Self::check_backward(n.left[0]) as usize } else { 3 }
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn comp_ref_type_ctx(&self) -> usize {
|
||||
let n = self.nb;
|
||||
let (au, al) = (self.b.avail_u, self.b.avail_l);
|
||||
let samedir = |a: i8, b: i8| (a >= BWDREF_FRAME as i8) == (b >= BWDREF_FRAME as i8);
|
||||
let (above0, above1, left0, left1) = (n.above[0], n.above[1], n.left[0], n.left[1]);
|
||||
let above_comp_inter = au && !n.above_intra && !n.above_single;
|
||||
let left_comp_inter = al && !n.left_intra && !n.left_single;
|
||||
let above_uni = above_comp_inter && samedir(above0, above1);
|
||||
let left_uni = left_comp_inter && samedir(left0, left1);
|
||||
if au && !n.above_intra && al && !n.left_intra {
|
||||
let sd = samedir(above0, left0) as usize;
|
||||
if !above_comp_inter && !left_comp_inter {
|
||||
1 + 2 * sd
|
||||
} else if !above_comp_inter {
|
||||
if !left_uni { 1 } else { 3 + sd }
|
||||
} else if !left_comp_inter {
|
||||
if !above_uni { 1 } else { 3 + sd }
|
||||
} else if !above_uni && !left_uni {
|
||||
0
|
||||
} else if !above_uni || !left_uni {
|
||||
2
|
||||
} else {
|
||||
3 + ((above0 == BWDREF_FRAME as i8) == (left0 == BWDREF_FRAME as i8)) as usize
|
||||
}
|
||||
} else if au && al {
|
||||
if above_comp_inter {
|
||||
1 + 2 * above_uni as usize
|
||||
} else if left_comp_inter {
|
||||
1 + 2 * left_uni as usize
|
||||
} else {
|
||||
2
|
||||
}
|
||||
} else if above_comp_inter {
|
||||
4 * above_uni as usize
|
||||
} else if left_comp_inter {
|
||||
4 * left_uni as usize
|
||||
} else {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
fn inter_block_mode_info(&mut self) -> Result<()> {
|
||||
self.b.palette_size_y = 0;
|
||||
self.b.palette_size_uv = 0;
|
||||
self.read_ref_frames();
|
||||
let is_compound = self.b.ref_frame[1] > INTRA_FRAME as i8;
|
||||
self.find_mv_stack(is_compound);
|
||||
if self.b.skip_mode {
|
||||
self.b.y_mode = NEAREST_NEARESTMV;
|
||||
} else if self.seg_active(SEG_LVL_SKIP) || self.seg_active(SEG_LVL_GLOBALMV) {
|
||||
self.b.y_mode = GLOBALMV;
|
||||
} else if is_compound {
|
||||
let ctx = COMPOUND_MODE_CTX_MAP[self.mvs.ref_mv_context >> 1][self.mvs.new_mv_context.min(COMP_NEWMV_CTXS - 1)] as usize;
|
||||
self.b.y_mode = NEAREST_NEARESTMV + self.sd.read_symbol(&mut self.cdf.compound_mode[ctx]);
|
||||
} else {
|
||||
let new_mv = self.sd.read_symbol(&mut self.cdf.new_mv[self.mvs.new_mv_context]);
|
||||
if new_mv == 0 {
|
||||
self.b.y_mode = NEWMV;
|
||||
} else {
|
||||
let zero_mv = self.sd.read_symbol(&mut self.cdf.zero_mv[self.mvs.zero_mv_context]);
|
||||
if zero_mv == 0 {
|
||||
self.b.y_mode = GLOBALMV;
|
||||
} else {
|
||||
let ref_mv = self.sd.read_symbol(&mut self.cdf.ref_mv[self.mvs.ref_mv_context]);
|
||||
self.b.y_mode = if ref_mv == 0 { NEARESTMV } else { NEARMV };
|
||||
}
|
||||
}
|
||||
}
|
||||
self.b.ref_mv_idx = 0;
|
||||
let ym = self.b.y_mode;
|
||||
if ym == NEWMV || ym == NEW_NEWMV {
|
||||
for idx in 0..2 {
|
||||
if self.mvs.num_mv_found > idx + 1 {
|
||||
let ctx = self.mvs.drl_ctx_stack[idx];
|
||||
let drl = self.sd.read_symbol(&mut self.cdf.drl_mode[ctx]);
|
||||
if drl == 0 {
|
||||
self.b.ref_mv_idx = idx;
|
||||
break;
|
||||
}
|
||||
self.b.ref_mv_idx = idx + 1;
|
||||
}
|
||||
}
|
||||
} else if matches!(ym, NEARMV | NEAR_NEARMV | NEAR_NEWMV | NEW_NEARMV) {
|
||||
self.b.ref_mv_idx = 1;
|
||||
for idx in 1..3 {
|
||||
if self.mvs.num_mv_found > idx + 1 {
|
||||
let ctx = self.mvs.drl_ctx_stack[idx];
|
||||
let drl = self.sd.read_symbol(&mut self.cdf.drl_mode[ctx]);
|
||||
if drl == 0 {
|
||||
self.b.ref_mv_idx = idx;
|
||||
break;
|
||||
}
|
||||
self.b.ref_mv_idx = idx + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assign_mv(is_compound)?;
|
||||
self.read_interintra_mode(is_compound);
|
||||
self.read_motion_mode(is_compound);
|
||||
self.read_compound_type(is_compound);
|
||||
if self.fs.fh.interpolation_filter == SWITCHABLE {
|
||||
let dual = self.fs.seq.enable_dual_filter;
|
||||
for dir in 0..if dual { 2 } else { 1 } {
|
||||
if self.needs_interp_filter() {
|
||||
let ctx = self.interp_filter_ctx(dir);
|
||||
self.b.interp_filter[dir] = self.sd.read_symbol(&mut self.cdf.interp_filter[ctx]) as u8;
|
||||
} else {
|
||||
self.b.interp_filter[dir] = EIGHTTAP;
|
||||
}
|
||||
}
|
||||
if !dual {
|
||||
self.b.interp_filter[1] = self.b.interp_filter[0];
|
||||
}
|
||||
} else {
|
||||
self.b.interp_filter = [self.fs.fh.interpolation_filter; 2];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn needs_interp_filter(&self) -> bool {
|
||||
let ms = self.b.mi_size;
|
||||
let large = block_width(ms).min(block_height(ms)) >= 8;
|
||||
let gm = &self.fs.fh.gm_type;
|
||||
if self.b.skip_mode || self.b.motion_mode as usize == LOCALWARP {
|
||||
false
|
||||
} else if large && self.b.y_mode == GLOBALMV {
|
||||
gm[self.b.ref_frame[0] as usize] as usize == TRANSLATION
|
||||
} else if large && self.b.y_mode == GLOBAL_GLOBALMV {
|
||||
gm[self.b.ref_frame[0] as usize] as usize == TRANSLATION || gm[self.b.ref_frame[1] as usize] as usize == TRANSLATION
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn interp_filter_ctx(&self, dir: usize) -> usize {
|
||||
let mut ctx = ((dir & 1) * 2 + (self.b.ref_frame[1] > INTRA_FRAME as i8) as usize) * 4;
|
||||
let mut left_type = 3u8;
|
||||
let mut above_type = 3u8;
|
||||
let (r, c) = (self.b.mi_row, self.b.mi_col);
|
||||
let mi = &self.t.mi;
|
||||
let rf0 = self.b.ref_frame[0];
|
||||
if self.b.avail_l {
|
||||
let i = mi.idx(r, c - 1);
|
||||
if mi.ref_frame[i][0] == rf0 || mi.ref_frame[i][1] == rf0 {
|
||||
left_type = mi.interp_filter[i][dir];
|
||||
}
|
||||
}
|
||||
if self.b.avail_u {
|
||||
let i = mi.idx(r - 1, c);
|
||||
if mi.ref_frame[i][0] == rf0 || mi.ref_frame[i][1] == rf0 {
|
||||
above_type = mi.interp_filter[i][dir];
|
||||
}
|
||||
}
|
||||
ctx += if left_type == above_type {
|
||||
left_type as usize
|
||||
} else if left_type == 3 {
|
||||
above_type as usize
|
||||
} else if above_type == 3 {
|
||||
left_type as usize
|
||||
} else {
|
||||
3
|
||||
};
|
||||
ctx
|
||||
}
|
||||
|
||||
fn get_mode(&self, ref_list: usize) -> usize {
|
||||
let ym = self.b.y_mode;
|
||||
if ref_list == 0 {
|
||||
if ym < NEAREST_NEARESTMV {
|
||||
ym
|
||||
} else if matches!(ym, NEW_NEWMV | NEW_NEARESTMV | NEW_NEARMV) {
|
||||
NEWMV
|
||||
} else if matches!(ym, NEAREST_NEARESTMV | NEAREST_NEWMV) {
|
||||
NEARESTMV
|
||||
} else if matches!(ym, NEAR_NEARMV | NEAR_NEWMV) {
|
||||
NEARMV
|
||||
} else {
|
||||
GLOBALMV
|
||||
}
|
||||
} else if matches!(ym, NEW_NEWMV | NEAREST_NEWMV | NEAR_NEWMV) {
|
||||
NEWMV
|
||||
} else if matches!(ym, NEAREST_NEARESTMV | NEW_NEARESTMV) {
|
||||
NEARESTMV
|
||||
} else if matches!(ym, NEAR_NEARMV | NEW_NEARMV) {
|
||||
NEARMV
|
||||
} else {
|
||||
GLOBALMV
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn assign_mv(&mut self, is_compound: bool) -> Result<()> {
|
||||
for i in 0..1 + is_compound as usize {
|
||||
let comp_mode = if self.b.use_intrabc { NEWMV } else { self.get_mode(i) };
|
||||
let pred: MvP = if self.b.use_intrabc {
|
||||
let mut p = self.mvs.ref_stack_mv[0][0];
|
||||
if p == [0, 0] {
|
||||
p = self.mvs.ref_stack_mv[1][0];
|
||||
}
|
||||
if p == [0, 0] {
|
||||
let sb_size = if self.fs.seq.use_128x128_superblock { BLOCK_128X128 } else { BLOCK_64X64 };
|
||||
let sb4 = NUM_4X4_BLOCKS_HIGH[sb_size] as i32;
|
||||
if (self.b.mi_row as i32) - sb4 < self.mi_row_start as i32 {
|
||||
p = [0, -(sb4 * 4 + INTRABC_DELAY_PIXELS as i32) * 8];
|
||||
} else {
|
||||
p = [-(sb4 * 4 * 8), 0];
|
||||
}
|
||||
}
|
||||
p
|
||||
} else if comp_mode == GLOBALMV {
|
||||
self.mvs.global_mvs[i]
|
||||
} else {
|
||||
let mut pos = if comp_mode == NEARESTMV { 0 } else { self.b.ref_mv_idx };
|
||||
if comp_mode == NEWMV && self.mvs.num_mv_found <= 1 {
|
||||
pos = 0;
|
||||
}
|
||||
self.mvs.ref_stack_mv[pos][i]
|
||||
};
|
||||
self.b.pred_mv[i] = pred;
|
||||
if comp_mode == NEWMV {
|
||||
self.read_mv(i);
|
||||
} else {
|
||||
self.b.mv_i[i] = pred;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_mv(&mut self, r: usize) {
|
||||
let mut diff = [0i32; 2];
|
||||
let ctx = if self.b.use_intrabc { MV_INTRABC_CONTEXT } else { 0 };
|
||||
let joint = self.sd.read_symbol(&mut self.cdf.mv_joint[ctx]);
|
||||
if joint == MV_JOINT_HZVNZ || joint == MV_JOINT_HNZVNZ {
|
||||
diff[0] = self.read_mv_component(ctx, 0);
|
||||
}
|
||||
if joint == MV_JOINT_HNZVZ || joint == MV_JOINT_HNZVNZ {
|
||||
diff[1] = self.read_mv_component(ctx, 1);
|
||||
}
|
||||
self.b.mv_i[r] = [self.b.pred_mv[r][0] + diff[0], self.b.pred_mv[r][1] + diff[1]];
|
||||
}
|
||||
|
||||
fn read_mv_component(&mut self, ctx: usize, comp: usize) -> i32 {
|
||||
let fh = &self.fs.fh;
|
||||
let (fim, hp) = (fh.force_integer_mv, fh.allow_high_precision_mv);
|
||||
let sign = self.sd.read_symbol(&mut self.cdf.mv_sign[ctx][comp]);
|
||||
let class = self.sd.read_symbol(&mut self.cdf.mv_class[ctx][comp]);
|
||||
let mag = if class == MV_CLASS_0 {
|
||||
let b0 = self.sd.read_symbol(&mut self.cdf.mv_class0_bit[ctx][comp]) as i32;
|
||||
let fr = if fim { 3 } else { self.sd.read_symbol(&mut self.cdf.mv_class0_fr[ctx][comp][b0 as usize]) as i32 };
|
||||
let h = if hp { self.sd.read_symbol(&mut self.cdf.mv_class0_hp[ctx][comp]) as i32 } else { 1 };
|
||||
((b0 << 3) | (fr << 1) | h) + 1
|
||||
} else {
|
||||
let mut d = 0i32;
|
||||
for i in 0..class {
|
||||
d |= (self.sd.read_symbol(&mut self.cdf.mv_bit[ctx][comp][i]) as i32) << i;
|
||||
}
|
||||
let mut mag = (CLASS0_SIZE as i32) << (class + 2);
|
||||
let fr = if fim { 3 } else { self.sd.read_symbol(&mut self.cdf.mv_fr[ctx][comp]) as i32 };
|
||||
let h = if hp { self.sd.read_symbol(&mut self.cdf.mv_hp[ctx][comp]) as i32 } else { 1 };
|
||||
mag += ((d << 3) | (fr << 1) | h) + 1;
|
||||
mag
|
||||
};
|
||||
if sign == 1 { -mag } else { mag }
|
||||
}
|
||||
|
||||
fn read_interintra_mode(&mut self, is_compound: bool) {
|
||||
let ms = self.b.mi_size;
|
||||
self.b.interintra = false;
|
||||
if !self.b.skip_mode && self.fs.seq.enable_interintra_compound && !is_compound && (BLOCK_8X8..=BLOCK_32X32).contains(&ms) {
|
||||
let ctx = SIZE_GROUP[ms] as usize - 1;
|
||||
self.b.interintra = self.sd.read_symbol(&mut self.cdf.inter_intra[ctx]) == 1;
|
||||
if self.b.interintra {
|
||||
self.b.interintra_mode = self.sd.read_symbol(&mut self.cdf.inter_intra_mode[ctx]);
|
||||
self.b.ref_frame[1] = INTRA_FRAME as i8;
|
||||
self.b.angle_delta_y = 0;
|
||||
self.b.angle_delta_uv = 0;
|
||||
self.b.use_filter_intra = false;
|
||||
self.b.wedge_interintra = self.sd.read_symbol(&mut self.cdf.wedge_inter_intra[ms]) == 1;
|
||||
if self.b.wedge_interintra {
|
||||
self.b.wedge_index = self.sd.read_symbol(&mut self.cdf.wedge_index[ms]);
|
||||
self.b.wedge_sign = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_scaled(&self, rf: i8) -> bool {
|
||||
let fh = &self.fs.fh;
|
||||
let ri = &self.fs.ref_info[fh.ref_frame_idx[rf as usize - LAST_FRAME]];
|
||||
let fw = fh.frame_width;
|
||||
let fhh = fh.frame_height;
|
||||
let xs = ((ri.upscaled_width << REF_SCALE_SHIFT) + fw / 2) / fw;
|
||||
let ys = ((ri.frame_height << REF_SCALE_SHIFT) + fhh / 2) / fhh;
|
||||
let no = 1 << REF_SCALE_SHIFT;
|
||||
xs != no || ys != no
|
||||
}
|
||||
|
||||
fn read_motion_mode(&mut self, is_compound: bool) {
|
||||
self.b.motion_mode = SIMPLE as u8;
|
||||
let fh = &self.fs.fh;
|
||||
if self.b.skip_mode || !fh.is_motion_mode_switchable {
|
||||
return;
|
||||
}
|
||||
let ms = self.b.mi_size;
|
||||
if block_width(ms).min(block_height(ms)) < 8 {
|
||||
return;
|
||||
}
|
||||
if !fh.force_integer_mv
|
||||
&& (self.b.y_mode == GLOBALMV || self.b.y_mode == GLOBAL_GLOBALMV)
|
||||
&& fh.gm_type[self.b.ref_frame[0] as usize] as usize > TRANSLATION
|
||||
{
|
||||
return;
|
||||
}
|
||||
if is_compound || self.b.ref_frame[1] == INTRA_FRAME as i8 || !self.has_overlappable_candidates() {
|
||||
return;
|
||||
}
|
||||
self.find_warp_samples();
|
||||
let fh = &self.fs.fh;
|
||||
if fh.force_integer_mv || self.mvs.num_samples == 0 || !fh.allow_warped_motion || self.is_scaled(self.b.ref_frame[0]) {
|
||||
let use_obmc = self.sd.read_symbol(&mut self.cdf.use_obmc[ms]);
|
||||
self.b.motion_mode = if use_obmc == 1 { OBMC as u8 } else { SIMPLE as u8 };
|
||||
} else {
|
||||
self.b.motion_mode = self.sd.read_symbol(&mut self.cdf.motion_mode[ms]) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
fn read_compound_type(&mut self, is_compound: bool) {
|
||||
self.b.comp_group_idx = 0;
|
||||
self.b.compound_idx = 1;
|
||||
if self.b.skip_mode {
|
||||
self.b.compound_type = COMPOUND_AVERAGE;
|
||||
return;
|
||||
}
|
||||
if is_compound {
|
||||
let ms = self.b.mi_size;
|
||||
let n = WEDGE_BITS[ms];
|
||||
if self.fs.seq.enable_masked_compound {
|
||||
let ctx = self.comp_group_idx_ctx();
|
||||
self.b.comp_group_idx = self.sd.read_symbol(&mut self.cdf.comp_group_idx[ctx]) as u8;
|
||||
}
|
||||
if self.b.comp_group_idx == 0 {
|
||||
if self.fs.seq.enable_jnt_comp {
|
||||
let ctx = self.compound_idx_ctx();
|
||||
self.b.compound_idx = self.sd.read_symbol(&mut self.cdf.compound_idx[ctx]) as u8;
|
||||
self.b.compound_type = if self.b.compound_idx == 1 { COMPOUND_AVERAGE } else { COMPOUND_DISTANCE };
|
||||
} else {
|
||||
self.b.compound_type = COMPOUND_AVERAGE;
|
||||
}
|
||||
} else if n == 0 {
|
||||
self.b.compound_type = COMPOUND_DIFFWTD;
|
||||
} else {
|
||||
self.b.compound_type = self.sd.read_symbol(&mut self.cdf.compound_type[ms]);
|
||||
}
|
||||
if self.b.compound_type == COMPOUND_WEDGE {
|
||||
self.b.wedge_index = self.sd.read_symbol(&mut self.cdf.wedge_index[ms]);
|
||||
self.b.wedge_sign = self.sd.read_literal(1) as usize;
|
||||
} else if self.b.compound_type == COMPOUND_DIFFWTD {
|
||||
self.b.mask_type = self.sd.read_literal(1) as usize;
|
||||
}
|
||||
} else if self.b.interintra {
|
||||
self.b.compound_type = if self.b.wedge_interintra { COMPOUND_WEDGE } else { COMPOUND_INTRA };
|
||||
} else {
|
||||
self.b.compound_type = COMPOUND_AVERAGE;
|
||||
}
|
||||
}
|
||||
|
||||
fn comp_group_idx_ctx(&self) -> usize {
|
||||
let n = self.nb;
|
||||
let mi = &self.t.mi;
|
||||
let (r, c) = (self.b.mi_row, self.b.mi_col);
|
||||
let mut ctx = 0;
|
||||
if self.b.avail_u {
|
||||
if !n.above_single {
|
||||
ctx += mi.comp_group_idx[mi.idx(r - 1, c)] as usize;
|
||||
} else if n.above[0] == ALTREF_FRAME as i8 {
|
||||
ctx += 3;
|
||||
}
|
||||
}
|
||||
if self.b.avail_l {
|
||||
if !n.left_single {
|
||||
ctx += mi.comp_group_idx[mi.idx(r, c - 1)] as usize;
|
||||
} else if n.left[0] == ALTREF_FRAME as i8 {
|
||||
ctx += 3;
|
||||
}
|
||||
}
|
||||
ctx.min(5)
|
||||
}
|
||||
|
||||
fn compound_idx_ctx(&self) -> usize {
|
||||
let fh = &self.fs.fh;
|
||||
let seq = &self.fs.seq;
|
||||
let fwd = crate::header::relative_dist(seq, fh.order_hints[self.b.ref_frame[0] as usize], fh.order_hint).abs();
|
||||
let bck = crate::header::relative_dist(seq, fh.order_hints[self.b.ref_frame[1] as usize], fh.order_hint).abs();
|
||||
let mut ctx = if fwd == bck { 3 } else { 0 };
|
||||
let n = self.nb;
|
||||
let mi = &self.t.mi;
|
||||
let (r, c) = (self.b.mi_row, self.b.mi_col);
|
||||
if self.b.avail_u {
|
||||
if !n.above_single {
|
||||
ctx += mi.compound_idx[mi.idx(r - 1, c)] as usize;
|
||||
} else if n.above[0] == ALTREF_FRAME as i8 {
|
||||
ctx += 1;
|
||||
}
|
||||
}
|
||||
if self.b.avail_l {
|
||||
if !n.left_single {
|
||||
ctx += mi.compound_idx[mi.idx(r, c - 1)] as usize;
|
||||
} else if n.left[0] == ALTREF_FRAME as i8 {
|
||||
ctx += 1;
|
||||
}
|
||||
}
|
||||
ctx
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::spec_tables::{
|
||||
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
|
||||
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
|
||||
SMOOTH_V_PRED, V_PRED,
|
||||
};
|
||||
use crate::transform::round2;
|
||||
|
||||
/// Inputs of the intra prediction process that come from the block.
|
||||
pub(crate) struct IntraParams {
|
||||
pub plane: usize,
|
||||
pub x: usize,
|
||||
pub y: usize,
|
||||
pub have_left: bool,
|
||||
pub have_above: bool,
|
||||
pub have_above_right: bool,
|
||||
pub have_below_left: bool,
|
||||
pub mode: usize,
|
||||
pub log2w: u32,
|
||||
pub log2h: u32,
|
||||
/// Largest valid x / y in the plane: ((MiCols * MI_SIZE) >> subX) - 1.
|
||||
pub max_x: i32,
|
||||
pub max_y: i32,
|
||||
pub bit_depth: u32,
|
||||
pub angle_delta: i32,
|
||||
pub use_filter_intra: bool,
|
||||
pub filter_intra_mode: usize,
|
||||
pub enable_intra_edge_filter: bool,
|
||||
/// Result of the intra filter type process (7.11.2.8).
|
||||
pub filter_type: bool,
|
||||
}
|
||||
|
||||
const EDGE: usize = 32; // offset of index 0 in the edge buffers (they start at index -2 .. -1)
|
||||
|
||||
#[inline(always)]
|
||||
fn clip1(v: i32, bd: u32) -> u16 {
|
||||
v.clamp(0, (1 << bd) - 1) as u16
|
||||
}
|
||||
|
||||
pub(crate) fn is_directional_mode(mode: usize) -> bool {
|
||||
(V_PRED..=D67_PRED).contains(&mode)
|
||||
}
|
||||
|
||||
/// Predict the w x h block at (x, y) of `pl` (writes into the plane).
|
||||
pub(crate) fn predict_intra(pl: &mut Plane, p: &IntraParams) {
|
||||
let w = 1usize << p.log2w;
|
||||
let h = 1usize << p.log2h;
|
||||
let bd = p.bit_depth;
|
||||
let (x, y) = (p.x, p.y);
|
||||
// edge arrays with room for index -2 .. 2*(w+h)
|
||||
let mut above = [0i32; 2 * 128 + 2 * EDGE];
|
||||
let mut left = [0i32; 2 * 128 + 2 * EDGE];
|
||||
let n = w + h;
|
||||
if !p.have_above && p.have_left {
|
||||
let v = pl.at(x - 1, y) as i32;
|
||||
above[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
|
||||
} else if !p.have_above && !p.have_left {
|
||||
let v = (1 << (bd - 1)) - 1;
|
||||
above[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
|
||||
} else {
|
||||
let above_limit = (p.max_x).min(x as i32 + if p.have_above_right { 2 * w as i32 } else { w as i32 } - 1);
|
||||
let row = pl.row_from(y - 1, x);
|
||||
for i in 0..n {
|
||||
above[EDGE + i] = row[(above_limit.min(x as i32 + i as i32)) as usize - x] as i32;
|
||||
}
|
||||
}
|
||||
if !p.have_left && p.have_above {
|
||||
let v = pl.at(x, y - 1) as i32;
|
||||
left[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
|
||||
} else if !p.have_left && !p.have_above {
|
||||
let v = (1 << (bd - 1)) + 1;
|
||||
left[EDGE..EDGE + n].iter_mut().for_each(|a| *a = v);
|
||||
} else {
|
||||
let left_limit = (p.max_y).min(y as i32 + if p.have_below_left { 2 * h as i32 } else { h as i32 } - 1);
|
||||
for i in 0..n {
|
||||
left[EDGE + i] = pl.at(x - 1, left_limit.min(y as i32 + i as i32) as usize) as i32;
|
||||
}
|
||||
}
|
||||
let corner = if p.have_above && p.have_left {
|
||||
pl.at(x - 1, y - 1) as i32
|
||||
} else if p.have_above {
|
||||
pl.at(x, y - 1) as i32
|
||||
} else if p.have_left {
|
||||
pl.at(x - 1, y) as i32
|
||||
} else {
|
||||
1 << (bd - 1)
|
||||
};
|
||||
above[EDGE - 1] = corner;
|
||||
left[EDGE - 1] = corner;
|
||||
|
||||
let mut pred = [0u16; 64 * 64];
|
||||
if p.plane == 0 && p.use_filter_intra {
|
||||
recursive_intra(&above, &left, w, h, p.filter_intra_mode, bd, &mut pred);
|
||||
} else if is_directional_mode(p.mode) {
|
||||
directional(&mut above, &mut left, p, w, h, &mut pred);
|
||||
} else if p.mode == SMOOTH_PRED || p.mode == SMOOTH_V_PRED || p.mode == SMOOTH_H_PRED {
|
||||
smooth(&above, &left, p.mode, p.log2w, p.log2h, w, h, &mut pred);
|
||||
} else if p.mode == DC_PRED {
|
||||
dc(&above, &left, p.have_left, p.have_above, p.log2w, p.log2h, w, h, bd, &mut pred);
|
||||
} else {
|
||||
// PAETH_PRED
|
||||
let tl = above[EDGE - 1];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let a = above[EDGE + j];
|
||||
let l = left[EDGE + i];
|
||||
let base = a + l - tl;
|
||||
let p_left = (base - l).abs();
|
||||
let p_top = (base - a).abs();
|
||||
let p_tl = (base - tl).abs();
|
||||
pred[i * w + j] = if p_left <= p_top && p_left <= p_tl {
|
||||
l as u16
|
||||
} else if p_top <= p_tl {
|
||||
a as u16
|
||||
} else {
|
||||
tl as u16
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
for i in 0..h {
|
||||
pl.row_from_mut(y + i, x)[..w].copy_from_slice(&pred[i * w..i * w + w]);
|
||||
}
|
||||
}
|
||||
|
||||
fn recursive_intra(above: &[i32], left: &[i32], w: usize, h: usize, mode: usize, bd: u32, pred: &mut [u16]) {
|
||||
let w4 = w >> 2;
|
||||
let h2 = h >> 1;
|
||||
for i2 in 0..h2 {
|
||||
for j4 in 0..w4 {
|
||||
let mut pv = [0i32; 7];
|
||||
for (i, v) in pv.iter_mut().enumerate() {
|
||||
*v = if i < 5 {
|
||||
if i2 == 0 {
|
||||
above[EDGE + (j4 << 2) + i - 1]
|
||||
} else if j4 == 0 && i == 0 {
|
||||
left[EDGE + (i2 << 1) - 1]
|
||||
} else {
|
||||
pred[((i2 << 1) - 1) * w + (j4 << 2) + i - 1] as i32
|
||||
}
|
||||
} else if j4 == 0 {
|
||||
left[EDGE + (i2 << 1) + i - 5]
|
||||
} else {
|
||||
pred[((i2 << 1) + i - 5) * w + (j4 << 2) - 1] as i32
|
||||
};
|
||||
}
|
||||
for i1 in 0..2 {
|
||||
for j1 in 0..4 {
|
||||
let mut pr = 0i32;
|
||||
for i in 0..7 {
|
||||
pr += INTRA_FILTER_TAPS[mode][(i1 << 2) + j1][i] as i32 * pv[i];
|
||||
}
|
||||
pred[((i2 << 1) + i1) * w + (j4 << 2) + j1] = clip1(round2_signed(pr, INTRA_FILTER_SCALE_BITS as u32), bd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn round2_signed(x: i32, n: u32) -> i32 {
|
||||
if x >= 0 { round2(x, n) } else { -round2(-x, n) }
|
||||
}
|
||||
|
||||
fn sm_weights(log2: u32) -> &'static [u8] {
|
||||
match log2 {
|
||||
2 => &SM_WEIGHTS_TX_4X4,
|
||||
3 => &SM_WEIGHTS_TX_8X8,
|
||||
4 => &SM_WEIGHTS_TX_16X16,
|
||||
5 => &SM_WEIGHTS_TX_32X32,
|
||||
_ => &SM_WEIGHTS_TX_64X64,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn smooth(above: &[i32], left: &[i32], mode: usize, log2w: u32, log2h: u32, w: usize, h: usize, pred: &mut [u16]) {
|
||||
let wx = sm_weights(log2w);
|
||||
let wy = sm_weights(log2h);
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let v = if mode == SMOOTH_PRED {
|
||||
let s = wy[i] as i32 * above[EDGE + j]
|
||||
+ (256 - wy[i] as i32) * left[EDGE + h - 1]
|
||||
+ wx[j] as i32 * left[EDGE + i]
|
||||
+ (256 - wx[j] as i32) * above[EDGE + w - 1];
|
||||
round2(s, 9)
|
||||
} else if mode == SMOOTH_V_PRED {
|
||||
round2(wy[i] as i32 * above[EDGE + j] + (256 - wy[i] as i32) * left[EDGE + h - 1], 8)
|
||||
} else {
|
||||
round2(wx[j] as i32 * left[EDGE + i] + (256 - wx[j] as i32) * above[EDGE + w - 1], 8)
|
||||
};
|
||||
pred[i * w + j] = v as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn dc(above: &[i32], left: &[i32], have_left: bool, have_above: bool, log2w: u32, log2h: u32, w: usize, h: usize, bd: u32, pred: &mut [u16]) {
|
||||
let v = if have_left && have_above {
|
||||
let mut sum: i32 = left[EDGE..EDGE + h].iter().sum::<i32>() + above[EDGE..EDGE + w].iter().sum::<i32>();
|
||||
sum += ((w + h) >> 1) as i32;
|
||||
sum / (w + h) as i32
|
||||
} else if have_left {
|
||||
let sum: i32 = left[EDGE..EDGE + h].iter().sum();
|
||||
(sum + (h >> 1) as i32) >> log2h
|
||||
} else if have_above {
|
||||
let sum: i32 = above[EDGE..EDGE + w].iter().sum();
|
||||
(sum + (w >> 1) as i32) >> log2w
|
||||
} else {
|
||||
1 << (bd - 1)
|
||||
};
|
||||
let v = clip1(v, bd);
|
||||
pred[..w * h].iter_mut().for_each(|p| *p = v);
|
||||
}
|
||||
|
||||
fn edge_filter_strength(w: usize, h: usize, filter_type: bool, delta: i32) -> u32 {
|
||||
let d = delta.abs();
|
||||
let blk_wh = w + h;
|
||||
let mut strength = 0;
|
||||
if !filter_type {
|
||||
if blk_wh <= 8 {
|
||||
if d >= 56 {
|
||||
strength = 1;
|
||||
}
|
||||
} else if blk_wh <= 16 {
|
||||
if d >= 40 {
|
||||
strength = 1;
|
||||
}
|
||||
} else if blk_wh <= 24 {
|
||||
if d >= 8 {
|
||||
strength = 1;
|
||||
}
|
||||
if d >= 16 {
|
||||
strength = 2;
|
||||
}
|
||||
if d >= 32 {
|
||||
strength = 3;
|
||||
}
|
||||
} else if blk_wh <= 32 {
|
||||
strength = 1;
|
||||
if d >= 4 {
|
||||
strength = 2;
|
||||
}
|
||||
if d >= 32 {
|
||||
strength = 3;
|
||||
}
|
||||
} else {
|
||||
strength = 3;
|
||||
}
|
||||
} else if blk_wh <= 8 {
|
||||
if d >= 40 {
|
||||
strength = 1;
|
||||
}
|
||||
if d >= 64 {
|
||||
strength = 2;
|
||||
}
|
||||
} else if blk_wh <= 16 {
|
||||
if d >= 20 {
|
||||
strength = 1;
|
||||
}
|
||||
if d >= 48 {
|
||||
strength = 2;
|
||||
}
|
||||
} else if blk_wh <= 24 {
|
||||
if d >= 4 {
|
||||
strength = 3;
|
||||
}
|
||||
} else {
|
||||
strength = 3;
|
||||
}
|
||||
strength
|
||||
}
|
||||
|
||||
fn use_upsample(w: usize, h: usize, filter_type: bool, delta: i32) -> bool {
|
||||
let d = delta.abs();
|
||||
let blk_wh = w + h;
|
||||
if d <= 0 || d >= 40 {
|
||||
false
|
||||
} else if !filter_type {
|
||||
blk_wh <= 16
|
||||
} else {
|
||||
blk_wh <= 8
|
||||
}
|
||||
}
|
||||
|
||||
/// Intra edge filter (7.11.2.12) on buf[-1 .. sz-2] (edge[i] = buf[i - 1]).
|
||||
fn edge_filter(buf: &mut [i32], sz: usize, strength: u32) {
|
||||
if strength == 0 {
|
||||
return;
|
||||
}
|
||||
let mut edge = [0i32; 2 * 128 + 2 * EDGE];
|
||||
for i in 0..sz {
|
||||
edge[i] = buf[EDGE + i - 1];
|
||||
}
|
||||
for i in 1..sz {
|
||||
let mut s = 0;
|
||||
for j in 0..INTRA_EDGE_TAPS {
|
||||
let k = (i as i32 - 2 + j as i32).clamp(0, sz as i32 - 1) as usize;
|
||||
s += INTRA_EDGE_KERNEL[strength as usize - 1][j] as i32 * edge[k];
|
||||
}
|
||||
buf[EDGE + i - 1] = (s + 8) >> 4;
|
||||
}
|
||||
}
|
||||
|
||||
/// Intra edge upsample (7.11.2.11).
|
||||
fn edge_upsample(buf: &mut [i32], num_px: usize, bd: u32) {
|
||||
let mut dup = [0i32; 2 * 128 + 8];
|
||||
dup[0] = buf[EDGE - 1];
|
||||
for i in -1..num_px as i32 {
|
||||
dup[(i + 2) as usize] = buf[(EDGE as i32 + i) as usize];
|
||||
}
|
||||
dup[num_px + 2] = buf[EDGE + num_px - 1];
|
||||
buf[EDGE - 2] = dup[0];
|
||||
for i in 0..num_px {
|
||||
let s = -dup[i] + 9 * dup[i + 1] + 9 * dup[i + 2] - dup[i + 3];
|
||||
let s = clip1(round2(s, 4), bd) as i32;
|
||||
buf[EDGE + 2 * i - 1] = s;
|
||||
buf[EDGE + 2 * i] = dup[i + 2];
|
||||
}
|
||||
}
|
||||
|
||||
fn directional(above: &mut [i32], left: &mut [i32], p: &IntraParams, w: usize, h: usize, pred: &mut [u16]) {
|
||||
let p_angle = MODE_TO_ANGLE[p.mode] as i32 + p.angle_delta * ANGLE_STEP as i32;
|
||||
let mut upsample_above = 0u32;
|
||||
let mut upsample_left = 0u32;
|
||||
if p.enable_intra_edge_filter {
|
||||
if p_angle != 90 && p_angle != 180 {
|
||||
if p_angle > 90 && p_angle < 180 && (w + h) >= 24 {
|
||||
let s = left[EDGE] * 5 + above[EDGE - 1] * 6 + above[EDGE] * 5;
|
||||
let c = round2(s, 4);
|
||||
left[EDGE - 1] = c;
|
||||
above[EDGE - 1] = c;
|
||||
}
|
||||
if p.have_above {
|
||||
let strength = edge_filter_strength(w, h, p.filter_type, p_angle - 90);
|
||||
let num_px = w.min((p.max_x - p.x as i32 + 1) as usize) + if p_angle < 90 { h } else { 0 } + 1;
|
||||
edge_filter(above, num_px, strength);
|
||||
}
|
||||
if p.have_left {
|
||||
let strength = edge_filter_strength(w, h, p.filter_type, p_angle - 180);
|
||||
let num_px = h.min((p.max_y - p.y as i32 + 1) as usize) + if p_angle > 180 { w } else { 0 } + 1;
|
||||
edge_filter(left, num_px, strength);
|
||||
}
|
||||
}
|
||||
if use_upsample(w, h, p.filter_type, p_angle - 90) {
|
||||
upsample_above = 1;
|
||||
let num_px = w + if p_angle < 90 { h } else { 0 };
|
||||
edge_upsample(above, num_px, p.bit_depth);
|
||||
}
|
||||
if use_upsample(w, h, p.filter_type, p_angle - 180) {
|
||||
upsample_left = 1;
|
||||
let num_px = h + if p_angle > 180 { w } else { 0 };
|
||||
edge_upsample(left, num_px, p.bit_depth);
|
||||
}
|
||||
}
|
||||
let dx = if p_angle < 90 {
|
||||
DR_INTRA_DERIVATIVE[p_angle as usize] as i32
|
||||
} else if p_angle > 90 && p_angle < 180 {
|
||||
DR_INTRA_DERIVATIVE[(180 - p_angle) as usize] as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let dy = if p_angle > 90 && p_angle < 180 {
|
||||
DR_INTRA_DERIVATIVE[(p_angle - 90) as usize] as i32
|
||||
} else if p_angle > 180 {
|
||||
DR_INTRA_DERIVATIVE[(270 - p_angle) as usize] as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let a = |i: i32| above[(EDGE as i32 + i) as usize];
|
||||
let l = |i: i32| left[(EDGE as i32 + i) as usize];
|
||||
for i in 0..h as i32 {
|
||||
for j in 0..w as i32 {
|
||||
let v = if p_angle < 90 {
|
||||
let idx = (i + 1) * dx;
|
||||
let base = (idx >> (6 - upsample_above)) + (j << upsample_above);
|
||||
let shift = ((idx << upsample_above) >> 1) & 0x1f;
|
||||
let max_base_x = (w as i32 + h as i32 - 1) << upsample_above;
|
||||
if base < max_base_x { round2(a(base) * (32 - shift) + a(base + 1) * shift, 5) } else { a(max_base_x) }
|
||||
} else if p_angle > 90 && p_angle < 180 {
|
||||
let idx = (j << 6) - (i + 1) * dx;
|
||||
let base = idx >> (6 - upsample_above);
|
||||
if base >= -(1 << upsample_above) {
|
||||
let shift = ((idx << upsample_above) >> 1) & 0x1f;
|
||||
round2(a(base) * (32 - shift) + a(base + 1) * shift, 5)
|
||||
} else {
|
||||
let idx = (i << 6) - (j + 1) * dy;
|
||||
let base = idx >> (6 - upsample_left);
|
||||
let shift = ((idx << upsample_left) >> 1) & 0x1f;
|
||||
round2(l(base) * (32 - shift) + l(base + 1) * shift, 5)
|
||||
}
|
||||
} else if p_angle > 180 {
|
||||
let idx = (j + 1) * dy;
|
||||
let base = (idx >> (6 - upsample_left)) + (i << upsample_left);
|
||||
let shift = ((idx << upsample_left) >> 1) & 0x1f;
|
||||
round2(l(base) * (32 - shift) + l(base + 1) * shift, 5)
|
||||
} else if p_angle == 90 {
|
||||
a(j)
|
||||
} else {
|
||||
l(i)
|
||||
};
|
||||
pred[(i as usize) * w + j as usize] = v as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
//! Clean-room AV1 decoder, implemented from the AV1 Bitstream & Decoding Process Specification
|
||||
//! v1.0.0 with Errata 1 (AOMediaCodec/av1-spec).
|
||||
//!
|
||||
//! See the crate README for the conformance status of each decoding stage.
|
||||
|
||||
// Items used only by stages still being written (inter prediction, post filters).
|
||||
// Spec-style indexed loops (FilmCraft allows this lint workspace-wide).
|
||||
#![allow(clippy::needless_range_loop)]
|
||||
#![allow(dead_code)]
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod bits;
|
||||
mod cdef;
|
||||
mod cdf;
|
||||
mod decoder;
|
||||
mod frame;
|
||||
mod grain;
|
||||
pub mod header;
|
||||
mod inter;
|
||||
mod inter_info;
|
||||
mod intra;
|
||||
mod mvpred;
|
||||
mod par;
|
||||
mod postfilter;
|
||||
mod restoration;
|
||||
mod spec_tables;
|
||||
mod state;
|
||||
mod stats;
|
||||
mod symbol;
|
||||
mod tile;
|
||||
mod transform;
|
||||
|
||||
pub use decoder::Decoder;
|
||||
pub use header::{ColorConfig, FrameHeader, SequenceHeader};
|
||||
pub use stats::{DecodeStats, Stage};
|
||||
|
||||
/// Decoder errors.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("truncated AV1 data")]
|
||||
Truncated,
|
||||
#[error("invalid AV1 data: {0}")]
|
||||
Invalid(&'static str),
|
||||
#[error("unsupported AV1 feature: {0}")]
|
||||
Unsupported(&'static str),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A decoded (shown) frame: planar samples without row padding, `bit_depth` significant bits in
|
||||
/// `u16`. Chroma planes are `((width + ssx) >> ssx) x ((height + ssy) >> ssy)`; they are empty
|
||||
/// for monochrome streams.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Picture {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub bit_depth: u8,
|
||||
pub subsampling_x: u8,
|
||||
pub subsampling_y: u8,
|
||||
pub mono_chrome: bool,
|
||||
pub planes: [Vec<u16>; 3],
|
||||
pub color_primaries: u8,
|
||||
pub transfer_characteristics: u8,
|
||||
pub matrix_coefficients: u8,
|
||||
pub full_range: bool,
|
||||
/// Timestamp of the temporal unit that showed the picture ([`Decoder::decode_pts`]).
|
||||
pub pts: i64,
|
||||
/// Decoded in draft mode without deblocking / CDEF / loop restoration
|
||||
/// ([`Decoder::set_draft`]): approximate, for reduced-resolution playback only.
|
||||
pub draft: bool,
|
||||
}
|
||||
|
||||
impl Picture {
|
||||
pub fn plane_width(&self, plane: usize) -> u32 {
|
||||
if plane == 0 { self.width } else { (self.width + self.subsampling_x as u32) >> self.subsampling_x }
|
||||
}
|
||||
pub fn plane_height(&self, plane: usize) -> u32 {
|
||||
if plane == 0 { self.height } else { (self.height + self.subsampling_y as u32) >> self.subsampling_y }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a temporal unit starts a random access point: its first frame is a shown key frame
|
||||
/// (used for keyframe detection: containers may flag samples as sync that are not).
|
||||
pub fn is_key_frame_unit(data: &[u8]) -> bool {
|
||||
let mut pos = 0;
|
||||
let mut seq_seen = false;
|
||||
while pos < data.len() {
|
||||
let h = data[pos];
|
||||
let obu_type = (h >> 3) & 0xf;
|
||||
let ext = h & 4 != 0;
|
||||
let has_size = h & 2 != 0;
|
||||
let mut p = pos + 1 + ext as usize;
|
||||
let size = if has_size {
|
||||
match bits::leb128(data.get(p..).unwrap_or(&[])) {
|
||||
Ok((v, n)) => {
|
||||
p += n;
|
||||
v as usize
|
||||
}
|
||||
Err(_) => return false,
|
||||
}
|
||||
} else {
|
||||
data.len().saturating_sub(p)
|
||||
};
|
||||
if p > data.len() {
|
||||
return false;
|
||||
}
|
||||
match obu_type {
|
||||
1 => seq_seen = true,
|
||||
3 | 6 => {
|
||||
let b = data.get(p).copied().unwrap_or(0);
|
||||
// show_existing_frame = 0, frame_type = KEY_FRAME (0), show_frame = 1
|
||||
let show_existing = b >> 7;
|
||||
let frame_type = (b >> 5) & 3;
|
||||
let show_frame = (b >> 4) & 1;
|
||||
let _ = seq_seen;
|
||||
return show_existing == 0 && frame_type == 0 && show_frame == 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
pos = p.saturating_add(size);
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
|
||||
//! and find_warp_samples.
|
||||
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
|
||||
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
pub(crate) type MvP = [i32; 2];
|
||||
pub(crate) const INVALID_MV: i32 = -1 << 15;
|
||||
|
||||
/// Output of find_mv_stack plus its working state.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct MvStack {
|
||||
pub ref_stack_mv: [[MvP; 2]; MAX_REF_MV_STACK_SIZE + 1],
|
||||
pub weight_stack: [u32; MAX_REF_MV_STACK_SIZE + 1],
|
||||
pub num_mv_found: usize,
|
||||
pub new_mv_count: usize,
|
||||
pub global_mvs: [MvP; 2],
|
||||
pub found_match: bool,
|
||||
pub close_matches: usize,
|
||||
pub total_matches: usize,
|
||||
pub new_mv_context: usize,
|
||||
pub ref_mv_context: usize,
|
||||
pub zero_mv_context: usize,
|
||||
pub drl_ctx_stack: [usize; MAX_REF_MV_STACK_SIZE + 1],
|
||||
ref_id_count: [usize; 2],
|
||||
ref_diff_count: [usize; 2],
|
||||
ref_id_mvs: [[MvP; 2]; 2],
|
||||
ref_diff_mvs: [[MvP; 2]; 2],
|
||||
// warp samples
|
||||
pub num_samples: usize,
|
||||
pub num_samples_scanned: usize,
|
||||
pub cand_list: [[i32; 4]; LEAST_SQUARES_SAMPLES_MAX],
|
||||
}
|
||||
|
||||
fn block_width(bs: usize) -> i32 {
|
||||
4 * NUM_4X4_BLOCKS_WIDE[bs] as i32
|
||||
}
|
||||
fn block_height(bs: usize) -> i32 {
|
||||
4 * NUM_4X4_BLOCKS_HIGH[bs] as i32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn round2_signed64(x: i64, n: u32) -> i64 {
|
||||
if n == 0 {
|
||||
return x;
|
||||
}
|
||||
if x >= 0 { (x + (1 << (n - 1))) >> n } else { -((-x + (1 << (n - 1))) >> n) }
|
||||
}
|
||||
|
||||
pub(crate) fn has_newmv(mode: usize) -> bool {
|
||||
matches!(mode, NEWMV | NEW_NEWMV | NEAR_NEWMV | NEW_NEARMV | NEAREST_NEWMV | NEW_NEARESTMV)
|
||||
}
|
||||
|
||||
impl TileDecoder<'_, '_> {
|
||||
/// lower_mv_precision( candMv ) (7.10.2.10)
|
||||
pub(crate) fn lower_mv_precision(&self, mv: &mut MvP) {
|
||||
let fh = &self.fs.fh;
|
||||
if fh.allow_high_precision_mv {
|
||||
return;
|
||||
}
|
||||
for c in mv.iter_mut() {
|
||||
if fh.force_integer_mv {
|
||||
let a = c.abs();
|
||||
let a_int = (a + 3) >> 3;
|
||||
*c = if *c > 0 { a_int << 3 } else { -(a_int << 3) };
|
||||
} else if *c & 1 != 0 {
|
||||
*c += if *c > 0 { -1 } else { 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Setup global MV process (7.10.2.1).
|
||||
pub(crate) fn setup_global_mv(&self, ref_list: usize) -> MvP {
|
||||
let rf = self.b.ref_frame[ref_list];
|
||||
let fh = &self.fs.fh;
|
||||
let mut mv: MvP = [0, 0];
|
||||
if rf > INTRA_FRAME as i8 {
|
||||
let rf = rf as usize;
|
||||
let typ = fh.gm_type[rf] as usize;
|
||||
let gm = &fh.gm_params[rf];
|
||||
if typ == TRANSLATION {
|
||||
mv = [gm[0] >> (WARPEDMODEL_PREC_BITS - 3), gm[1] >> (WARPEDMODEL_PREC_BITS - 3)];
|
||||
} else if typ != IDENTITY {
|
||||
let bw = block_width(self.b.mi_size) as i64;
|
||||
let bh = block_height(self.b.mi_size) as i64;
|
||||
let x = self.b.mi_col as i64 * 4 + bw / 2 - 1;
|
||||
let y = self.b.mi_row as i64 * 4 + bh / 2 - 1;
|
||||
let p = 1i64 << WARPEDMODEL_PREC_BITS;
|
||||
let xc = (gm[2] as i64 - p) * x + gm[3] as i64 * y + gm[0] as i64;
|
||||
let yc = gm[4] as i64 * x + (gm[5] as i64 - p) * y + gm[1] as i64;
|
||||
if fh.allow_high_precision_mv {
|
||||
mv = [round2_signed64(yc, WARPEDMODEL_PREC_BITS as u32 - 3) as i32, round2_signed64(xc, WARPEDMODEL_PREC_BITS as u32 - 3) as i32];
|
||||
} else {
|
||||
mv = [
|
||||
round2_signed64(yc, WARPEDMODEL_PREC_BITS as u32 - 2) as i32 * 2,
|
||||
round2_signed64(xc, WARPEDMODEL_PREC_BITS as u32 - 2) as i32 * 2,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
self.lower_mv_precision(&mut mv);
|
||||
mv
|
||||
}
|
||||
|
||||
/// Find MV stack process (7.10.2).
|
||||
pub(crate) fn find_mv_stack(&mut self, is_compound: bool) {
|
||||
let bw4 = self.b.bw4 as isize;
|
||||
let bh4 = self.b.bh4 as isize;
|
||||
let mut s = MvStack { global_mvs: [self.setup_global_mv(0), [0, 0]], ..Default::default() };
|
||||
if is_compound {
|
||||
s.global_mvs[1] = self.setup_global_mv(1);
|
||||
}
|
||||
self.mvs = s;
|
||||
self.mvs.found_match = false;
|
||||
self.scan_row(-1, is_compound);
|
||||
let mut found_above = self.mvs.found_match;
|
||||
self.mvs.found_match = false;
|
||||
self.scan_col(-1, is_compound);
|
||||
let mut found_left = self.mvs.found_match;
|
||||
self.mvs.found_match = false;
|
||||
if bw4.max(bh4) <= 16 {
|
||||
self.scan_point(-1, bw4, is_compound);
|
||||
}
|
||||
if self.mvs.found_match {
|
||||
found_above = true;
|
||||
}
|
||||
self.mvs.close_matches = found_above as usize + found_left as usize;
|
||||
let num_nearest = self.mvs.num_mv_found;
|
||||
let num_new = self.mvs.new_mv_count;
|
||||
for idx in 0..num_nearest {
|
||||
self.mvs.weight_stack[idx] += REF_CAT_LEVEL as u32;
|
||||
}
|
||||
self.mvs.zero_mv_context = 0;
|
||||
if self.fs.fh.use_ref_frame_mvs {
|
||||
self.temporal_scan(is_compound);
|
||||
}
|
||||
self.scan_point(-1, -1, is_compound);
|
||||
if self.mvs.found_match {
|
||||
found_above = true;
|
||||
}
|
||||
self.mvs.found_match = false;
|
||||
self.scan_row(-3, is_compound);
|
||||
if self.mvs.found_match {
|
||||
found_above = true;
|
||||
}
|
||||
self.mvs.found_match = false;
|
||||
self.scan_col(-3, is_compound);
|
||||
if self.mvs.found_match {
|
||||
found_left = true;
|
||||
}
|
||||
self.mvs.found_match = false;
|
||||
if bh4 > 1 {
|
||||
self.scan_row(-5, is_compound);
|
||||
}
|
||||
if self.mvs.found_match {
|
||||
found_above = true;
|
||||
}
|
||||
self.mvs.found_match = false;
|
||||
if bw4 > 1 {
|
||||
self.scan_col(-5, is_compound);
|
||||
}
|
||||
if self.mvs.found_match {
|
||||
found_left = true;
|
||||
}
|
||||
self.mvs.total_matches = found_above as usize + found_left as usize;
|
||||
let n = self.mvs.num_mv_found;
|
||||
self.sort_stack(0, num_nearest, is_compound);
|
||||
self.sort_stack(num_nearest, n, is_compound);
|
||||
if self.mvs.num_mv_found < 2 {
|
||||
self.extra_search(is_compound);
|
||||
}
|
||||
self.context_and_clamping(is_compound, num_new);
|
||||
}
|
||||
|
||||
fn scan_row(&mut self, delta_row: isize, is_compound: bool) {
|
||||
let bw4 = self.b.bw4 as isize;
|
||||
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
|
||||
let mi_cols = self.fs.fh.mi_cols as isize;
|
||||
let end4 = bw4.min(mi_cols - mi_col).min(16);
|
||||
let mut delta_col = 0;
|
||||
let use_step16 = bw4 >= 16;
|
||||
let mut delta_row = delta_row;
|
||||
if delta_row.abs() > 1 {
|
||||
delta_row += mi_row & 1;
|
||||
delta_col = 1 - (mi_col & 1);
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < end4 {
|
||||
let mv_row = mi_row + delta_row;
|
||||
let mv_col = mi_col + delta_col + i;
|
||||
if !self.inside(mv_row, mv_col) {
|
||||
break;
|
||||
}
|
||||
let mi = &self.t.mi;
|
||||
let mut len = bw4.min(NUM_4X4_BLOCKS_WIDE[mi.mi_size[mi.idx(mv_row as usize, mv_col as usize)] as usize] as isize);
|
||||
if delta_row.abs() > 1 {
|
||||
len = len.max(2);
|
||||
}
|
||||
if use_step16 {
|
||||
len = len.max(4);
|
||||
}
|
||||
let weight = len as u32 * 2;
|
||||
self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, is_compound, weight);
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_col(&mut self, delta_col: isize, is_compound: bool) {
|
||||
let bh4 = self.b.bh4 as isize;
|
||||
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
|
||||
let mi_rows = self.fs.fh.mi_rows as isize;
|
||||
let end4 = bh4.min(mi_rows - mi_row).min(16);
|
||||
let mut delta_row = 0;
|
||||
let use_step16 = bh4 >= 16;
|
||||
let mut delta_col = delta_col;
|
||||
if delta_col.abs() > 1 {
|
||||
delta_row = 1 - (mi_row & 1);
|
||||
delta_col += mi_col & 1;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < end4 {
|
||||
let mv_row = mi_row + delta_row + i;
|
||||
let mv_col = mi_col + delta_col;
|
||||
if !self.inside(mv_row, mv_col) {
|
||||
break;
|
||||
}
|
||||
let mi = &self.t.mi;
|
||||
let mut len = bh4.min(NUM_4X4_BLOCKS_HIGH[mi.mi_size[mi.idx(mv_row as usize, mv_col as usize)] as usize] as isize);
|
||||
if delta_col.abs() > 1 {
|
||||
len = len.max(2);
|
||||
}
|
||||
if use_step16 {
|
||||
len = len.max(4);
|
||||
}
|
||||
let weight = len as u32 * 2;
|
||||
self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, is_compound, weight);
|
||||
i += len;
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_point(&mut self, delta_row: isize, delta_col: isize, is_compound: bool) {
|
||||
let mv_row = self.b.mi_row as isize + delta_row;
|
||||
let mv_col = self.b.mi_col as isize + delta_col;
|
||||
if self.inside(mv_row, mv_col) && self.t.mi.written[self.t.mi.idx(mv_row as usize, mv_col as usize)] {
|
||||
self.add_ref_mv_candidate(mv_row as usize, mv_col as usize, is_compound, 4);
|
||||
}
|
||||
}
|
||||
|
||||
fn temporal_scan(&mut self, is_compound: bool) {
|
||||
let bw4 = self.b.bw4 as isize;
|
||||
let bh4 = self.b.bh4 as isize;
|
||||
let step_w4 = if bw4 >= 16 { 4 } else { 2 };
|
||||
let step_h4 = if bh4 >= 16 { 4 } else { 2 };
|
||||
let mut dr = 0;
|
||||
while dr < bh4.min(16) {
|
||||
let mut dc = 0;
|
||||
while dc < bw4.min(16) {
|
||||
self.add_tpl_ref_mv(dr, dc, is_compound);
|
||||
dc += step_w4;
|
||||
}
|
||||
dr += step_h4;
|
||||
}
|
||||
let allow_extension = (2..16).contains(&bh4) && (2..16).contains(&bw4);
|
||||
if allow_extension {
|
||||
let pos = [(bh4, -2), (bh4, bw4), (bh4 - 2, bw4)];
|
||||
for &(dr, dc) in &pos {
|
||||
let row = (self.b.mi_row as isize & 15) + dr;
|
||||
let col = (self.b.mi_col as isize & 15) + dc;
|
||||
if (0..16).contains(&row) && (0..16).contains(&col) {
|
||||
self.add_tpl_ref_mv(dr, dc, is_compound);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_tpl_ref_mv(&mut self, delta_row: isize, delta_col: isize, is_compound: bool) {
|
||||
let mv_row = (self.b.mi_row as isize + delta_row) | 1;
|
||||
let mv_col = (self.b.mi_col as isize + delta_col) | 1;
|
||||
if !self.inside(mv_row, mv_col) {
|
||||
return;
|
||||
}
|
||||
let x8 = (mv_col >> 1) as usize;
|
||||
let y8 = (mv_row >> 1) as usize;
|
||||
let w8 = self.fs.fh.mi_cols as usize >> 1;
|
||||
if delta_row == 0 && delta_col == 0 {
|
||||
self.mvs.zero_mv_context = 1;
|
||||
}
|
||||
let rf0 = self.b.ref_frame[0] as usize;
|
||||
if !is_compound {
|
||||
let mut cand = self.fs.motion_field[rf0][y8 * w8 + x8];
|
||||
if cand[0] == INVALID_MV {
|
||||
return;
|
||||
}
|
||||
self.lower_mv_precision(&mut cand);
|
||||
if delta_row == 0 && delta_col == 0 {
|
||||
let g = self.mvs.global_mvs[0];
|
||||
self.mvs.zero_mv_context = ((cand[0] - g[0]).abs() >= 16 || (cand[1] - g[1]).abs() >= 16) as usize;
|
||||
}
|
||||
let n = self.mvs.num_mv_found;
|
||||
let idx = (0..n).find(|&i| self.mvs.ref_stack_mv[i][0] == cand);
|
||||
if let Some(i) = idx {
|
||||
self.mvs.weight_stack[i] += 2;
|
||||
} else if n < MAX_REF_MV_STACK_SIZE {
|
||||
self.mvs.ref_stack_mv[n][0] = cand;
|
||||
self.mvs.weight_stack[n] = 2;
|
||||
self.mvs.num_mv_found += 1;
|
||||
}
|
||||
} else {
|
||||
let rf1 = self.b.ref_frame[1] as usize;
|
||||
let mut c0 = self.fs.motion_field[rf0][y8 * w8 + x8];
|
||||
if c0[0] == INVALID_MV {
|
||||
return;
|
||||
}
|
||||
let mut c1 = self.fs.motion_field[rf1][y8 * w8 + x8];
|
||||
if c1[0] == INVALID_MV {
|
||||
return;
|
||||
}
|
||||
self.lower_mv_precision(&mut c0);
|
||||
self.lower_mv_precision(&mut c1);
|
||||
if delta_row == 0 && delta_col == 0 {
|
||||
let g = self.mvs.global_mvs;
|
||||
self.mvs.zero_mv_context = ((c0[0] - g[0][0]).abs() >= 16
|
||||
|| (c0[1] - g[0][1]).abs() >= 16
|
||||
|| (c1[0] - g[1][0]).abs() >= 16
|
||||
|| (c1[1] - g[1][1]).abs() >= 16) as usize;
|
||||
}
|
||||
let n = self.mvs.num_mv_found;
|
||||
let idx = (0..n).find(|&i| self.mvs.ref_stack_mv[i][0] == c0 && self.mvs.ref_stack_mv[i][1] == c1);
|
||||
if let Some(i) = idx {
|
||||
self.mvs.weight_stack[i] += 2;
|
||||
} else if n < MAX_REF_MV_STACK_SIZE {
|
||||
self.mvs.ref_stack_mv[n] = [c0, c1];
|
||||
self.mvs.weight_stack[n] = 2;
|
||||
self.mvs.num_mv_found += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_ref_mv_candidate(&mut self, mv_row: usize, mv_col: usize, is_compound: bool, weight: u32) {
|
||||
let mi = &self.t.mi;
|
||||
let i = mi.idx(mv_row, mv_col);
|
||||
if !mi.is_inter[i] {
|
||||
return;
|
||||
}
|
||||
let rf = mi.ref_frame[i];
|
||||
if !is_compound {
|
||||
for cand_list in 0..2 {
|
||||
if rf[cand_list] == self.b.ref_frame[0] {
|
||||
self.search_stack(mv_row, mv_col, cand_list, weight);
|
||||
}
|
||||
}
|
||||
} else if rf[0] == self.b.ref_frame[0] && rf[1] == self.b.ref_frame[1] {
|
||||
self.compound_search_stack(mv_row, mv_col, weight);
|
||||
}
|
||||
}
|
||||
|
||||
fn search_stack(&mut self, mv_row: usize, mv_col: usize, cand_list: usize, weight: u32) {
|
||||
let mi = &self.t.mi;
|
||||
let i = mi.idx(mv_row, mv_col);
|
||||
let cand_mode = mi.y_mode[i] as usize;
|
||||
let cand_size = mi.mi_size[i] as usize;
|
||||
let large = block_width(cand_size).min(block_height(cand_size)) >= 8;
|
||||
let gm0 = self.fs.fh.gm_type[self.b.ref_frame[0] as usize] as usize;
|
||||
let mut cand = if (cand_mode == GLOBALMV || cand_mode == GLOBAL_GLOBALMV) && gm0 > TRANSLATION && large {
|
||||
self.mvs.global_mvs[0]
|
||||
} else {
|
||||
let m = mi.mv[i][cand_list];
|
||||
[m.row as i32, m.col as i32]
|
||||
};
|
||||
self.lower_mv_precision(&mut cand);
|
||||
if has_newmv(cand_mode) {
|
||||
self.mvs.new_mv_count += 1;
|
||||
}
|
||||
self.mvs.found_match = true;
|
||||
let n = self.mvs.num_mv_found;
|
||||
if let Some(idx) = (0..n).find(|&k| self.mvs.ref_stack_mv[k][0] == cand) {
|
||||
self.mvs.weight_stack[idx] += weight;
|
||||
} else if n < MAX_REF_MV_STACK_SIZE {
|
||||
self.mvs.ref_stack_mv[n][0] = cand;
|
||||
self.mvs.weight_stack[n] = weight;
|
||||
self.mvs.num_mv_found += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn compound_search_stack(&mut self, mv_row: usize, mv_col: usize, weight: u32) {
|
||||
let mi = &self.t.mi;
|
||||
let i = mi.idx(mv_row, mv_col);
|
||||
let mut cand = [[mi.mv[i][0].row as i32, mi.mv[i][0].col as i32], [mi.mv[i][1].row as i32, mi.mv[i][1].col as i32]];
|
||||
let cand_mode = mi.y_mode[i] as usize;
|
||||
if cand_mode == GLOBAL_GLOBALMV {
|
||||
for (rl, c) in cand.iter_mut().enumerate() {
|
||||
if self.fs.fh.gm_type[self.b.ref_frame[rl] as usize] as usize > TRANSLATION {
|
||||
*c = self.mvs.global_mvs[rl];
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in cand.iter_mut() {
|
||||
self.lower_mv_precision(c);
|
||||
}
|
||||
self.mvs.found_match = true;
|
||||
let n = self.mvs.num_mv_found;
|
||||
if let Some(idx) = (0..n).find(|&k| self.mvs.ref_stack_mv[k][0] == cand[0] && self.mvs.ref_stack_mv[k][1] == cand[1]) {
|
||||
self.mvs.weight_stack[idx] += weight;
|
||||
} else if n < MAX_REF_MV_STACK_SIZE {
|
||||
self.mvs.ref_stack_mv[n] = cand;
|
||||
self.mvs.weight_stack[n] = weight;
|
||||
self.mvs.num_mv_found += 1;
|
||||
}
|
||||
if has_newmv(cand_mode) {
|
||||
self.mvs.new_mv_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_stack(&mut self, start: usize, end: usize, is_compound: bool) {
|
||||
let mut end = end;
|
||||
let s = &mut self.mvs;
|
||||
while end > start {
|
||||
let mut new_end = start;
|
||||
for idx in start + 1..end {
|
||||
if s.weight_stack[idx - 1] < s.weight_stack[idx] {
|
||||
s.weight_stack.swap(idx - 1, idx);
|
||||
for list in 0..1 + is_compound as usize {
|
||||
let t = s.ref_stack_mv[idx - 1][list];
|
||||
s.ref_stack_mv[idx - 1][list] = s.ref_stack_mv[idx][list];
|
||||
s.ref_stack_mv[idx][list] = t;
|
||||
}
|
||||
new_end = idx;
|
||||
}
|
||||
}
|
||||
end = new_end;
|
||||
}
|
||||
}
|
||||
|
||||
fn extra_search(&mut self, is_compound: bool) {
|
||||
self.mvs.ref_id_count = [0; 2];
|
||||
self.mvs.ref_diff_count = [0; 2];
|
||||
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
|
||||
let mut w4 = (self.b.bw4 as isize).min(16);
|
||||
let mut h4 = (self.b.bh4 as isize).min(16);
|
||||
w4 = w4.min(self.fs.fh.mi_cols as isize - mi_col);
|
||||
h4 = h4.min(self.fs.fh.mi_rows as isize - mi_row);
|
||||
let num4x4 = w4.min(h4);
|
||||
for pass in 0..2 {
|
||||
let mut idx = 0;
|
||||
while idx < num4x4 && self.mvs.num_mv_found < 2 {
|
||||
let (mv_row, mv_col) = if pass == 0 { (mi_row - 1, mi_col + idx) } else { (mi_row + idx, mi_col - 1) };
|
||||
if !self.inside(mv_row, mv_col) {
|
||||
break;
|
||||
}
|
||||
self.add_extra_mv_candidate(mv_row as usize, mv_col as usize, is_compound);
|
||||
let mi = &self.t.mi;
|
||||
let sz = mi.mi_size[mi.idx(mv_row as usize, mv_col as usize)] as usize;
|
||||
idx += if pass == 0 { NUM_4X4_BLOCKS_WIDE[sz] } else { NUM_4X4_BLOCKS_HIGH[sz] } as isize;
|
||||
}
|
||||
}
|
||||
if is_compound {
|
||||
let mut combined = [[[0i32; 2]; 2]; 2];
|
||||
for list in 0..2 {
|
||||
let mut comp_count = 0;
|
||||
for idx in 0..self.mvs.ref_id_count[list] {
|
||||
combined[comp_count][list] = self.mvs.ref_id_mvs[list][idx];
|
||||
comp_count += 1;
|
||||
}
|
||||
let mut idx = 0;
|
||||
while idx < self.mvs.ref_diff_count[list] && comp_count < 2 {
|
||||
combined[comp_count][list] = self.mvs.ref_diff_mvs[list][idx];
|
||||
comp_count += 1;
|
||||
idx += 1;
|
||||
}
|
||||
while comp_count < 2 {
|
||||
combined[comp_count][list] = self.mvs.global_mvs[list];
|
||||
comp_count += 1;
|
||||
}
|
||||
}
|
||||
let n = self.mvs.num_mv_found;
|
||||
if n == 1 {
|
||||
if combined[0][0] == self.mvs.ref_stack_mv[0][0] && combined[0][1] == self.mvs.ref_stack_mv[0][1] {
|
||||
self.mvs.ref_stack_mv[n] = combined[1];
|
||||
} else {
|
||||
self.mvs.ref_stack_mv[n] = combined[0];
|
||||
}
|
||||
self.mvs.weight_stack[n] = 2;
|
||||
self.mvs.num_mv_found += 1;
|
||||
} else {
|
||||
for c in combined {
|
||||
let n = self.mvs.num_mv_found;
|
||||
self.mvs.ref_stack_mv[n] = c;
|
||||
self.mvs.weight_stack[n] = 2;
|
||||
self.mvs.num_mv_found += 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for idx in self.mvs.num_mv_found..2 {
|
||||
self.mvs.ref_stack_mv[idx][0] = self.mvs.global_mvs[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_extra_mv_candidate(&mut self, mv_row: usize, mv_col: usize, is_compound: bool) {
|
||||
let mi = &self.t.mi;
|
||||
let i = mi.idx(mv_row, mv_col);
|
||||
let rfs = mi.ref_frame[i];
|
||||
let mvs = mi.mv[i];
|
||||
let bias = &self.fs.fh.ref_frame_sign_bias;
|
||||
if is_compound {
|
||||
for cand_list in 0..2 {
|
||||
let cand_ref = rfs[cand_list];
|
||||
if cand_ref > INTRA_FRAME as i8 {
|
||||
for list in 0..2 {
|
||||
let mut cand = [mvs[cand_list].row as i32, mvs[cand_list].col as i32];
|
||||
if cand_ref == self.b.ref_frame[list] && self.mvs.ref_id_count[list] < 2 {
|
||||
let k = self.mvs.ref_id_count[list];
|
||||
self.mvs.ref_id_mvs[list][k] = cand;
|
||||
self.mvs.ref_id_count[list] += 1;
|
||||
} else if self.mvs.ref_diff_count[list] < 2 {
|
||||
if bias[cand_ref as usize] != bias[self.b.ref_frame[list] as usize] {
|
||||
cand = [-cand[0], -cand[1]];
|
||||
}
|
||||
let k = self.mvs.ref_diff_count[list];
|
||||
self.mvs.ref_diff_mvs[list][k] = cand;
|
||||
self.mvs.ref_diff_count[list] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for cand_list in 0..2 {
|
||||
let cand_ref = rfs[cand_list];
|
||||
if cand_ref > INTRA_FRAME as i8 {
|
||||
let mut cand = [mvs[cand_list].row as i32, mvs[cand_list].col as i32];
|
||||
if bias[cand_ref as usize] != bias[self.b.ref_frame[0] as usize] {
|
||||
cand = [-cand[0], -cand[1]];
|
||||
}
|
||||
let n = self.mvs.num_mv_found;
|
||||
if !(0..n).any(|k| self.mvs.ref_stack_mv[k][0] == cand) {
|
||||
self.mvs.ref_stack_mv[n][0] = cand;
|
||||
self.mvs.weight_stack[n] = 2;
|
||||
self.mvs.num_mv_found += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn context_and_clamping(&mut self, is_compound: bool, num_new: usize) {
|
||||
let bw = block_width(self.b.mi_size);
|
||||
let bh = block_height(self.b.mi_size);
|
||||
let n = self.mvs.num_mv_found;
|
||||
for idx in 0..n {
|
||||
let mut z = 0;
|
||||
if idx + 1 < n {
|
||||
let w0 = self.mvs.weight_stack[idx];
|
||||
let w1 = self.mvs.weight_stack[idx + 1];
|
||||
if w0 >= REF_CAT_LEVEL as u32 {
|
||||
if w1 < REF_CAT_LEVEL as u32 {
|
||||
z = 1;
|
||||
}
|
||||
} else {
|
||||
z = 2;
|
||||
}
|
||||
}
|
||||
self.mvs.drl_ctx_stack[idx] = z;
|
||||
}
|
||||
for list in 0..1 + is_compound as usize {
|
||||
for idx in 0..n {
|
||||
let mut mv = self.mvs.ref_stack_mv[idx][list];
|
||||
mv[0] = self.clamp_mv_row(mv[0], MV_BORDER as i32 + bh * 8);
|
||||
mv[1] = self.clamp_mv_col(mv[1], MV_BORDER as i32 + bw * 8);
|
||||
self.mvs.ref_stack_mv[idx][list] = mv;
|
||||
}
|
||||
}
|
||||
let (close, total) = (self.mvs.close_matches, self.mvs.total_matches);
|
||||
if close == 0 {
|
||||
self.mvs.new_mv_context = total.min(1);
|
||||
self.mvs.ref_mv_context = total;
|
||||
} else if close == 1 {
|
||||
self.mvs.new_mv_context = 3 - num_new.min(1);
|
||||
self.mvs.ref_mv_context = 2 + total;
|
||||
} else {
|
||||
self.mvs.new_mv_context = 5 - num_new.min(1);
|
||||
self.mvs.ref_mv_context = 5;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clamp_mv_row(&self, mvec: i32, border: i32) -> i32 {
|
||||
let bh4 = self.b.bh4 as i32;
|
||||
let to_top = -((self.b.mi_row as i32 * 4) * 8);
|
||||
let to_bottom = ((self.fs.fh.mi_rows as i32 - bh4 - self.b.mi_row as i32) * 4) * 8;
|
||||
mvec.clamp(to_top - border, to_bottom + border)
|
||||
}
|
||||
|
||||
pub(crate) fn clamp_mv_col(&self, mvec: i32, border: i32) -> i32 {
|
||||
let bw4 = self.b.bw4 as i32;
|
||||
let to_left = -((self.b.mi_col as i32 * 4) * 8);
|
||||
let to_right = ((self.fs.fh.mi_cols as i32 - bw4 - self.b.mi_col as i32) * 4) * 8;
|
||||
mvec.clamp(to_left - border, to_right + border)
|
||||
}
|
||||
|
||||
/// has_overlappable_candidates( ) (7.10.3)
|
||||
pub(crate) fn has_overlappable_candidates(&self) -> bool {
|
||||
let mi = &self.t.mi;
|
||||
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as usize, self.fs.fh.mi_cols as usize);
|
||||
if self.b.avail_u {
|
||||
let mut x4 = self.b.mi_col;
|
||||
while x4 < mi_cols.min(self.b.mi_col + self.b.bw4) {
|
||||
let c = (x4 | 1).min(mi_cols - 1);
|
||||
if mi.ref_frame[mi.idx(self.b.mi_row - 1, c)][0] > INTRA_FRAME as i8 {
|
||||
return true;
|
||||
}
|
||||
x4 += 2;
|
||||
}
|
||||
}
|
||||
if self.b.avail_l {
|
||||
let mut y4 = self.b.mi_row;
|
||||
while y4 < mi_rows.min(self.b.mi_row + self.b.bh4) {
|
||||
let r = (y4 | 1).min(mi_rows - 1);
|
||||
if mi.ref_frame[mi.idx(r, self.b.mi_col - 1)][0] > INTRA_FRAME as i8 {
|
||||
return true;
|
||||
}
|
||||
y4 += 2;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find warp samples process (7.10.4).
|
||||
pub(crate) fn find_warp_samples(&mut self) {
|
||||
self.mvs.num_samples = 0;
|
||||
self.mvs.num_samples_scanned = 0;
|
||||
let w4 = self.b.bw4 as isize;
|
||||
let h4 = self.b.bh4 as isize;
|
||||
let (mi_row, mi_col) = (self.b.mi_row as isize, self.b.mi_col as isize);
|
||||
let (mi_rows, mi_cols) = (self.fs.fh.mi_rows as isize, self.fs.fh.mi_cols as isize);
|
||||
let mut do_top_left = true;
|
||||
let mut do_top_right = true;
|
||||
if self.b.avail_u {
|
||||
let mi = &self.t.mi;
|
||||
let src_w = NUM_4X4_BLOCKS_WIDE[mi.mi_size[mi.idx(mi_row as usize - 1, mi_col as usize)] as usize] as isize;
|
||||
if w4 <= src_w {
|
||||
let col_offset = -(mi_col & (src_w - 1));
|
||||
if col_offset < 0 {
|
||||
do_top_left = false;
|
||||
}
|
||||
if col_offset + src_w > w4 {
|
||||
do_top_right = false;
|
||||
}
|
||||
self.add_sample(-1, 0);
|
||||
} else {
|
||||
let mut i = 0;
|
||||
while i < w4.min(mi_cols - mi_col) {
|
||||
let mi = &self.t.mi;
|
||||
let src_w = NUM_4X4_BLOCKS_WIDE[mi.mi_size[mi.idx(mi_row as usize - 1, (mi_col + i) as usize)] as usize] as isize;
|
||||
let step = w4.min(src_w);
|
||||
self.add_sample(-1, i);
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.b.avail_l {
|
||||
let mi = &self.t.mi;
|
||||
let src_h = NUM_4X4_BLOCKS_HIGH[mi.mi_size[mi.idx(mi_row as usize, mi_col as usize - 1)] as usize] as isize;
|
||||
if h4 <= src_h {
|
||||
let row_offset = -(mi_row & (src_h - 1));
|
||||
if row_offset < 0 {
|
||||
do_top_left = false;
|
||||
}
|
||||
self.add_sample(0, -1);
|
||||
} else {
|
||||
let mut i = 0;
|
||||
while i < h4.min(mi_rows - mi_row) {
|
||||
let mi = &self.t.mi;
|
||||
let src_h = NUM_4X4_BLOCKS_HIGH[mi.mi_size[mi.idx((mi_row + i) as usize, mi_col as usize - 1)] as usize] as isize;
|
||||
let step = h4.min(src_h);
|
||||
self.add_sample(i, -1);
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
}
|
||||
if do_top_left {
|
||||
self.add_sample(-1, -1);
|
||||
}
|
||||
if do_top_right && w4.max(h4) <= 16 {
|
||||
self.add_sample(-1, w4);
|
||||
}
|
||||
if self.mvs.num_samples == 0 && self.mvs.num_samples_scanned > 0 {
|
||||
self.mvs.num_samples = 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn add_sample(&mut self, delta_row: isize, delta_col: isize) {
|
||||
if self.mvs.num_samples_scanned >= LEAST_SQUARES_SAMPLES_MAX {
|
||||
return;
|
||||
}
|
||||
let mv_row = self.b.mi_row as isize + delta_row;
|
||||
let mv_col = self.b.mi_col as isize + delta_col;
|
||||
if !self.inside(mv_row, mv_col) {
|
||||
return;
|
||||
}
|
||||
let mi = &self.t.mi;
|
||||
let i = mi.idx(mv_row as usize, mv_col as usize);
|
||||
if !mi.written[i] {
|
||||
return;
|
||||
}
|
||||
if mi.ref_frame[i][0] != self.b.ref_frame[0] || mi.ref_frame[i][1] != -1 {
|
||||
return;
|
||||
}
|
||||
let cand_sz = mi.mi_size[i] as usize;
|
||||
let cand_w4 = NUM_4X4_BLOCKS_WIDE[cand_sz] as isize;
|
||||
let cand_h4 = NUM_4X4_BLOCKS_HIGH[cand_sz] as isize;
|
||||
let cand_row = mv_row & !(cand_h4 - 1);
|
||||
let cand_col = mv_col & !(cand_w4 - 1);
|
||||
let mid_y = cand_row * 4 + cand_h4 * 2 - 1;
|
||||
let mid_x = cand_col * 4 + cand_w4 * 2 - 1;
|
||||
let threshold = block_width(self.b.mi_size).max(block_height(self.b.mi_size)).clamp(16, 112);
|
||||
let cm = mi.mv[mi.idx(cand_row as usize, cand_col as usize)][0];
|
||||
let mv_diff_row = (cm.row as i32 - self.b.mv_i[0][0]).abs();
|
||||
let mv_diff_col = (cm.col as i32 - self.b.mv_i[0][1]).abs();
|
||||
let valid = mv_diff_row + mv_diff_col <= threshold;
|
||||
let cand = [mid_y as i32 * 8, mid_x as i32 * 8, mid_y as i32 * 8 + cm.row as i32, mid_x as i32 * 8 + cm.col as i32];
|
||||
self.mvs.num_samples_scanned += 1;
|
||||
if !valid && self.mvs.num_samples_scanned > 1 {
|
||||
return;
|
||||
}
|
||||
self.mvs.cand_list[self.mvs.num_samples] = cand;
|
||||
if valid {
|
||||
self.mvs.num_samples += 1;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn inside(&self, r: isize, c: isize) -> bool {
|
||||
c >= self.mi_col_start as isize && c < self.mi_col_end as isize && r >= self.mi_row_start as isize && r < self.mi_row_end as isize
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! Optional data parallelism (rayon behind the `threads` feature; sequential otherwise and on
|
||||
//! wasm32).
|
||||
|
||||
#[cfg(feature = "threads")]
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A handle to the decoder's worker pool (or none: run on the calling thread).
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Pool {
|
||||
#[cfg(feature = "threads")]
|
||||
pool: Option<Arc<rayon::ThreadPool>>,
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
/// A pool of `threads` workers (sequential when `threads` <= 1, without the `threads`
|
||||
/// feature, or on wasm32).
|
||||
pub fn new(threads: usize) -> Pool {
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
let pool = if threads > 1 && cfg!(not(target_arch = "wasm32")) {
|
||||
rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("av1-{i}")).build().ok().map(Arc::new)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Pool { pool }
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
let _ = threads;
|
||||
Pool {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_parallel(&self) -> bool {
|
||||
#[cfg(feature = "threads")]
|
||||
{
|
||||
self.pool.is_some()
|
||||
}
|
||||
#[cfg(not(feature = "threads"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// `f(i, &mut items[i])` for every item, in parallel when possible; results in order.
|
||||
pub fn map_mut<T: Send, R: Send>(&self, items: &mut [T], f: impl Fn(usize, &mut T) -> R + Sync + Send) -> Vec<R> {
|
||||
#[cfg(feature = "threads")]
|
||||
if let Some(pool) = &self.pool
|
||||
&& items.len() > 1
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
return pool.install(|| items.par_iter_mut().enumerate().map(|(i, t)| f(i, t)).collect());
|
||||
}
|
||||
items.iter_mut().enumerate().map(|(i, t)| f(i, t)).collect()
|
||||
}
|
||||
|
||||
/// `f(i, chunk)` for the consecutive `chunk_len`-element chunks of `data`.
|
||||
pub fn chunks<T: Send>(&self, data: &mut [T], chunk_len: usize, f: impl Fn(usize, &mut [T]) + Sync + Send) {
|
||||
let chunk_len = chunk_len.max(1);
|
||||
#[cfg(feature = "threads")]
|
||||
if let Some(pool) = &self.pool
|
||||
&& data.len() > chunk_len
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
pool.install(|| data.par_chunks_mut(chunk_len).enumerate().for_each(|(i, c)| f(i, c)));
|
||||
return;
|
||||
}
|
||||
for (i, c) in data.chunks_mut(chunk_len).enumerate() {
|
||||
f(i, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
|
||||
//! super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
|
||||
TX_WIDTH,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used).
|
||||
/// `draft`: only super-resolution (which sets the output size) runs.
|
||||
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) {
|
||||
let mut t = Timer::start();
|
||||
let lvl = fs.fh.lf.level;
|
||||
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
|
||||
loop_filter(fs);
|
||||
}
|
||||
stats.add(Stage::LoopFilter, t.lap());
|
||||
let cdef = if draft { None } else { crate::cdef::apply(fs) };
|
||||
stats.add(Stage::Cdef, t.lap());
|
||||
let (up_cur, up_cdef) = if fs.fh.use_superres {
|
||||
let c = crate::restoration::upscale(fs, &fs.cur);
|
||||
let d = cdef.as_ref().map(|f| crate::restoration::upscale(fs, f));
|
||||
(c, d)
|
||||
} else {
|
||||
(std::mem::take(&mut fs.cur), cdef)
|
||||
};
|
||||
stats.add(Stage::Superres, t.lap());
|
||||
fs.cur = if fs.fh.lr.uses_lr && !draft {
|
||||
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
|
||||
} else {
|
||||
up_cdef.unwrap_or(up_cur)
|
||||
};
|
||||
stats.add(Stage::Restoration, t.lap());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Loop filter (7.14)
|
||||
|
||||
fn loop_filter(fs: &mut FrameState) {
|
||||
let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize);
|
||||
for plane in 0..fs.num_planes {
|
||||
if plane == 0 || fs.fh.lf.level[1 + plane] != 0 {
|
||||
for pass in 0..2 {
|
||||
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
|
||||
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
|
||||
let mut row = 0;
|
||||
while row < mi_rows {
|
||||
let mut col = 0;
|
||||
while col < mi_cols {
|
||||
edge(fs, plane, pass, row, col);
|
||||
col += col_step;
|
||||
}
|
||||
row += row_step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) {
|
||||
let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) };
|
||||
let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) };
|
||||
let x = col * 4;
|
||||
let y = row * 4;
|
||||
let row = row | sub_y;
|
||||
let col = col | sub_x;
|
||||
let on_screen = !(x >= fs.fh.frame_width as usize || y >= fs.fh.frame_height as usize || (pass == 0 && x == 0) || (pass == 1 && y == 0));
|
||||
if !on_screen {
|
||||
return;
|
||||
}
|
||||
let xp = x >> sub_x;
|
||||
let yp = y >> sub_y;
|
||||
let prev_row = row - ((dy as usize) << sub_y);
|
||||
let prev_col = col - ((dx as usize) << sub_x);
|
||||
let tx_sz = fs.lf_tx_size(plane, row >> sub_y, col >> sub_x);
|
||||
let is_tx_edge = if pass == 0 { xp.is_multiple_of(TX_WIDTH[tx_sz] as usize) } else { yp.is_multiple_of(TX_HEIGHT[tx_sz] as usize) };
|
||||
if !is_tx_edge {
|
||||
return;
|
||||
}
|
||||
let mi = &fs.mi;
|
||||
let i = mi.idx(row, col);
|
||||
let mi_size = mi.mi_size[i] as usize;
|
||||
let plane_size = fs.plane_residual_size(mi_size, plane);
|
||||
let skip = mi.skip[i];
|
||||
let is_intra = mi.ref_frame[i][0] <= INTRA_FRAME as i8;
|
||||
let is_block_edge = if pass == 0 {
|
||||
xp.is_multiple_of(4 * NUM_4X4_BLOCKS_WIDE[plane_size] as usize)
|
||||
} else {
|
||||
yp.is_multiple_of(4 * NUM_4X4_BLOCKS_HIGH[plane_size] as usize)
|
||||
};
|
||||
if !(is_block_edge || !skip || is_intra) {
|
||||
return;
|
||||
}
|
||||
let prev_tx_sz = fs.lf_tx_size(plane, prev_row >> sub_y, prev_col >> sub_x);
|
||||
// filter size (7.14.3)
|
||||
let base_size = if pass == 0 { TX_WIDTH[prev_tx_sz].min(TX_WIDTH[tx_sz]) } else { TX_HEIGHT[prev_tx_sz].min(TX_HEIGHT[tx_sz]) } as usize;
|
||||
let filter_size = if plane == 0 { 16.min(base_size) } else { 8.min(base_size) };
|
||||
let (mut lvl, mut limit, mut blimit, mut thresh) = strength(fs, row, col, plane, pass);
|
||||
if lvl == 0 {
|
||||
(lvl, limit, blimit, thresh) = strength(fs, prev_row, prev_col, plane, pass);
|
||||
}
|
||||
if lvl == 0 {
|
||||
return;
|
||||
}
|
||||
let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth);
|
||||
let pl = &mut fs.cur.planes[plane];
|
||||
let stride = pl.stride;
|
||||
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
|
||||
let base = yp * stride + xp;
|
||||
let chroma = plane > 0;
|
||||
for k in 0..4 {
|
||||
sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh).
|
||||
fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) {
|
||||
let mi = &fs.mi;
|
||||
let i = mi.idx(row, col);
|
||||
let segment = mi.segment_id[i] as usize;
|
||||
let rf = mi.ref_frame[i][0];
|
||||
let mode = mi.y_mode[i] as usize;
|
||||
let mode_type = (mode >= NEARESTMV && mode != GLOBALMV && mode != GLOBAL_GLOBALMV) as usize;
|
||||
let delta_lf = if fs.fh.delta_lf_multi { mi.delta_lf[i][if plane == 0 { pass } else { plane + 1 }] } else { mi.delta_lf[i][0] } as i32;
|
||||
// 7.14.5
|
||||
let idx = if plane == 0 { pass } else { plane + 1 };
|
||||
let base = (delta_lf + fs.fh.lf.level[idx] as i32).clamp(0, MAX_LOOP_FILTER as i32);
|
||||
let mut lvl_seg = base;
|
||||
let feature = SEG_LVL_ALT_LF_Y_V + idx;
|
||||
if fs.fh.seg.enabled && fs.fh.seg.features.enabled[segment][feature] {
|
||||
lvl_seg = (fs.fh.seg.features.data[segment][feature] + lvl_seg).clamp(0, MAX_LOOP_FILTER as i32);
|
||||
}
|
||||
if fs.fh.lf.delta_enabled {
|
||||
let n_shift = lvl_seg >> 5;
|
||||
let d = &fs.fh.lf.deltas;
|
||||
if rf == INTRA_FRAME as i8 {
|
||||
lvl_seg += d.ref_deltas[INTRA_FRAME] << n_shift;
|
||||
} else if rf > INTRA_FRAME as i8 {
|
||||
lvl_seg += (d.ref_deltas[rf as usize] << n_shift) + (d.mode_deltas[mode_type] << n_shift);
|
||||
}
|
||||
lvl_seg = lvl_seg.clamp(0, MAX_LOOP_FILTER as i32);
|
||||
}
|
||||
let lvl = lvl_seg;
|
||||
let sharp = fs.fh.lf.sharpness as i32;
|
||||
let shift = if sharp > 4 {
|
||||
2
|
||||
} else if sharp > 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let limit = if sharp > 0 { (lvl >> shift).clamp(1, 9 - sharp) } else { (lvl >> shift).max(1) };
|
||||
let blimit = 2 * (lvl + 2) + limit;
|
||||
let thresh = lvl >> 4;
|
||||
(lvl, limit, blimit, thresh)
|
||||
}
|
||||
|
||||
/// Per-edge thresholds of the sample filter, scaled to the bit depth.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LfThresh {
|
||||
limit: i32,
|
||||
blimit: i32,
|
||||
thresh: i32,
|
||||
/// Flatness threshold 1 << (BitDepth - 8).
|
||||
flat: i32,
|
||||
/// 0x80 << (BitDepth - 8) and 1 << (BitDepth - 1).
|
||||
off: i32,
|
||||
half: i32,
|
||||
}
|
||||
|
||||
impl LfThresh {
|
||||
fn new(limit: i32, blimit: i32, thresh: i32, bd: u32) -> LfThresh {
|
||||
let sh = bd - 8;
|
||||
LfThresh { limit: limit << sh, blimit: blimit << sh, thresh: thresh << sh, flat: 1 << sh, off: 0x80 << sh, half: 1 << (bd - 1) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample filtering process (7.14.6) for one line across the edge: `base` indexes q0 and
|
||||
/// `step` is the distance between samples across the edge.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[inline(always)]
|
||||
fn sample_filter(d: &mut [u16], base: usize, step: usize, chroma: bool, filter_size: usize, t: LfThresh) {
|
||||
let at = |d: &[u16], k: isize| -> i32 { d[(base as isize + k * step as isize) as usize] as i32 };
|
||||
let (q0, q1, p0, p1) = (at(d, 0), at(d, 1), at(d, -1), at(d, -2));
|
||||
let lim = t.limit;
|
||||
// filter mask (7.14.6.2)
|
||||
if (p1 - p0).abs() > lim || (q1 - q0).abs() > lim || (p0 - q0).abs() * 2 + (p1 - q1).abs() / 2 > t.blimit {
|
||||
return;
|
||||
}
|
||||
let hev = (p1 - p0).abs() > t.thresh || (q1 - q0).abs() > t.thresh;
|
||||
let mut flat = false;
|
||||
if filter_size > 4 {
|
||||
let (q2, p2) = (at(d, 2), at(d, -3));
|
||||
if (p2 - p1).abs() > lim || (q2 - q1).abs() > lim {
|
||||
return;
|
||||
}
|
||||
let tf = t.flat;
|
||||
flat = (p1 - p0).abs() <= tf && (q1 - q0).abs() <= tf && (p2 - p0).abs() <= tf && (q2 - q0).abs() <= tf;
|
||||
if !chroma {
|
||||
// filterLen >= 8
|
||||
let (q3, p3) = (at(d, 3), at(d, -4));
|
||||
if (p3 - p2).abs() > lim || (q3 - q2).abs() > lim {
|
||||
return;
|
||||
}
|
||||
flat = flat && (p3 - p0).abs() <= tf && (q3 - q0).abs() <= tf;
|
||||
}
|
||||
}
|
||||
if !flat {
|
||||
// narrow filter (7.14.6.3)
|
||||
let c = |v: i32| v.clamp(-t.half, t.half - 1);
|
||||
let off = t.off;
|
||||
let (ps1, ps0, qs0, qs1) = (p1 - off, p0 - off, q0 - off, q1 - off);
|
||||
let mut filter = if hev { c(ps1 - qs1) } else { 0 };
|
||||
filter = c(filter + 3 * (qs0 - ps0));
|
||||
let filter1 = c(filter + 4) >> 3;
|
||||
let filter2 = c(filter + 3) >> 3;
|
||||
d[base] = (c(qs0 - filter1) + off) as u16;
|
||||
d[base - step] = (c(ps0 + filter2) + off) as u16;
|
||||
if !hev {
|
||||
let f = (filter1 + 1) >> 1;
|
||||
d[base + step] = (c(qs1 - f) + off) as u16;
|
||||
d[base - 2 * step] = (c(ps1 + f) + off) as u16;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if chroma {
|
||||
wide::<2, 1, 3>(d, base, step);
|
||||
} else if filter_size == 8 {
|
||||
wide::<3, 0, 3>(d, base, step);
|
||||
} else {
|
||||
let tf = t.flat;
|
||||
let q0 = at(d, 0);
|
||||
let p0 = at(d, -1);
|
||||
let flat2 = (4..7).all(|k| (at(d, k) - q0).abs() <= tf && (at(d, -k - 1) - p0).abs() <= tf);
|
||||
if flat2 { wide::<6, 1, 4>(d, base, step) } else { wide::<3, 0, 3>(d, base, step) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Wide filter process (7.14.6.4) with n = N, the 2-weight taps |j| <= N2 and log2Size = LOG2.
|
||||
#[inline(always)]
|
||||
fn wide<const N: usize, const N2: usize, const LOG2: u32>(d: &mut [u16], base: usize, step: usize) {
|
||||
// v[k] = sample at offset k - (N + 1) from q0, k in 0 .. 2N + 2
|
||||
let mut v = [0i32; 14];
|
||||
let first = base - (N + 1) * step;
|
||||
for (k, x) in v[..2 * N + 2].iter_mut().enumerate() {
|
||||
*x = d[first + k * step] as i32;
|
||||
}
|
||||
// e[m] = v[clamp(m - N, 0, 2N + 1)]: the clamped tap positions as one array, so
|
||||
// F[i] = sum_{jj = 0}^{2N} e[i + 1 + jj] * (2 if |jj - N| <= N2 else 1), a sliding window.
|
||||
let mut e = [0i32; 25];
|
||||
for (m, x) in e[..4 * N + 1].iter_mut().enumerate() {
|
||||
*x = v[m.saturating_sub(N).min(2 * N + 1)];
|
||||
}
|
||||
let mut f = [0i32; 12];
|
||||
let mut win: i32 = e[1..2 * N + 2].iter().sum();
|
||||
for i in 0..2 * N {
|
||||
if i > 0 {
|
||||
win += e[i + 1 + 2 * N] - e[i];
|
||||
}
|
||||
let centre: i32 = e[i + 1 + N - N2..=i + 1 + N + N2].iter().sum();
|
||||
f[i] = (win + centre + (1 << (LOG2 - 1))) >> LOG2;
|
||||
}
|
||||
let out = base - N * step;
|
||||
for i in 0..2 * N {
|
||||
d[out + i * step] = f[i] as u16;
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::{
|
||||
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
|
||||
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
|
||||
UPSCALE_FILTER,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
|
||||
pub(crate) fn upscale(fs: &FrameState, input: &FrameBuf) -> FrameBuf {
|
||||
let fh = &fs.fh;
|
||||
let up_w = fh.upscaled_width as usize;
|
||||
let mut out = FrameBuf::new(up_w, fh.frame_height as usize, fs.num_planes, fs.ssx, fs.ssy, fs.bit_depth as u8);
|
||||
let max = (1i32 << fs.bit_depth) - 1;
|
||||
for plane in 0..fs.num_planes {
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx, fs.ssy) } else { (0, 0) };
|
||||
let down_w = ((fh.frame_width as usize) + sub_x) >> sub_x;
|
||||
let up_pw = (up_w + sub_x) >> sub_x;
|
||||
let plane_h = ((fh.frame_height as usize) + sub_y) >> sub_y;
|
||||
let sb = SUPERRES_SCALE_BITS as i64;
|
||||
let step_x = (((down_w as i64) << sb) + (up_pw as i64 / 2)) / up_pw as i64;
|
||||
let err = up_pw as i64 * step_x - ((down_w as i64) << sb);
|
||||
let mut initial = (-(((up_pw - down_w) as i64) << (sb - 1)) + up_pw as i64 / 2) / up_pw as i64 + (1 << (SUPERRES_EXTRA_BITS - 1)) - err / 2;
|
||||
initial &= SUPERRES_SCALE_MASK as i64;
|
||||
let mi_w = fh.mi_cols as usize >> sub_x;
|
||||
let max_x = (mi_w * 4 - 1) as i64;
|
||||
let src = &input.planes[plane];
|
||||
let dst = &mut out.planes[plane];
|
||||
for y in 0..plane_h {
|
||||
let row = src.row(y);
|
||||
for x in 0..up_pw {
|
||||
let src_x = -(1i64 << sb) + initial + x as i64 * step_x;
|
||||
let src_px = src_x >> sb;
|
||||
let subpel = ((src_x & SUPERRES_SCALE_MASK as i64) >> SUPERRES_EXTRA_BITS) as usize;
|
||||
let mut sum = 0i32;
|
||||
for k in 0..SUPERRES_FILTER_TAPS {
|
||||
let sx = (src_px + k as i64 - SUPERRES_FILTER_OFFSET as i64).clamp(0, max_x) as usize;
|
||||
sum += row[sx] as i32 * UPSCALE_FILTER[subpel][k] as i32;
|
||||
}
|
||||
dst.set(x, y, ((sum + 64) >> FILTER_BITS).clamp(0, max) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct LrCtx<'a> {
|
||||
cur: &'a Plane,
|
||||
cdef: &'a Plane,
|
||||
plane_end_x: i32,
|
||||
plane_end_y: i32,
|
||||
stripe_start_y: i32,
|
||||
stripe_end_y: i32,
|
||||
}
|
||||
|
||||
impl LrCtx<'_> {
|
||||
/// Get source sample process (7.17.6).
|
||||
#[inline(always)]
|
||||
fn sample(&self, x: i32, y: i32) -> i32 {
|
||||
let x = x.min(self.plane_end_x).max(0) as usize;
|
||||
let y = y.min(self.plane_end_y).max(0);
|
||||
if y < self.stripe_start_y {
|
||||
let y = (self.stripe_start_y - 2).max(y) as usize;
|
||||
self.cur.at(x, y) as i32
|
||||
} else if y > self.stripe_end_y {
|
||||
let y = (self.stripe_end_y + 2).min(y) as usize;
|
||||
self.cur.at(x, y) as i32
|
||||
} else {
|
||||
self.cdef.at(x, y as usize) as i32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Position and size of a (at most 4x4) restoration block within its plane.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LrBlock {
|
||||
x: i32,
|
||||
y: i32,
|
||||
w: i32,
|
||||
h: i32,
|
||||
}
|
||||
|
||||
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
|
||||
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
|
||||
let mut lr = cdef.clone();
|
||||
for plane in 0..fs.num_planes {
|
||||
if fs.fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
|
||||
continue;
|
||||
}
|
||||
restore_plane(fs, cur, cdef, &mut lr.planes[plane], plane);
|
||||
}
|
||||
lr
|
||||
}
|
||||
|
||||
fn restore_plane(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, out: &mut Plane, plane: usize) {
|
||||
let fh = &fs.fh;
|
||||
let up_w = fh.upscaled_width as i32;
|
||||
let fr_h = fh.frame_height as i32;
|
||||
let bd = fs.bit_depth;
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
|
||||
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
|
||||
let unit_rows = fs.lr_unit_rows[plane] as i32;
|
||||
let unit_cols = fs.lr_unit_cols[plane] as i32;
|
||||
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
|
||||
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
|
||||
let mut row = 0;
|
||||
while row * 4 < fr_h {
|
||||
let luma_y = row * 4;
|
||||
let stripe_num = (luma_y + 8) / 64;
|
||||
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
|
||||
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
|
||||
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
|
||||
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
|
||||
let mut col = 0;
|
||||
while col * 4 < up_w {
|
||||
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
|
||||
let x = (col * 4) >> sub_x;
|
||||
let y = (row * 4) >> sub_y;
|
||||
let w = (4 >> sub_x).min(plane_end_x - x + 1);
|
||||
let h = (4 >> sub_y).min(plane_end_y - y + 1);
|
||||
if w > 0 && h > 0 {
|
||||
let ui = (unit_row * unit_cols + unit_col) as usize;
|
||||
let blk = LrBlock { x, y, w, h };
|
||||
match fs.lr.lr_type[plane][ui] {
|
||||
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, (plane, ui), blk, bd),
|
||||
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, (plane, ui), blk, bd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let round0: u32 = if bd == 12 { 5 } else { 3 };
|
||||
let round1: u32 = if bd == 12 { 9 } else { 11 };
|
||||
let coef = &fs.lr.lr_wiener[plane][ui];
|
||||
let mk = |c: &[i8; 3]| -> [i32; 7] {
|
||||
let mut f = [0i32; 7];
|
||||
f[3] = 128;
|
||||
for i in 0..3 {
|
||||
f[i] = c[i] as i32;
|
||||
f[6 - i] = c[i] as i32;
|
||||
f[3] -= 2 * c[i] as i32;
|
||||
}
|
||||
f
|
||||
};
|
||||
let vfilter = mk(&coef[0]);
|
||||
let hfilter = mk(&coef[1]);
|
||||
let fb = FILTER_BITS as u32;
|
||||
let offset = 1i32 << (bd + fb - round0 - 1);
|
||||
let limit = (1i32 << (bd + 1 + fb - round0)) - 1;
|
||||
let mut inter = [[0i32; 4]; 10];
|
||||
for r in 0..(h + 6) as usize {
|
||||
for c in 0..w as usize {
|
||||
let mut s = 0;
|
||||
for t in 0..7 {
|
||||
s += hfilter[t] * ctx.sample(x + c as i32 + t as i32 - 3, y + r as i32 - 3);
|
||||
}
|
||||
let v = (s + (1 << (round0 - 1))) >> round0;
|
||||
inter[r][c] = v.clamp(-offset, limit - offset);
|
||||
}
|
||||
}
|
||||
let max = (1i32 << bd) - 1;
|
||||
for r in 0..h as usize {
|
||||
for c in 0..w as usize {
|
||||
let mut s = 0;
|
||||
for t in 0..7 {
|
||||
s += vfilter[t] * inter[r + t][c];
|
||||
}
|
||||
let v = (s + (1 << (round1 - 1))) >> round1;
|
||||
out.set(x as usize + c, y as usize + r, v.clamp(0, max) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, (plane, ui): (usize, usize), blk: LrBlock, bd: u32) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
|
||||
let mut flt0 = [[0i32; 4]; 4];
|
||||
let mut flt1 = [[0i32; 4]; 4];
|
||||
box_filter(ctx, blk, (set, 0), bd, &mut flt0);
|
||||
box_filter(ctx, blk, (set, 1), bd, &mut flt1);
|
||||
let xqd = fs.lr.lr_sgr_xqd[plane][ui];
|
||||
let w0 = xqd[0] as i32;
|
||||
let w1 = xqd[1] as i32;
|
||||
let w2 = (1 << SGRPROJ_PRJ_BITS) - w0 - w1;
|
||||
let r0 = SGR_PARAMS[set][0];
|
||||
let r1 = SGR_PARAMS[set][2];
|
||||
let max = (1i32 << bd) - 1;
|
||||
let sh = (SGRPROJ_RST_BITS + SGRPROJ_PRJ_BITS) as u32;
|
||||
for i in 0..h as usize {
|
||||
for j in 0..w as usize {
|
||||
let u = (ctx.cdef.at(x as usize + j, y as usize + i) as i32) << SGRPROJ_RST_BITS;
|
||||
let mut v = w1 * u;
|
||||
v += if r0 != 0 { w0 * flt0[i][j] } else { w0 * u };
|
||||
v += if r1 != 0 { w2 * flt1[i][j] } else { w2 * u };
|
||||
let s = (v + (1 << (sh - 1))) >> sh;
|
||||
out.set(x as usize + j, y as usize + i, s.clamp(0, max) as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i64 {
|
||||
if n == 0 { x } else { (x + (1i64 << (n - 1))) >> n }
|
||||
}
|
||||
|
||||
/// Box sums of the samples within `r` of (x, y): (sum of squares, sum).
|
||||
fn box_sums(ctx: &LrCtx, x: i32, y: i32, r: i32) -> (i64, i64) {
|
||||
let mut a = 0i64;
|
||||
let mut b = 0i64;
|
||||
for dy in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let c = ctx.sample(x + dx, y + dy) as i64;
|
||||
a += c * c;
|
||||
b += c;
|
||||
}
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Self-guided filter constants of one pass.
|
||||
struct SgrConsts {
|
||||
n: i64,
|
||||
s: i64,
|
||||
one_over_n: i64,
|
||||
}
|
||||
|
||||
/// (A, B) coefficients of one sample from its box sums.
|
||||
fn sgr_coefficients(a: i64, b: i64, k: &SgrConsts, bd: u32) -> (i64, i64) {
|
||||
let a = round2_64(a, 2 * (bd - 8));
|
||||
let d = round2_64(b, bd - 8);
|
||||
let p = (a * k.n - d * d).max(0);
|
||||
let z = round2_64(p * k.s, SGRPROJ_MTABLE_BITS as u32);
|
||||
let a2 = if z >= 255 {
|
||||
256
|
||||
} else if z == 0 {
|
||||
1
|
||||
} else {
|
||||
((z << SGRPROJ_SGR_BITS) + z / 2) / (z + 1)
|
||||
};
|
||||
let b2 = ((1i64 << SGRPROJ_SGR_BITS) - a2) * b * k.one_over_n;
|
||||
(a2, round2_64(b2, SGRPROJ_RECIP_BITS as u32))
|
||||
}
|
||||
|
||||
fn box_weight(pass: usize, i: i32, dx: i32, dy: i32) -> i64 {
|
||||
if pass == 0 {
|
||||
if ((i + dy) & 1) != 0 { if dx == 0 { 6 } else { 5 } } else { 0 }
|
||||
} else if dx == 0 || dy == 0 {
|
||||
4
|
||||
} else {
|
||||
3
|
||||
}
|
||||
}
|
||||
|
||||
fn box_filter(ctx: &LrCtx, blk: LrBlock, (set, pass): (usize, usize), bd: u32, f: &mut [[i32; 4]; 4]) {
|
||||
let LrBlock { x, y, w, h } = blk;
|
||||
let r = SGR_PARAMS[set][pass * 2] as i32;
|
||||
if r == 0 {
|
||||
return;
|
||||
}
|
||||
let eps = SGR_PARAMS[set][pass * 2 + 1] as i64;
|
||||
let n = ((2 * r + 1) * (2 * r + 1)) as i64;
|
||||
let n2e = n * n * eps;
|
||||
let k = SgrConsts { n, s: ((1i64 << SGRPROJ_MTABLE_BITS) + n2e / 2) / n2e, one_over_n: ((1i64 << SGRPROJ_RECIP_BITS) + n / 2) / n };
|
||||
let mut a_arr = [[0i64; 6]; 6];
|
||||
let mut b_arr = [[0i64; 6]; 6];
|
||||
for i in -1..h + 1 {
|
||||
for j in -1..w + 1 {
|
||||
let (a, b) = box_sums(ctx, x + j, y + i, r);
|
||||
let (a2, b2) = sgr_coefficients(a, b, &k, bd);
|
||||
a_arr[(i + 1) as usize][(j + 1) as usize] = a2;
|
||||
b_arr[(i + 1) as usize][(j + 1) as usize] = b2;
|
||||
}
|
||||
}
|
||||
for i in 0..h {
|
||||
let shift = if pass == 0 && (i & 1) == 1 { 4 } else { 5 };
|
||||
for j in 0..w {
|
||||
let mut a = 0i64;
|
||||
let mut b = 0i64;
|
||||
for dy in -1..=1i32 {
|
||||
for dx in -1..=1i32 {
|
||||
let weight = box_weight(pass, i, dx, dy);
|
||||
a += weight * a_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
|
||||
b += weight * b_arr[(i + dy + 1) as usize][(j + dx + 1) as usize];
|
||||
}
|
||||
}
|
||||
let v = a * ctx.cdef.at((x + j) as usize, (y + i) as usize) as i64 + b;
|
||||
f[i as usize][j as usize] = round2_64(v, (SGRPROJ_SGR_BITS + shift - SGRPROJ_RST_BITS) as u32) as i32;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,294 +0,0 @@
|
||||
//! Per-frame decoding state shared by the tiles and the post-filters.
|
||||
//!
|
||||
//! [`FrameShared`] holds what tiles only read (headers, references, projected motion field);
|
||||
//! [`TileState`] what a tile writes (reconstruction, mode info, per-block filter parameters,
|
||||
//! entropy contexts). With one tile the frame's own buffers move into the tile state and back;
|
||||
//! with several, each tile decodes into private buffers covering its area (tiles never read
|
||||
//! outside themselves), which are then copied into the frame.
|
||||
|
||||
use crate::decoder::RefFrame;
|
||||
use crate::frame::{FrameBuf, MiInfo};
|
||||
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
|
||||
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Read-only (during tile decoding) frame state.
|
||||
pub(crate) struct FrameShared {
|
||||
pub seq: SequenceHeader,
|
||||
pub fh: FrameHeader,
|
||||
pub num_planes: usize,
|
||||
pub ssx: usize,
|
||||
pub ssy: usize,
|
||||
pub bit_depth: u32,
|
||||
pub lr_unit_rows: [usize; 3],
|
||||
pub lr_unit_cols: [usize; 3],
|
||||
pub cdef_cols: usize,
|
||||
/// Reference slots (FrameStore and saved state) used by this frame.
|
||||
pub refs: [Option<Arc<RefFrame>>; NUM_REF_FRAMES],
|
||||
pub ref_info: [RefInfo; NUM_REF_FRAMES],
|
||||
/// MotionFieldMvs[ ref ][ y8 * w8 + x8 ] (7.9); empty when unused.
|
||||
pub motion_field: [Vec<[i32; 2]>; 8],
|
||||
/// PrevSegmentIds (mi units).
|
||||
pub prev_segment_ids: Vec<u8>,
|
||||
}
|
||||
|
||||
impl FrameShared {
|
||||
pub fn plane_residual_size(&self, subsize: usize, plane: usize) -> usize {
|
||||
let sx = if plane > 0 { self.ssx } else { 0 };
|
||||
let sy = if plane > 0 { self.ssy } else { 0 };
|
||||
SUBSAMPLED_SIZE[subsize][sx][sy] as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte per 4x4 unit of a plane (LoopfilterTxSizes), possibly for a region only.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Grid8 {
|
||||
pub data: Vec<u8>,
|
||||
pub ox: usize,
|
||||
pub oy: usize,
|
||||
pub stride: usize,
|
||||
pub rows: usize,
|
||||
}
|
||||
|
||||
impl Grid8 {
|
||||
fn new(ox: usize, oy: usize, stride: usize, rows: usize) -> Grid8 {
|
||||
Grid8 { data: vec![0; stride * rows], ox, oy, stride, rows }
|
||||
}
|
||||
#[inline]
|
||||
pub fn get(&self, row: usize, col: usize) -> u8 {
|
||||
self.data[(row - self.oy) * self.stride + col - self.ox]
|
||||
}
|
||||
/// Stores outside the grid are dropped (blocks reaching past the allocated margin).
|
||||
#[inline]
|
||||
pub fn set(&mut self, row: usize, col: usize, v: u8) {
|
||||
if col >= self.ox && col < self.ox + self.stride && row >= self.oy && row < self.oy + self.rows {
|
||||
self.data[(row - self.oy) * self.stride + col - self.ox] = v;
|
||||
}
|
||||
}
|
||||
fn copy_into(&self, dst: &mut Grid8) {
|
||||
for y in 0..self.rows {
|
||||
let d = (self.oy + y - dst.oy) * dst.stride + self.ox - dst.ox;
|
||||
dst.data[d..d + self.stride].copy_from_slice(&self.data[y * self.stride..(y + 1) * self.stride]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loop restoration parameters per unit.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct LrUnits {
|
||||
pub lr_type: [Vec<u8>; 3],
|
||||
pub lr_wiener: [Vec<[[i8; 3]; 2]>; 3],
|
||||
pub lr_sgr_set: [Vec<u8>; 3],
|
||||
pub lr_sgr_xqd: [Vec<[i8; 2]>; 3],
|
||||
}
|
||||
|
||||
/// What tile decoding writes.
|
||||
pub(crate) struct TileState {
|
||||
pub cur: FrameBuf,
|
||||
pub mi: MiInfo,
|
||||
/// cdef_idx per 64x64 block (-1: not coded).
|
||||
pub cdef: Vec<i8>,
|
||||
/// LoopfilterTxSizes[ plane ][ row ][ col ] in 4x4 units of each plane.
|
||||
pub lf_tx_size: [Grid8; 3],
|
||||
pub lr: LrUnits,
|
||||
/// LR units this tile coded (per plane), for merging.
|
||||
pub lr_written: [Vec<usize>; 3],
|
||||
pub above_level: [Vec<u8>; 3],
|
||||
pub above_dc: [Vec<u8>; 3],
|
||||
pub above_seg_pred: Vec<u8>,
|
||||
pub left_level: [Vec<u8>; 3],
|
||||
pub left_dc: [Vec<u8>; 3],
|
||||
pub left_seg_pred: Vec<u8>,
|
||||
/// TileIntraFrameYModeCdf (reset per tile).
|
||||
pub intra_frame_y_mode_cdf: [[[u16; 14]; 5]; 5],
|
||||
cdef_cols: usize,
|
||||
}
|
||||
|
||||
impl TileState {
|
||||
fn with(cur: FrameBuf, mi: MiInfo, cdef: Vec<i8>, lf_tx_size: [Grid8; 3], lr: LrUnits, cdef_cols: usize) -> TileState {
|
||||
let (mi_cols, mi_rows) = (mi.cols, mi.rows);
|
||||
TileState {
|
||||
cur,
|
||||
mi,
|
||||
cdef,
|
||||
lf_tx_size,
|
||||
lr,
|
||||
lr_written: Default::default(),
|
||||
above_level: std::array::from_fn(|_| vec![0; mi_cols + 64]),
|
||||
above_dc: std::array::from_fn(|_| vec![0; mi_cols + 64]),
|
||||
above_seg_pred: vec![0; mi_cols + 64],
|
||||
left_level: std::array::from_fn(|_| vec![0; mi_rows + 64]),
|
||||
left_dc: std::array::from_fn(|_| vec![0; mi_rows + 64]),
|
||||
left_seg_pred: vec![0; mi_rows + 64],
|
||||
intra_frame_y_mode_cdf: DEFAULT_INTRA_FRAME_Y_MODE_CDF,
|
||||
cdef_cols,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn cdef_idx(&self, r: usize, c: usize) -> i8 {
|
||||
self.cdef[(r >> 4) * self.cdef_cols + (c >> 4)]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_cdef_idx(&mut self, r: usize, c: usize, v: i8) {
|
||||
let i = (r >> 4) * self.cdef_cols + (c >> 4);
|
||||
if i < self.cdef.len() {
|
||||
self.cdef[i] = v;
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn set_lf_tx_size(&mut self, plane: usize, row: usize, col: usize, v: u8) {
|
||||
self.lf_tx_size[plane].set(row, col, v);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct FrameState {
|
||||
pub sh: FrameShared,
|
||||
pub cur: FrameBuf,
|
||||
pub mi: MiInfo,
|
||||
cdef: Vec<i8>,
|
||||
lf_tx_size: [Grid8; 3],
|
||||
pub lr: LrUnits,
|
||||
}
|
||||
|
||||
impl Deref for FrameState {
|
||||
type Target = FrameShared;
|
||||
fn deref(&self) -> &FrameShared {
|
||||
&self.sh
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for FrameState {
|
||||
fn deref_mut(&mut self) -> &mut FrameShared {
|
||||
&mut self.sh
|
||||
}
|
||||
}
|
||||
|
||||
fn count_units_in_frame(unit_size: usize, frame_size: usize) -> usize {
|
||||
((frame_size + (unit_size >> 1)) / unit_size).max(1)
|
||||
}
|
||||
|
||||
impl FrameState {
|
||||
pub fn new(seq: &SequenceHeader, fh: &FrameHeader) -> FrameState {
|
||||
let c = &seq.color;
|
||||
let (ssx, ssy) = (c.subsampling_x as usize, c.subsampling_y as usize);
|
||||
let mi_cols = fh.mi_cols as usize;
|
||||
let mi_rows = fh.mi_rows as usize;
|
||||
let cur = FrameBuf::new(fh.frame_width as usize, fh.frame_height as usize, c.num_planes, ssx, ssy, c.bit_depth);
|
||||
let cdef_cols = mi_cols.div_ceil(16) + 2;
|
||||
let cdef_rows = mi_rows.div_ceil(16) + 2;
|
||||
let mut lr_unit_rows = [0; 3];
|
||||
let mut lr_unit_cols = [0; 3];
|
||||
let mut lr = LrUnits::default();
|
||||
for plane in 0..c.num_planes {
|
||||
if fh.lr.frame_restoration_type[plane] == RESTORE_NONE as u8 {
|
||||
continue;
|
||||
}
|
||||
let sub_x = if plane == 0 { 0 } else { ssx };
|
||||
let sub_y = if plane == 0 { 0 } else { ssy };
|
||||
let unit = fh.lr.loop_restoration_size[plane] as usize;
|
||||
lr_unit_rows[plane] = count_units_in_frame(unit, (fh.frame_height as usize + sub_y) >> sub_y);
|
||||
lr_unit_cols[plane] = count_units_in_frame(unit, (fh.upscaled_width as usize + sub_x) >> sub_x);
|
||||
let n = lr_unit_rows[plane] * lr_unit_cols[plane];
|
||||
lr.lr_type[plane] = vec![RESTORE_NONE as u8; n];
|
||||
lr.lr_wiener[plane] = vec![[[0; 3]; 2]; n];
|
||||
lr.lr_sgr_set[plane] = vec![0; n];
|
||||
lr.lr_sgr_xqd[plane] = vec![[0; 2]; n];
|
||||
}
|
||||
FrameState {
|
||||
sh: FrameShared {
|
||||
seq: seq.clone(),
|
||||
fh: fh.clone(),
|
||||
num_planes: c.num_planes,
|
||||
ssx,
|
||||
ssy,
|
||||
bit_depth: c.bit_depth as u32,
|
||||
lr_unit_rows,
|
||||
lr_unit_cols,
|
||||
cdef_cols,
|
||||
refs: Default::default(),
|
||||
ref_info: Default::default(),
|
||||
motion_field: Default::default(),
|
||||
prev_segment_ids: vec![0; mi_cols * mi_rows],
|
||||
},
|
||||
cur,
|
||||
mi: MiInfo::new(mi_cols, mi_rows, fh.allow_screen_content_tools),
|
||||
cdef: vec![-1; cdef_cols * cdef_rows],
|
||||
lf_tx_size: std::array::from_fn(|_| Grid8::new(0, 0, mi_cols + 32, mi_rows + 32)),
|
||||
lr,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn cdef_idx(&self, r: usize, c: usize) -> i8 {
|
||||
self.cdef[(r >> 4) * self.cdef_cols + (c >> 4)]
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn lf_tx_size(&self, plane: usize, row: usize, col: usize) -> usize {
|
||||
self.lf_tx_size[plane].get(row, col) as usize
|
||||
}
|
||||
|
||||
/// Move the frame's buffers into a tile state covering the whole frame.
|
||||
pub fn take_tile_state(&mut self) -> TileState {
|
||||
TileState::with(
|
||||
std::mem::take(&mut self.cur),
|
||||
std::mem::take(&mut self.mi),
|
||||
std::mem::take(&mut self.cdef),
|
||||
std::mem::take(&mut self.lf_tx_size),
|
||||
std::mem::take(&mut self.lr),
|
||||
self.cdef_cols,
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the buffers taken by [`FrameState::take_tile_state`].
|
||||
pub fn put_tile_state(&mut self, t: TileState) {
|
||||
self.cur = t.cur;
|
||||
self.mi = t.mi;
|
||||
self.cdef = t.cdef;
|
||||
self.lf_tx_size = t.lf_tx_size;
|
||||
self.lr = t.lr;
|
||||
}
|
||||
|
||||
/// Private buffers for the tile covering mode info rows [r0, r1) x columns [c0, c1); tiles
|
||||
/// on the right / bottom frame edge also cover the allocation margins.
|
||||
pub fn tile_state(&self, r0: usize, r1: usize, c0: usize, c1: usize) -> TileState {
|
||||
let (mi_cols, mi_rows) = (self.mi.cols, self.mi.rows);
|
||||
let x1 = if c1 >= mi_cols { usize::MAX } else { c1 * 4 };
|
||||
let y1 = if r1 >= mi_rows { usize::MAX } else { r1 * 4 };
|
||||
let cur = self.cur.region_like(c0 * 4, r0 * 4, x1, y1);
|
||||
let mi = MiInfo::region(mi_cols, mi_rows, c0, r0, c1 - c0, r1 - r0, !self.mi.palette_size[0].is_empty());
|
||||
let lf = std::array::from_fn(|p| {
|
||||
let (sx, sy) = if p == 0 { (0, 0) } else { (self.ssx, self.ssy) };
|
||||
let full = &self.lf_tx_size[p];
|
||||
let (gx0, gy0) = (c0 >> sx, r0 >> sy);
|
||||
let gx1 = if c1 >= mi_cols { full.stride } else { c1 >> sx };
|
||||
let gy1 = if r1 >= mi_rows { full.rows } else { r1 >> sy };
|
||||
Grid8::new(gx0, gy0, gx1 - gx0, gy1 - gy0)
|
||||
});
|
||||
TileState::with(cur, mi, vec![-1; self.cdef.len()], lf, self.lr.clone(), self.cdef_cols)
|
||||
}
|
||||
|
||||
/// Copy a tile's private buffers into the frame.
|
||||
pub fn merge_tile_state(&mut self, t: &TileState) {
|
||||
for p in 0..self.num_planes {
|
||||
t.cur.planes[p].copy_into(&mut self.cur.planes[p]);
|
||||
t.lf_tx_size[p].copy_into(&mut self.lf_tx_size[p]);
|
||||
for &u in &t.lr_written[p] {
|
||||
self.lr.lr_type[p][u] = t.lr.lr_type[p][u];
|
||||
self.lr.lr_wiener[p][u] = t.lr.lr_wiener[p][u];
|
||||
self.lr.lr_sgr_set[p][u] = t.lr.lr_sgr_set[p][u];
|
||||
self.lr.lr_sgr_xqd[p][u] = t.lr.lr_sgr_xqd[p][u];
|
||||
}
|
||||
}
|
||||
t.mi.copy_into(&mut self.mi);
|
||||
for (d, &s) in self.cdef.iter_mut().zip(&t.cdef) {
|
||||
if s != -1 {
|
||||
*d = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
//! Decode statistics: frame / tile counts and the busy time of every decoding stage.
|
||||
|
||||
/// A decoding stage (see [`DecodeStats::stage_secs`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Stage {
|
||||
/// Frame header follow-up: reference setup, motion field estimation, CDF initialisation.
|
||||
Setup,
|
||||
/// Tile decoding: mode info, coefficients, prediction and reconstruction.
|
||||
Tiles,
|
||||
/// Deblocking loop filter (7.14).
|
||||
LoopFilter,
|
||||
/// CDEF (7.15).
|
||||
Cdef,
|
||||
/// Super-resolution upscaling (7.16).
|
||||
Superres,
|
||||
/// Loop restoration (7.17).
|
||||
Restoration,
|
||||
/// Reference update, motion vector storage, output copy and film grain synthesis.
|
||||
Output,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
pub const COUNT: usize = 7;
|
||||
pub const ALL: [Stage; Stage::COUNT] =
|
||||
[Stage::Setup, Stage::Tiles, Stage::LoopFilter, Stage::Cdef, Stage::Superres, Stage::Restoration, Stage::Output];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Stage::Setup => "setup",
|
||||
Stage::Tiles => "tiles",
|
||||
Stage::LoopFilter => "deblock",
|
||||
Stage::Cdef => "cdef",
|
||||
Stage::Superres => "superres",
|
||||
Stage::Restoration => "restoration",
|
||||
Stage::Output => "output",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters accumulated by a [`crate::Decoder`]. Stage times are busy time summed over every
|
||||
/// thread that worked on the stage (wall-clock per piece of work), so with threads they can add
|
||||
/// up to more than the elapsed time.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct DecodeStats {
|
||||
/// Frames decoded (excluding show_existing_frame repeats).
|
||||
pub frames: u64,
|
||||
/// Frames decoded in draft mode without in-loop filters.
|
||||
pub draft_frames: u64,
|
||||
/// Tiles decoded.
|
||||
pub tiles: u64,
|
||||
/// Busy seconds per [`Stage`] (index with `stage as usize`).
|
||||
pub stage_secs: [f64; Stage::COUNT],
|
||||
}
|
||||
|
||||
impl DecodeStats {
|
||||
pub fn secs(&self, stage: Stage) -> f64 {
|
||||
self.stage_secs[stage as usize]
|
||||
}
|
||||
|
||||
pub(crate) fn add(&mut self, stage: Stage, secs: f64) {
|
||||
self.stage_secs[stage as usize] += secs;
|
||||
}
|
||||
|
||||
pub(crate) fn merge(&mut self, o: &DecodeStats) {
|
||||
self.frames += o.frames;
|
||||
self.draft_frames += o.draft_frames;
|
||||
self.tiles += o.tiles;
|
||||
for (a, b) in self.stage_secs.iter_mut().zip(o.stage_secs.iter()) {
|
||||
*a += b;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum of all stage times.
|
||||
pub fn total_secs(&self) -> f64 {
|
||||
self.stage_secs.iter().sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// A wall-clock stopwatch (a no-op on wasm32, which has no clock in std).
|
||||
pub(crate) struct Timer {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant,
|
||||
}
|
||||
|
||||
impl Timer {
|
||||
#[inline]
|
||||
pub fn start() -> Timer {
|
||||
Timer {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
start: std::time::Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since `start` (0 on wasm32).
|
||||
#[inline]
|
||||
pub fn secs(&self) -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.start.elapsed().as_secs_f64()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since `start`, restarting the stopwatch.
|
||||
#[inline]
|
||||
pub fn lap(&mut self) -> f64 {
|
||||
let s = self.secs();
|
||||
*self = Timer::start();
|
||||
s
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
//! Symbol (arithmetic) decoder, spec 8.2.
|
||||
//!
|
||||
//! The state follows the specification literally: `SymbolValue` / `SymbolRange` are 15/16-bit
|
||||
//! quantities renormalised by reading `bits` new bits at a time, and `SymbolMaxBits` counts the
|
||||
//! bits still available (going negative while zero padding is consumed).
|
||||
|
||||
const EC_PROB_SHIFT: u32 = 6;
|
||||
const EC_MIN_PROB: u32 = 4;
|
||||
|
||||
pub(crate) struct SymbolDecoder<'a> {
|
||||
data: &'a [u8],
|
||||
/// Next byte of `data` to load into the window.
|
||||
next: usize,
|
||||
/// SymbolValue followed by `k` look-ahead bits: the bitstream with every bit inverted
|
||||
/// (the spec's renormalisation XORs the new bits with ones), zero bits past the end of
|
||||
/// the data reading as ones. SymbolValue = win >> k.
|
||||
win: u64,
|
||||
k: u32,
|
||||
range: u32,
|
||||
pub disable_update: bool,
|
||||
}
|
||||
|
||||
impl<'a> SymbolDecoder<'a> {
|
||||
/// `init_symbol( sz )` over `data` (the tile's bytes).
|
||||
pub fn new(data: &'a [u8], disable_update: bool) -> Self {
|
||||
let mut d = SymbolDecoder { data, next: 0, win: 0, k: 0, range: 1 << 15, disable_update };
|
||||
// SymbolValue = ((1 << 15) - 1) ^ (first 15 bits): 15 inverted bits.
|
||||
let mut filled = 0;
|
||||
while filled < 15 + 32 {
|
||||
d.win = (d.win << 8) | d.inverted_byte() as u64;
|
||||
filled += 8;
|
||||
}
|
||||
d.k = filled - 15;
|
||||
d
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn inverted_byte(&mut self) -> u8 {
|
||||
let b = self.data.get(self.next).copied().unwrap_or(0);
|
||||
self.next += 1;
|
||||
!b
|
||||
}
|
||||
|
||||
/// `read_symbol( cdf )`: `cdf` has N + 1 entries (the last is the adaptation counter).
|
||||
#[inline]
|
||||
pub fn read_symbol(&mut self, cdf: &mut [u16]) -> usize {
|
||||
let n = cdf.len() - 1;
|
||||
let symbol = self.decode(cdf, n);
|
||||
if !self.disable_update {
|
||||
update_cdf(cdf, n, symbol);
|
||||
}
|
||||
symbol
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn decode(&mut self, cdf: &[u16], n: usize) -> usize {
|
||||
let value = (self.win >> self.k) as u32;
|
||||
let mut cur = self.range;
|
||||
let mut symbol = 0usize;
|
||||
let mut prev;
|
||||
loop {
|
||||
prev = cur;
|
||||
let f = (1u32 << 15) - cdf[symbol] as u32;
|
||||
cur = ((self.range >> 8) * (f >> EC_PROB_SHIFT)) >> (7 - EC_PROB_SHIFT);
|
||||
cur += EC_MIN_PROB * (n - symbol - 1) as u32;
|
||||
if value >= cur {
|
||||
break;
|
||||
}
|
||||
symbol += 1;
|
||||
}
|
||||
self.range = prev - cur;
|
||||
self.win -= (cur as u64) << self.k;
|
||||
self.renormalize();
|
||||
symbol
|
||||
}
|
||||
|
||||
/// Renormalisation (8.2.6): SymbolValue takes `bits` more (inverted) bits, which are
|
||||
/// already in the window.
|
||||
#[inline(always)]
|
||||
fn renormalize(&mut self) {
|
||||
let bits = 15 - (31 - self.range.leading_zeros());
|
||||
self.range <<= bits;
|
||||
self.k -= bits;
|
||||
if self.k < 16 {
|
||||
// SymbolValue < 2^16, so 16 + k <= 64 bits stay in the window.
|
||||
while self.k <= 40 {
|
||||
self.win = (self.win << 8) | self.inverted_byte() as u64;
|
||||
self.k += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `read_bool()`
|
||||
#[inline]
|
||||
pub fn read_bool(&mut self) -> u32 {
|
||||
let cdf = [1u16 << 14, 1 << 15, 0];
|
||||
self.decode(&cdf, 2) as u32
|
||||
}
|
||||
|
||||
/// `read_literal( n )` (L(n))
|
||||
pub fn read_literal(&mut self, n: u32) -> u32 {
|
||||
let mut x = 0;
|
||||
for _ in 0..n {
|
||||
x = (x << 1) | self.read_bool();
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// NS(n)
|
||||
pub fn read_ns(&mut self, n: u32) -> u32 {
|
||||
if n <= 1 {
|
||||
return 0;
|
||||
}
|
||||
let w = 32 - n.leading_zeros();
|
||||
let m = (1u32 << w) - n;
|
||||
let v = self.read_literal(w - 1);
|
||||
if v < m {
|
||||
return v;
|
||||
}
|
||||
let extra = self.read_literal(1);
|
||||
(v << 1) - m + extra
|
||||
}
|
||||
}
|
||||
|
||||
/// CDF adaptation (8.2.6).
|
||||
#[inline(always)]
|
||||
pub(crate) fn update_cdf(cdf: &mut [u16], n: usize, symbol: usize) {
|
||||
let count = cdf[n];
|
||||
let rate = 3 + (count > 15) as u32 + (count > 31) as u32 + (31 - (n as u32).leading_zeros()).min(2);
|
||||
// Entries below `symbol` move towards 0, the others towards 1 << 15 (the specification's
|
||||
// comparison with tmp, resolved per entry: c >= 0 and c <= 1 << 15), without branching on
|
||||
// the decoded symbol.
|
||||
for (i, e) in cdf[..n - 1].iter_mut().enumerate() {
|
||||
let c = *e as u32;
|
||||
*e = if i < symbol { c - (c >> rate) } else { c + (((1 << 15) - c) >> rate) } as u16;
|
||||
}
|
||||
cdf[n] += (count < 32) as u16;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,780 +0,0 @@
|
||||
//! Inverse transforms (spec 7.13), implemented step by step as specified.
|
||||
|
||||
use crate::spec_tables::{
|
||||
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
|
||||
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
|
||||
};
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i32 {
|
||||
if n == 0 { x as i32 } else { ((x + (1i64 << (n - 1))) >> n) as i32 }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub(crate) fn round2(x: i32, n: u32) -> i32 {
|
||||
if n == 0 { x } else { (x + (1 << (n - 1))) >> n }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn cos128(angle: i32) -> i64 {
|
||||
let a = (angle & 255) as usize;
|
||||
(match a {
|
||||
0..=64 => COS128_LOOKUP[a] as i32,
|
||||
65..=128 => -(COS128_LOOKUP[128 - a] as i32),
|
||||
129..=192 => -(COS128_LOOKUP[a - 128] as i32),
|
||||
_ => COS128_LOOKUP[256 - a] as i32,
|
||||
}) as i64
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn sin128(angle: i32) -> i64 {
|
||||
cos128(angle - 64)
|
||||
}
|
||||
|
||||
/// One value of the 1D transforms, or `L` independent columns / rows of them processed
|
||||
/// together (lane-wise, so the steps vectorise).
|
||||
pub(crate) trait Lane: Copy {
|
||||
const ZERO: Self;
|
||||
/// (Round2( a * cos - b * sin, 12 ), Round2( a * sin + b * cos, 12 ))
|
||||
fn rotate(a: Self, b: Self, cos: i32, sin: i32) -> (Self, Self);
|
||||
/// (Clamp( x + y ), Clamp( x - y )) to [lo, hi]
|
||||
fn hadamard(x: Self, y: Self, lo: i32, hi: i32) -> (Self, Self);
|
||||
fn neg(self) -> Self;
|
||||
/// Round2( v * m, 12 )
|
||||
fn mul_round12(self, m: i32) -> Self;
|
||||
fn mul(self, k: i32) -> Self;
|
||||
fn adst4(t: &mut [Self]);
|
||||
}
|
||||
|
||||
impl Lane for i32 {
|
||||
const ZERO: i32 = 0;
|
||||
#[inline(always)]
|
||||
fn rotate(a: i32, b: i32, cos: i32, sin: i32) -> (i32, i32) {
|
||||
let (a, b, c, s) = (a as i64, b as i64, cos as i64, sin as i64);
|
||||
(round2_64(a * c - b * s, 12), round2_64(a * s + b * c, 12))
|
||||
}
|
||||
#[inline(always)]
|
||||
fn hadamard(x: i32, y: i32, lo: i32, hi: i32) -> (i32, i32) {
|
||||
((x + y).clamp(lo, hi), (x - y).clamp(lo, hi))
|
||||
}
|
||||
#[inline(always)]
|
||||
fn neg(self) -> i32 {
|
||||
-self
|
||||
}
|
||||
#[inline(always)]
|
||||
fn mul_round12(self, m: i32) -> i32 {
|
||||
round2_64(self as i64 * m as i64, 12)
|
||||
}
|
||||
#[inline(always)]
|
||||
fn mul(self, k: i32) -> i32 {
|
||||
self * k
|
||||
}
|
||||
fn adst4(t: &mut [i32]) {
|
||||
inverse_adst4_scalar(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lanes in 32-bit arithmetic: exact for conforming streams up to 10 bits per sample (the
|
||||
/// spec requires every intermediate value to fit in 8 + BitDepth <= 18 bits, so products with
|
||||
/// the 12-bit cosines stay below 2^31). Wrapping operations keep corrupt streams from
|
||||
/// panicking.
|
||||
impl<const L: usize> Lane for [i32; L] {
|
||||
const ZERO: Self = [0; L];
|
||||
#[inline(always)]
|
||||
fn rotate(a: Self, b: Self, cos: i32, sin: i32) -> (Self, Self) {
|
||||
let mut x = [0; L];
|
||||
let mut y = [0; L];
|
||||
for l in 0..L {
|
||||
let p = a[l].wrapping_mul(cos).wrapping_sub(b[l].wrapping_mul(sin));
|
||||
let q = a[l].wrapping_mul(sin).wrapping_add(b[l].wrapping_mul(cos));
|
||||
x[l] = p.wrapping_add(1 << 11) >> 12;
|
||||
y[l] = q.wrapping_add(1 << 11) >> 12;
|
||||
}
|
||||
(x, y)
|
||||
}
|
||||
#[inline(always)]
|
||||
fn hadamard(x: Self, y: Self, lo: i32, hi: i32) -> (Self, Self) {
|
||||
let mut a = [0; L];
|
||||
let mut b = [0; L];
|
||||
for l in 0..L {
|
||||
a[l] = x[l].wrapping_add(y[l]).max(lo).min(hi);
|
||||
b[l] = x[l].wrapping_sub(y[l]).max(lo).min(hi);
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
#[inline(always)]
|
||||
fn neg(self) -> Self {
|
||||
self.map(i32::wrapping_neg)
|
||||
}
|
||||
#[inline(always)]
|
||||
fn mul_round12(self, m: i32) -> Self {
|
||||
self.map(|v| v.wrapping_mul(m).wrapping_add(1 << 11) >> 12)
|
||||
}
|
||||
#[inline(always)]
|
||||
fn mul(self, k: i32) -> Self {
|
||||
self.map(|v| v.wrapping_mul(k))
|
||||
}
|
||||
fn adst4(t: &mut [Self]) {
|
||||
for l in 0..L {
|
||||
let mut v = [t[0][l], t[1][l], t[2][l], t[3][l]];
|
||||
inverse_adst4_scalar(&mut v);
|
||||
for k in 0..4 {
|
||||
t[k][l] = v[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn brev(num_bits: u32, x: usize) -> usize {
|
||||
let mut t = 0;
|
||||
for i in 0..num_bits {
|
||||
let bit = (x >> i) & 1;
|
||||
t += bit << (num_bits - 1 - i);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Butterfly rotation B( a, b, angle, flip ).
|
||||
#[inline(always)]
|
||||
fn bf<T: Lane>(t: &mut [T], a: usize, b: usize, angle: i32, flip: bool) {
|
||||
let (x, y) = T::rotate(t[a], t[b], cos128(angle) as i32, sin128(angle) as i32);
|
||||
t[a] = x;
|
||||
t[b] = y;
|
||||
if flip {
|
||||
t.swap(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hadamard rotation H( a, b, flip, r ).
|
||||
#[inline(always)]
|
||||
fn hd<T: Lane>(t: &mut [T], a: usize, b: usize, flip: bool, r: u32) {
|
||||
let (a, b) = if flip { (b, a) } else { (a, b) };
|
||||
let lo = -(1i32 << (r - 1));
|
||||
let hi = (1i32 << (r - 1)) - 1;
|
||||
let (x, y) = T::hadamard(t[a], t[b], lo, hi);
|
||||
t[a] = x;
|
||||
t[b] = y;
|
||||
}
|
||||
|
||||
/// Inverse DCT of length 2^n (7.13.2.3), in place.
|
||||
pub(crate) fn inverse_dct<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
// 7.13.2.2 permutation
|
||||
let len = 1usize << n;
|
||||
let mut copy = [T::ZERO; 64];
|
||||
copy[..len].copy_from_slice(&t[..len]);
|
||||
for i in 0..len {
|
||||
t[i] = copy[brev(n, i)];
|
||||
}
|
||||
inverse_dct_stage1(t, n, r);
|
||||
inverse_dct_stage2(t, n, r);
|
||||
inverse_dct_stage3(t, n, r);
|
||||
inverse_dct_stage4(t, n, r);
|
||||
inverse_dct_stage5(t, n, r);
|
||||
inverse_dct_stage6(t, n, r);
|
||||
}
|
||||
|
||||
fn inverse_dct_stage1<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n == 6 {
|
||||
for i in 0..16 {
|
||||
bf(t, 32 + i, 63 - i, 63 - 4 * brev(4, i) as i32, false);
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
for i in 0..8 {
|
||||
bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false);
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..16 {
|
||||
hd(t, 32 + i * 2, 33 + i * 2, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
if n >= 4 {
|
||||
for i in 0..4 {
|
||||
bf(t, 8 + i, 15 - i, 12 + ((brev(2, 3 - i) as i32) << 4), false);
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
for i in 0..8 {
|
||||
hd(t, 16 + 2 * i, 17 + 2 * i, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage2<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n == 6 {
|
||||
for i in 0..4 {
|
||||
for j in 0..2 {
|
||||
bf(t, 62 - i * 4 - j, 33 + i * 4 + j, 60 - 16 * brev(2, i) as i32 + 64 * j as i32, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if n >= 3 {
|
||||
for i in 0..2 {
|
||||
bf(t, 4 + i, 7 - i, 56 - 32 * i as i32, false);
|
||||
}
|
||||
}
|
||||
if n >= 4 {
|
||||
for i in 0..4 {
|
||||
hd(t, 8 + 2 * i, 9 + 2 * i, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
for i in 0..2 {
|
||||
for j in 0..2 {
|
||||
bf(t, 30 - 4 * i - j, 17 + 4 * i + j, 24 + ((j as i32) << 6) + (((1 - i) as i32) << 5), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..8 {
|
||||
for j in 0..2 {
|
||||
hd(t, 32 + i * 4 + j, 35 + i * 4 - j, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage3<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0);
|
||||
}
|
||||
if n >= 3 {
|
||||
for i in 0..2 {
|
||||
hd(t, 4 + 2 * i, 5 + 2 * i, i == 1, r);
|
||||
}
|
||||
}
|
||||
if n >= 4 {
|
||||
for i in 0..2 {
|
||||
bf(t, 14 - i, 9 + i, 48 + 64 * i as i32, true);
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
for i in 0..4 {
|
||||
for j in 0..2 {
|
||||
hd(t, 16 + 4 * i + j, 19 + 4 * i - j, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..2 {
|
||||
for j in 0..4 {
|
||||
bf(t, 61 - i * 8 - j, 34 + i * 8 + j, 56 - i as i32 * 32 + (j as i32 >> 1) * 64, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage4<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
for i in 0..2 {
|
||||
hd(t, i, 3 - i, false, r);
|
||||
}
|
||||
if n >= 3 {
|
||||
bf(t, 6, 5, 32, true);
|
||||
}
|
||||
if n >= 4 {
|
||||
for i in 0..2 {
|
||||
for j in 0..2 {
|
||||
hd(t, 8 + 4 * i + j, 11 + 4 * i - j, i == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
for i in 0..4 {
|
||||
bf(t, 29 - i, 18 + i, 48 + (i as i32 >> 1) * 64, true);
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..4 {
|
||||
for j in 0..4 {
|
||||
hd(t, 32 + 8 * i + j, 39 + 8 * i - j, i & 1 == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage5<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n >= 3 {
|
||||
for i in 0..4 {
|
||||
hd(t, i, 7 - i, false, r);
|
||||
}
|
||||
}
|
||||
if n >= 4 {
|
||||
for i in 0..2 {
|
||||
bf(t, 13 - i, 10 + i, 32, true);
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
for i in 0..2 {
|
||||
for j in 0..4 {
|
||||
hd(t, 16 + i * 8 + j, 23 + i * 8 - j, i == 1, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..8 {
|
||||
bf(t, 59 - i, 36 + i, if i < 4 { 48 } else { 112 }, true);
|
||||
}
|
||||
}
|
||||
if n >= 4 {
|
||||
for i in 0..8 {
|
||||
hd(t, i, 15 - i, false, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_dct_stage6<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
if n >= 5 {
|
||||
for i in 0..4 {
|
||||
bf(t, 27 - i, 20 + i, 32, true);
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..8 {
|
||||
hd(t, 32 + i, 47 - i, false, r);
|
||||
hd(t, 48 + i, 63 - i, true, r);
|
||||
}
|
||||
}
|
||||
if n >= 5 {
|
||||
for i in 0..16 {
|
||||
hd(t, i, 31 - i, false, r);
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..8 {
|
||||
bf(t, 55 - i, 40 + i, 32, true);
|
||||
}
|
||||
}
|
||||
if n == 6 {
|
||||
for i in 0..32 {
|
||||
hd(t, i, 63 - i, false, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SINPI_1_9: i64 = 1321;
|
||||
const SINPI_2_9: i64 = 2482;
|
||||
const SINPI_3_9: i64 = 3344;
|
||||
const SINPI_4_9: i64 = 3803;
|
||||
|
||||
fn inverse_adst4_scalar(t: &mut [i32]) {
|
||||
let mut s = [0i64; 7];
|
||||
let mut x = [0i64; 4];
|
||||
let t0 = t[0] as i64;
|
||||
let t1 = t[1] as i64;
|
||||
let t2 = t[2] as i64;
|
||||
let t3 = t[3] as i64;
|
||||
s[0] = SINPI_1_9 * t0;
|
||||
s[1] = SINPI_2_9 * t0;
|
||||
s[2] = SINPI_3_9 * t1;
|
||||
s[3] = SINPI_4_9 * t2;
|
||||
s[4] = SINPI_1_9 * t2;
|
||||
s[5] = SINPI_2_9 * t3;
|
||||
s[6] = SINPI_4_9 * t3;
|
||||
let a7 = t0 - t2;
|
||||
let b7 = a7 + t3;
|
||||
s[0] += s[3];
|
||||
s[1] -= s[4];
|
||||
s[3] = s[2];
|
||||
s[2] = SINPI_3_9 * b7;
|
||||
s[0] += s[5];
|
||||
s[1] -= s[6];
|
||||
x[0] = s[0] + s[3];
|
||||
x[1] = s[1] + s[3];
|
||||
x[2] = s[2];
|
||||
x[3] = s[0] + s[1];
|
||||
x[3] -= s[3];
|
||||
for i in 0..4 {
|
||||
t[i] = round2_64(x[i], 12);
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_in_permute<T: Lane>(t: &mut [T], n: u32) {
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [T::ZERO; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
for i in 0..n0 {
|
||||
let idx = if i & 1 == 1 { i - 1 } else { n0 - i - 1 };
|
||||
t[i] = copy[idx];
|
||||
}
|
||||
}
|
||||
|
||||
fn adst_out_permute<T: Lane>(t: &mut [T], n: u32) {
|
||||
let n0 = 1usize << n;
|
||||
let mut copy = [T::ZERO; 16];
|
||||
copy[..n0].copy_from_slice(&t[..n0]);
|
||||
for i in 0..n0 {
|
||||
let a = (i >> 3) & 1;
|
||||
let b = ((i >> 2) & 1) ^ ((i >> 3) & 1);
|
||||
let c = ((i >> 1) & 1) ^ ((i >> 2) & 1);
|
||||
let d = (i & 1) ^ ((i >> 1) & 1);
|
||||
let idx = ((d << 3) | (c << 2) | (b << 1) | a) >> (4 - n);
|
||||
t[i] = if i & 1 == 1 { copy[idx].neg() } else { copy[idx] };
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_adst8<T: Lane>(t: &mut [T], r: u32) {
|
||||
adst_in_permute(t, 3);
|
||||
for i in 0..4 {
|
||||
bf(t, 2 * i, 2 * i + 1, 60 - 16 * i as i32, true);
|
||||
}
|
||||
for i in 0..4 {
|
||||
hd(t, i, 4 + i, false, r);
|
||||
}
|
||||
for i in 0..2 {
|
||||
bf(t, 4 + 3 * i, 5 + i, 48 - 32 * i as i32, true);
|
||||
}
|
||||
for i in 0..2 {
|
||||
for j in 0..2 {
|
||||
hd(t, 4 * j + i, 2 + 4 * j + i, false, r);
|
||||
}
|
||||
}
|
||||
for i in 0..2 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 3);
|
||||
}
|
||||
|
||||
fn inverse_adst16<T: Lane>(t: &mut [T], r: u32) {
|
||||
adst_in_permute(t, 4);
|
||||
for i in 0..8 {
|
||||
bf(t, 2 * i, 2 * i + 1, 62 - 8 * i as i32, true);
|
||||
}
|
||||
for i in 0..8 {
|
||||
hd(t, i, 8 + i, false, r);
|
||||
}
|
||||
for i in 0..2 {
|
||||
bf(t, 8 + 2 * i, 9 + 2 * i, 56 - 32 * i as i32, true);
|
||||
bf(t, 13 + 2 * i, 12 + 2 * i, 8 + 32 * i as i32, true);
|
||||
}
|
||||
for i in 0..4 {
|
||||
for j in 0..2 {
|
||||
hd(t, 8 * j + i, 4 + 8 * j + i, false, r);
|
||||
}
|
||||
}
|
||||
for i in 0..2 {
|
||||
for j in 0..2 {
|
||||
bf(t, 4 + 8 * j + 3 * i, 5 + 8 * j + i, 48 - 32 * i as i32, true);
|
||||
}
|
||||
}
|
||||
for i in 0..2 {
|
||||
for j in 0..4 {
|
||||
hd(t, 4 * j + i, 2 + 4 * j + i, false, r);
|
||||
}
|
||||
}
|
||||
for i in 0..4 {
|
||||
bf(t, 2 + 4 * i, 3 + 4 * i, 32, true);
|
||||
}
|
||||
adst_out_permute(t, 4);
|
||||
}
|
||||
|
||||
fn inverse_adst<T: Lane>(t: &mut [T], n: u32, r: u32) {
|
||||
match n {
|
||||
2 => T::adst4(t),
|
||||
3 => inverse_adst8(t, r),
|
||||
_ => inverse_adst16(t, r),
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_identity<T: Lane>(t: &mut [T], n: u32) {
|
||||
match n {
|
||||
2 => {
|
||||
for v in t[..4].iter_mut() {
|
||||
*v = v.mul_round12(5793);
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
for v in t[..8].iter_mut() {
|
||||
*v = v.mul(2);
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
for v in t[..16].iter_mut() {
|
||||
*v = v.mul_round12(11586);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for v in t[..32].iter_mut() {
|
||||
*v = v.mul(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inverse_wht(t: &mut [i32], shift: u32) {
|
||||
let mut a = t[0] >> shift;
|
||||
let mut c = t[1] >> shift;
|
||||
let mut d = t[2] >> shift;
|
||||
let mut b = t[3] >> shift;
|
||||
a += c;
|
||||
d -= b;
|
||||
let e = (a - d) >> 1;
|
||||
b = e - b;
|
||||
c = e - c;
|
||||
a -= b;
|
||||
d += c;
|
||||
t[0] = a;
|
||||
t[1] = b;
|
||||
t[2] = c;
|
||||
t[3] = d;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Kind1d {
|
||||
Dct,
|
||||
Adst,
|
||||
Identity,
|
||||
}
|
||||
|
||||
fn row_kind(tx_type: usize) -> Kind1d {
|
||||
match tx_type {
|
||||
DCT_DCT | ADST_DCT | FLIPADST_DCT | H_DCT => Kind1d::Dct,
|
||||
DCT_ADST | ADST_ADST | DCT_FLIPADST | FLIPADST_FLIPADST | ADST_FLIPADST | FLIPADST_ADST | H_ADST | H_FLIPADST => Kind1d::Adst,
|
||||
_ => Kind1d::Identity,
|
||||
}
|
||||
}
|
||||
|
||||
fn col_kind(tx_type: usize) -> Kind1d {
|
||||
match tx_type {
|
||||
DCT_DCT | DCT_ADST | DCT_FLIPADST | V_DCT => Kind1d::Dct,
|
||||
ADST_DCT | ADST_ADST | FLIPADST_DCT | FLIPADST_FLIPADST | ADST_FLIPADST | FLIPADST_ADST | V_ADST | V_FLIPADST => Kind1d::Adst,
|
||||
_ => Kind1d::Identity,
|
||||
}
|
||||
}
|
||||
|
||||
/// 2D inverse transform (7.13.3). `dequant` is 64x64 (row stride 64, only the top-left 32x32
|
||||
/// may be non-zero); the residual is written to `residual` with row stride `w`.
|
||||
pub(crate) fn inverse_transform_2d(dequant: &[i32], residual: &mut [i32], tx_sz: usize, tx_type: usize, lossless: bool, bit_depth: u32) {
|
||||
let log2w = TX_WIDTH_LOG2[tx_sz] as u32;
|
||||
let log2h = TX_HEIGHT_LOG2[tx_sz] as u32;
|
||||
let w = 1usize << log2w;
|
||||
let h = 1usize << log2h;
|
||||
let row_shift = if lossless { 0 } else { TRANSFORM_ROW_SHIFT[tx_sz] as u32 };
|
||||
let col_shift = if lossless { 0 } else { 4 };
|
||||
let row_clamp = bit_depth + 8;
|
||||
let col_clamp = (bit_depth + 6).max(16);
|
||||
let rk = row_kind(tx_type);
|
||||
let ck = col_kind(tx_type);
|
||||
let rect2 = log2w.abs_diff(log2h) == 1;
|
||||
let p = Tx2d { w, h, log2w, log2h, row_shift, col_shift, row_clamp, col_clamp, rk, ck, rect2 };
|
||||
if !lossless && bit_depth <= 10 {
|
||||
if w >= 8 && h >= 8 {
|
||||
transform_lanes::<8>(dequant, residual, &p);
|
||||
} else {
|
||||
transform_lanes::<4>(dequant, residual, &p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
scalar_row_pass(dequant, residual, &p, lossless);
|
||||
scalar_col_pass(residual, &p, lossless);
|
||||
}
|
||||
|
||||
fn scalar_row_pass(dequant: &[i32], residual: &mut [i32], p: &Tx2d, lossless: bool) {
|
||||
let (w, h, log2w, row_shift, row_clamp, col_clamp, rk, rect2) = (p.w, p.h, p.log2w, p.row_shift, p.row_clamp, p.col_clamp, p.rk, p.rect2);
|
||||
let mut t = [0i32; 64];
|
||||
let in_w = w.min(32);
|
||||
for i in 0..h {
|
||||
// Every 1D transform maps an all-zero row to zeros (each step is linear with
|
||||
// Round2( 0 ) = 0), so rows without coefficients (including rows 32..63) are zero.
|
||||
if i >= 32 || dequant[i * 64..i * 64 + in_w].iter().all(|&v| v == 0) {
|
||||
residual[i * w..i * w + w].fill(0);
|
||||
continue;
|
||||
}
|
||||
t[..in_w].copy_from_slice(&dequant[i * 64..i * 64 + in_w]);
|
||||
t[in_w..w].fill(0);
|
||||
if rect2 {
|
||||
for v in t[..w].iter_mut() {
|
||||
*v = round2_64(*v as i64 * 2896, 12);
|
||||
}
|
||||
}
|
||||
if lossless {
|
||||
inverse_wht(&mut t, 2);
|
||||
} else {
|
||||
match rk {
|
||||
Kind1d::Dct => inverse_dct(&mut t, log2w, row_clamp),
|
||||
Kind1d::Adst => inverse_adst(&mut t, log2w, row_clamp),
|
||||
Kind1d::Identity => inverse_identity(&mut t, log2w),
|
||||
}
|
||||
}
|
||||
let lo = -(1i32 << (col_clamp - 1));
|
||||
let hi = (1i32 << (col_clamp - 1)) - 1;
|
||||
for j in 0..w {
|
||||
residual[i * w + j] = round2(t[j], row_shift).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_col_pass(residual: &mut [i32], p: &Tx2d, lossless: bool) {
|
||||
let (w, h, log2h, col_shift, col_clamp, ck) = (p.w, p.h, p.log2h, p.col_shift, p.col_clamp, p.ck);
|
||||
let mut t = [0i32; 64];
|
||||
for j in 0..w {
|
||||
for i in 0..h {
|
||||
t[i] = residual[i * w + j];
|
||||
}
|
||||
if lossless {
|
||||
inverse_wht(&mut t, 0);
|
||||
} else {
|
||||
match ck {
|
||||
Kind1d::Dct => inverse_dct(&mut t, log2h, col_clamp),
|
||||
Kind1d::Adst => inverse_adst(&mut t, log2h, col_clamp),
|
||||
Kind1d::Identity => inverse_identity(&mut t, log2h),
|
||||
}
|
||||
}
|
||||
for i in 0..h {
|
||||
residual[i * w + j] = round2(t[i], col_shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Tx2d {
|
||||
w: usize,
|
||||
h: usize,
|
||||
log2w: u32,
|
||||
log2h: u32,
|
||||
row_shift: u32,
|
||||
col_shift: u32,
|
||||
row_clamp: u32,
|
||||
col_clamp: u32,
|
||||
rk: Kind1d,
|
||||
ck: Kind1d,
|
||||
rect2: bool,
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn transform_1d<T: Lane>(t: &mut [T], kind: Kind1d, n: u32, r: u32) {
|
||||
match kind {
|
||||
Kind1d::Dct => inverse_dct(t, n, r),
|
||||
Kind1d::Adst => inverse_adst(t, n, r),
|
||||
Kind1d::Identity => inverse_identity(t, n),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 2D inverse transform with `L` rows (row pass) or columns (column pass) at a time;
|
||||
/// bit-identical to the one-at-a-time process for bit depths up to 10.
|
||||
fn transform_lanes<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d) {
|
||||
let mut t = [[0i32; L]; 64];
|
||||
lanes_row_pass(dequant, residual, p, &mut t);
|
||||
lanes_col_pass(residual, p, &mut t);
|
||||
}
|
||||
|
||||
fn lanes_row_pass<const L: usize>(dequant: &[i32], residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
|
||||
let (w, h) = (p.w, p.h);
|
||||
let in_w = w.min(32);
|
||||
let lo = -(1i32 << (p.col_clamp - 1));
|
||||
let hi = (1i32 << (p.col_clamp - 1)) - 1;
|
||||
let mut i0 = 0;
|
||||
while i0 < h {
|
||||
// Rows without coefficients (and rows 32..63) transform to zeros.
|
||||
if i0 >= 32 || (i0..i0 + L).all(|i| dequant[i * 64..i * 64 + in_w].iter().all(|&v| v == 0)) {
|
||||
residual[i0 * w..(i0 + L) * w].fill(0);
|
||||
i0 += L;
|
||||
continue;
|
||||
}
|
||||
for (j, tj) in t[..w].iter_mut().enumerate() {
|
||||
for l in 0..L {
|
||||
tj[l] = if j < in_w { dequant[(i0 + l) * 64 + j] } else { 0 };
|
||||
}
|
||||
}
|
||||
if p.rect2 {
|
||||
for v in t[..w].iter_mut() {
|
||||
*v = v.mul_round12(2896);
|
||||
}
|
||||
}
|
||||
transform_1d(&mut t[..], p.rk, p.log2w, p.row_clamp);
|
||||
for l in 0..L {
|
||||
let out = &mut residual[(i0 + l) * w..(i0 + l + 1) * w];
|
||||
for j in 0..w {
|
||||
out[j] = round2(t[j][l], p.row_shift).clamp(lo, hi);
|
||||
}
|
||||
}
|
||||
i0 += L;
|
||||
}
|
||||
}
|
||||
|
||||
fn lanes_col_pass<const L: usize>(residual: &mut [i32], p: &Tx2d, t: &mut [[i32; L]; 64]) {
|
||||
let (w, h) = (p.w, p.h);
|
||||
let mut j0 = 0;
|
||||
while j0 < w {
|
||||
for i in 0..h {
|
||||
t[i].copy_from_slice(&residual[i * w + j0..i * w + j0 + L]);
|
||||
}
|
||||
transform_1d(&mut t[..], p.ck, p.log2h, p.col_clamp);
|
||||
for i in 0..h {
|
||||
let out = &mut residual[i * w + j0..i * w + j0 + L];
|
||||
for l in 0..L {
|
||||
out[l] = round2(t[i][l], p.col_shift);
|
||||
}
|
||||
}
|
||||
j0 += L;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The DCT step list must implement an orthogonal transform: a DC-only input gives a flat
|
||||
/// output equal to DC * cos(pi/4) (in the 2^12 fixed-point scale).
|
||||
#[test]
|
||||
fn dct_dc_is_flat() {
|
||||
for n in 2..=6 {
|
||||
let len = 1 << n;
|
||||
let mut t = [0i32; 64];
|
||||
t[0] = 4096;
|
||||
inverse_dct(&mut t, n, 24);
|
||||
for v in &t[..len] {
|
||||
assert_eq!(*v, 2896, "n={n}: {:?}", &t[..len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare each inverse DCT basis vector with the real-valued DCT-III.
|
||||
#[test]
|
||||
fn dct_matches_float() {
|
||||
for n in 2..=6u32 {
|
||||
let len = 1usize << n;
|
||||
for k in 0..len {
|
||||
let mut t = [0i32; 64];
|
||||
t[k] = 1 << 12;
|
||||
inverse_dct(&mut t, n, 24);
|
||||
for x in 0..len {
|
||||
let c = if k == 0 { std::f64::consts::FRAC_1_SQRT_2 } else { 1.0 };
|
||||
let want = 4096.0 * c * ((2 * x + 1) as f64 * k as f64 * std::f64::consts::PI / (2 * len) as f64).cos();
|
||||
assert!((t[x] as f64 - want).abs() < 4.0 + 0.5 * n as f64, "n={n} k={k} x={x}: {} vs {want}", t[x]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ADST8/16 basis vectors follow sin(pi (2i+1)(2k+1) / 4N) (scaled like the DCT).
|
||||
#[test]
|
||||
fn adst_basis_matches_sine() {
|
||||
for n in 3..=4u32 {
|
||||
let len = 1usize << n;
|
||||
for k in 0..len {
|
||||
let mut t = [0i32; 64];
|
||||
t[k] = 1 << 12;
|
||||
inverse_adst(&mut t, n, 24);
|
||||
for i in 0..len {
|
||||
let want = 4096.0 * ((std::f64::consts::PI * (2 * i + 1) as f64 * (2 * k + 1) as f64) / (4 * len) as f64).sin();
|
||||
assert!((t[i] as f64 - want).abs() < 8.0, "n={n} k={k} i={i}: {} vs {want:.1}", t[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adst_is_close_to_sine_transform() {
|
||||
for n in 3..=4u32 {
|
||||
let len = 1usize << n;
|
||||
for k in 0..len {
|
||||
let mut t = [0i32; 64];
|
||||
t[k] = 1 << 12;
|
||||
inverse_adst(&mut t, n, 24);
|
||||
let energy: f64 = t[..len].iter().map(|&v| (v as f64 / 4096.0).powi(2)).sum();
|
||||
// like the DCT, the AV1 ADST is scaled by sqrt(N / 2)
|
||||
assert!((energy - len as f64 / 2.0).abs() < 0.02, "n={n} k={k} energy {energy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "deckcraft-bitstream"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-media = { workspace = true, features = ["bitstream"] }
|
||||
@@ -1,82 +0,0 @@
|
||||
//! Memory budget for decoded pictures, shared by the video decoders.
|
||||
//!
|
||||
//! A per-axis size cap does not bound memory: a decoder holds the current picture, every
|
||||
//! reference picture and the pictures still being decoded on worker threads. Each decoder checks
|
||||
//! the whole picture store against [`DECODE_MEMORY_BUDGET`] with checked arithmetic before it
|
||||
//! allocates for a new picture size, so a hostile header is an error, not an out-of-memory abort.
|
||||
|
||||
/// Bytes the decoded pictures of one decoder may take: 4 GiB on 64-bit targets, 1 GiB on 32-bit
|
||||
/// ones (wasm32 has a 4 GiB address space in all).
|
||||
pub const DECODE_MEMORY_BUDGET: u64 = if cfg!(target_pointer_width = "64") { 4 << 30 } else { 1 << 30 };
|
||||
|
||||
/// The shape of one decoded picture as a decoder allocates it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PictureShape {
|
||||
/// Allocated luma samples per row and rows (padding included).
|
||||
pub width: u64,
|
||||
pub height: u64,
|
||||
/// Chroma subsampling shifts (0 or 1); ignored when `chroma_planes` is 0.
|
||||
pub shift_x: u32,
|
||||
pub shift_y: u32,
|
||||
/// Chroma planes (0 for monochrome, 2 otherwise).
|
||||
pub chroma_planes: u64,
|
||||
/// Bytes per sample (1 for 8-bit buffers, 2 for 16-bit ones).
|
||||
pub bytes_per_sample: u64,
|
||||
}
|
||||
|
||||
impl PictureShape {
|
||||
/// Bytes of one picture, `None` if it does not fit in a `u64`.
|
||||
pub fn bytes(&self) -> Option<u64> {
|
||||
let luma = self.width.checked_mul(self.height)?;
|
||||
let cw = self.width.div_ceil(1 << self.shift_x.min(1));
|
||||
let ch = self.height.div_ceil(1 << self.shift_y.min(1));
|
||||
let chroma = cw.checked_mul(ch)?.checked_mul(self.chroma_planes)?;
|
||||
luma.checked_add(chroma)?.checked_mul(self.bytes_per_sample)
|
||||
}
|
||||
|
||||
/// Bytes of `pictures` such pictures, `None` on overflow.
|
||||
pub fn store_bytes(&self, pictures: u64) -> Option<u64> {
|
||||
self.bytes()?.checked_mul(pictures)
|
||||
}
|
||||
|
||||
/// Whether `pictures` such pictures fit in `budget` bytes.
|
||||
pub fn fits(&self, pictures: u64, budget: u64) -> bool {
|
||||
self.store_bytes(pictures).is_some_and(|b| b <= budget)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shape(w: u64, h: u64) -> PictureShape {
|
||||
PictureShape { width: w, height: h, shift_x: 1, shift_y: 1, chroma_planes: 2, bytes_per_sample: 2 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_count_luma_and_subsampled_chroma() {
|
||||
assert_eq!(shape(4, 4).bytes(), Some((16 + 2 * 4) * 2));
|
||||
// Odd sizes round chroma up.
|
||||
assert_eq!(shape(3, 3).bytes(), Some((9 + 2 * 4) * 2));
|
||||
let mono = PictureShape { chroma_planes: 0, bytes_per_sample: 1, ..shape(10, 10) };
|
||||
assert_eq!(mono.bytes(), Some(100));
|
||||
let full = PictureShape { shift_x: 0, shift_y: 0, ..shape(10, 10) };
|
||||
assert_eq!(full.bytes(), Some(600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_budget_boundary() {
|
||||
let s = shape(4, 4); // 48 bytes
|
||||
assert!(s.fits(10, 480));
|
||||
assert!(!s.fits(10, 479));
|
||||
assert!(!s.fits(11, 480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_is_never_within_budget() {
|
||||
let huge = shape(u64::MAX / 2, 3);
|
||||
assert_eq!(huge.bytes(), None);
|
||||
assert!(!huge.fits(1, u64::MAX));
|
||||
assert!(!shape(1 << 31, 1 << 31).fits(u64::MAX, u64::MAX));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
//! Bit-level reading and writing for DeckCraft's codec crates: a re-export of
|
||||
//! [`craft_media::bitstream`] from craft-libs, plus [`budget`].
|
||||
//!
|
||||
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
|
||||
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
|
||||
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
|
||||
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
|
||||
//! - [`budget`]: the memory budget video decoders check their picture store against.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub use craft_media::bitstream::*;
|
||||
|
||||
pub mod budget;
|
||||
@@ -466,22 +466,26 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
|
||||
|
||||
/// Run `f` on each target shape (copy-on-write), materialising inherited boxes first.
|
||||
pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let mut ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "no shapes selected"));
|
||||
}
|
||||
// A shape named twice is edited once.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(*id));
|
||||
s.edit(|doc, sel| {
|
||||
let boxes: Vec<(ShapeId, Xfrm)> = {
|
||||
let st_shapes: Vec<deckcraft_model::Shape> =
|
||||
ids.iter().filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)).cloned()).collect();
|
||||
st_shapes.iter().map(|sh| (sh.id, xfrm_of(doc, sel, sh))).collect()
|
||||
};
|
||||
// The boxes are read from the shapes in place: no copy of a shape (and its text) per id.
|
||||
let boxes: std::collections::HashMap<ShapeId, Xfrm> = ids
|
||||
.iter()
|
||||
.filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)))
|
||||
.map(|sh| (sh.id, xfrm_of(doc, sel, sh)))
|
||||
.collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
if sh.xfrm.is_none() {
|
||||
sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x);
|
||||
sh.xfrm = boxes.get(id).copied();
|
||||
}
|
||||
f(sh)?;
|
||||
n += 1;
|
||||
@@ -515,8 +519,18 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet<String> {
|
||||
current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default()
|
||||
/// Every name on the slide, group members included, and the number of shapes (which exceeds the
|
||||
/// number of names when two shapes share one).
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> (std::collections::HashSet<String>, usize) {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
if let Some(v) = current_shapes(doc, sel) {
|
||||
deckcraft_model::walk(v, &mut |sh, _| {
|
||||
names.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
}
|
||||
(names, count)
|
||||
}
|
||||
|
||||
/// The command palette runs a command with `{}`. True when the params doc names a value that
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Animation Styles",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations",
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Animation",
|
||||
["Animations", "Advanced Animation"],
|
||||
None,
|
||||
"{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}",
|
||||
"{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}",
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}",
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}",
|
||||
has_slide,
|
||||
options
|
||||
),
|
||||
@@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
|
||||
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
|
||||
}
|
||||
|
||||
fn build(p: &Value) -> Result<Animation> {
|
||||
fn build(p: &Value, slide: (f64, f64)) -> Result<Animation> {
|
||||
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
|
||||
let class = class_of(str_param(p, "class"));
|
||||
let info = match class {
|
||||
@@ -155,7 +155,9 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
a.start = start_of(st);
|
||||
}
|
||||
if a.class == AnimClass::Path {
|
||||
a.path = Some(default_path(&a.effect, &a.option));
|
||||
a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option)));
|
||||
} else if p.get("path").is_some() {
|
||||
return Err(bad("animation", "`path` only fits a motion path (class `path`)"));
|
||||
}
|
||||
if a.effect == "spin" {
|
||||
a.amount = Some(360.0);
|
||||
@@ -166,6 +168,97 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Most points of a motion path given as an array, and longest path string given as text.
|
||||
const MAX_PATH_POINTS: usize = 1_000;
|
||||
const MAX_PATH_TEXT: usize = 16 * 1024;
|
||||
/// Largest offset of a point, in slide points; anything beyond is not a place on any slide.
|
||||
const MAX_PATH_OFFSET: f64 = 1.0e6;
|
||||
|
||||
/// A number of a path string: slide fractions, rounded to 6 places.
|
||||
fn path_num(v: f64) -> String {
|
||||
let t = format!("{:.6}", v);
|
||||
let t = t.trim_end_matches('0').trim_end_matches('.');
|
||||
if t == "-0" || t.is_empty() { "0".into() } else { t.into() }
|
||||
}
|
||||
|
||||
/// Does a raw motion path string draw: only commands and numbers, every number finite, and at
|
||||
/// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.)
|
||||
fn raw_path_draws(text: &str) -> bool {
|
||||
let cs: Vec<char> = text.chars().collect();
|
||||
let (mut i, mut nums) = (0, 0usize);
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') {
|
||||
i += 1;
|
||||
}
|
||||
if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') {
|
||||
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
|
||||
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
|
||||
if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default();
|
||||
match tok.parse::<f64>() {
|
||||
Ok(v) if v.is_finite() => nums += 1,
|
||||
_ => return false,
|
||||
}
|
||||
} else if " ,MmLlCcQqHhVvZzE".contains(c) {
|
||||
i += 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
nums >= 4
|
||||
}
|
||||
|
||||
/// The motion path `path` asks for, as the OOXML path string animations store: either points
|
||||
/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?`
|
||||
/// to return to the first) or that string itself.
|
||||
fn path_param(p: &Value, slide: (f64, f64)) -> Result<Option<String>> {
|
||||
let Some(v) = p.get("path") else { return Ok(None) };
|
||||
let refuse = |why: &str| bad("animation", format!("`path` {why}"));
|
||||
match v {
|
||||
Value::String(text) => {
|
||||
let ok = text.len() <= MAX_PATH_TEXT
|
||||
&& text.contains(|c: char| c.is_ascii_digit())
|
||||
&& text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c))
|
||||
&& raw_path_draws(text);
|
||||
if !ok {
|
||||
return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)"));
|
||||
}
|
||||
Ok(Some(text.clone()))
|
||||
}
|
||||
Value::Array(points) => {
|
||||
if !(2..=MAX_PATH_POINTS).contains(&points.len()) {
|
||||
return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points")));
|
||||
}
|
||||
if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) {
|
||||
return Err(refuse("can't be placed: the slide has no size"));
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (k, pt) in points.iter().enumerate() {
|
||||
let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else {
|
||||
return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points")));
|
||||
};
|
||||
out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1)));
|
||||
}
|
||||
if p.get("closed").and_then(Value::as_bool) == Some(true) {
|
||||
out.push_str("Z ");
|
||||
}
|
||||
out.push('E');
|
||||
Ok(Some(out))
|
||||
}
|
||||
_ => Err(refuse("must be [[x, y], …] or an OOXML path string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default motion paths, in slide-fraction units relative to the shape's position.
|
||||
pub fn default_path(effect: &str, option: &str) -> String {
|
||||
let (dx, dy) = match option {
|
||||
@@ -185,7 +278,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
s.edit(|doc, sel| {
|
||||
let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?;
|
||||
let sl = Arc::make_mut(sl);
|
||||
@@ -215,9 +308,16 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The slide's width and height in points.
|
||||
fn slide_extent(s: &mut Session) -> Result<(f64, f64)> {
|
||||
let size = &s.doc()?.doc.slide_size;
|
||||
Ok((size.width, size.height))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
let path = a.path.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?);
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
@@ -227,7 +327,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sl.animations.push(x);
|
||||
}
|
||||
Ok(json!({"count": sl.animations.len()}))
|
||||
Ok(match &path {
|
||||
Some(path) => json!({"count": sl.animations.len(), "path": path}),
|
||||
None => json!({"count": sl.animations.len()}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,13 +423,17 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"byLetter" => TextBuild::ByLetter,
|
||||
_ => TextBuild::AsOne,
|
||||
});
|
||||
let path = path_param(p, slide_extent(s)?)?;
|
||||
each_target(s, p, |a| {
|
||||
if let (Some(path), AnimClass::Path) = (&path, a.class) {
|
||||
a.path = Some(path.clone());
|
||||
}
|
||||
if let Some(o) = &option {
|
||||
// Each target takes the option in its own effect's terms; one it has no such
|
||||
// option for keeps its own.
|
||||
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
|
||||
if let Some(o) = resolve_option(allowed, o) {
|
||||
if a.class == AnimClass::Path {
|
||||
if a.class == AnimClass::Path && path.is_none() {
|
||||
a.path = Some(default_path(&a.effect, &o));
|
||||
}
|
||||
a.option = o;
|
||||
@@ -339,7 +446,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(s.doc()?.selection.slide);
|
||||
let i = match opt_usize_param(p, "index", "animation.clear")? {
|
||||
Some(i) => i,
|
||||
None => s.doc()?.selection.slide,
|
||||
};
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).animations.clear();
|
||||
@@ -434,6 +544,19 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id = r.get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
|
||||
for bad_index in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("animation.clear", &json!({"index": bad_index})).is_err());
|
||||
let left = s.execute("animation.get", &json!({})).unwrap();
|
||||
assert_eq!(left.as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_painter_copies_effects_between_shapes() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -590,4 +713,76 @@ mod tests {
|
||||
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(s: &mut Session) -> Option<String> {
|
||||
let all = s.execute("animation.get", &json!({})).unwrap();
|
||||
all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from)
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// #1576: points in slide points, as offsets from the shape, become the slide-fraction path.
|
||||
#[test]
|
||||
fn a_custom_path_takes_its_points() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let w = s.doc().unwrap().doc.slide_size.width;
|
||||
let h = s.doc().unwrap().doc.slide_size.height;
|
||||
let r = s
|
||||
.execute(
|
||||
"animation.add",
|
||||
&json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E");
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E"));
|
||||
// closed shapes end back at the start; set replaces; no path keeps the default
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]}))
|
||||
.unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E"));
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_path_string_is_kept_and_options_edits_it() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E"));
|
||||
s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_path_is_refused_and_changes_nothing() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let many: Vec<Value> = (0..2_000).map(|i| json!([i, i])).collect();
|
||||
for bad_path in [
|
||||
json!([[0, 0]]),
|
||||
json!([]),
|
||||
json!([[0, 0], [1]]),
|
||||
json!([[0, 0], ["a", 1]]),
|
||||
json!([[0, 0], [1e300, 1]]),
|
||||
json!([[0, 0], [null, 1]]),
|
||||
json!("M 0 0 L <script> E"),
|
||||
json!("not a path"),
|
||||
json!("eeee1"),
|
||||
json!("M 0 0 L 1e999 0 E"),
|
||||
json!(5),
|
||||
Value::Array(many),
|
||||
Value::String("M 0 0 L 0.1 0.1 ".repeat(10_000)),
|
||||
] {
|
||||
let r = s.execute("animation.add", &json!({"effect": "customPath", "path": bad_path, "ids": [id]}));
|
||||
assert!(r.is_err(), "{bad_path:.60}");
|
||||
}
|
||||
assert!(
|
||||
s.execute("animation.add", &json!({"effect": "fade", "path": [[0, 0], [1, 1]], "ids": [id]})).is_err(),
|
||||
"path only fits a motion path"
|
||||
);
|
||||
assert!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().is_empty(), "a refused add left an animation behind");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,98 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// After an edit: refit every group whose children's geometry changed to the union of its
|
||||
/// children (PowerPoint keeps a group's box on its children, so moving one child grows or
|
||||
/// shrinks the group). The children keep their place on the slide: the group's box and its child
|
||||
/// space change together. `old` is the presentation before the edit; only slides the edit
|
||||
/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to
|
||||
/// that edit.
|
||||
pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) {
|
||||
// Each slide, master and layout is compared with its own earlier state, found by id: a move,
|
||||
// insertion or deletion shifts the indices, and shape ids repeat across slides.
|
||||
let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect();
|
||||
for slide in new.slides.iter_mut() {
|
||||
let Some(before) = old_slides.get(&slide.id) else { continue };
|
||||
if std::sync::Arc::ptr_eq(before, slide) {
|
||||
continue;
|
||||
}
|
||||
let shapes = &mut std::sync::Arc::make_mut(slide).shapes;
|
||||
refit_list(Some(&before.shapes), shapes, 0);
|
||||
}
|
||||
// A child edited in Slide Master, layout, notes or handout view refits its group there too.
|
||||
for master in new.masters.iter_mut() {
|
||||
if let Some(before) = old.masters.iter().find(|m| m.id == master.id)
|
||||
&& !std::sync::Arc::ptr_eq(before, master)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(master));
|
||||
}
|
||||
}
|
||||
for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] {
|
||||
if let (Some(before), Some(after)) = (before, after)
|
||||
&& !std::sync::Arc::ptr_eq(before, after)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(after));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) {
|
||||
refit_list(Some(&old.shapes), &mut new.shapes, 0);
|
||||
for layout in new.layouts.iter_mut() {
|
||||
refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_list(old: Option<&Vec<Shape>>, list: &mut [Shape], depth: usize) {
|
||||
// Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting
|
||||
// is bounded like the model's other walks.
|
||||
if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) {
|
||||
return;
|
||||
}
|
||||
let by_id: std::collections::HashMap<ShapeId, &Shape> = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default();
|
||||
for sh in list.iter_mut() {
|
||||
let before = by_id.get(&sh.id).copied();
|
||||
let old_children = before.and_then(|b| match &b.kind {
|
||||
ShapeKind::Group { children, .. } => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
let own_before = before.map(|b| {
|
||||
(
|
||||
b.xfrm,
|
||||
match &b.kind {
|
||||
ShapeKind::Group { child, .. } => Some(*child),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
});
|
||||
let smart = sh.diagram.is_some();
|
||||
let ShapeKind::Group { children, child } = &mut sh.kind else { continue };
|
||||
refit_list(old_children, children, depth + 1);
|
||||
if smart {
|
||||
// A SmartArt graphic's box is its own; its shapes are regenerated inside it.
|
||||
continue;
|
||||
}
|
||||
let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue };
|
||||
if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm);
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
let Some(group) = sh.xfrm else { continue };
|
||||
let xs: Vec<Xfrm> = children.iter().filter_map(|c| c.xfrm).collect();
|
||||
let Some(u) = union_bounds(&xs) else { continue };
|
||||
if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let union = Xfrm::new(u.x0, u.y0, u.width(), u.height());
|
||||
let placed = child_to_parent(&group, child, &union);
|
||||
sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v });
|
||||
*child = union;
|
||||
}
|
||||
}
|
||||
|
||||
/// A child's xfrm in slide space, given its group.
|
||||
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
|
||||
|
||||
@@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop
|
||||
Some(Packed { bytes, content_type, crop: new_crop })
|
||||
}
|
||||
|
||||
/// Picture shapes in `shapes` (groups included) that `want` accepts.
|
||||
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
|
||||
/// Pictures accepted by `want`, with width and height measured in slide space through group transforms.
|
||||
fn pictures_mut<'a>(
|
||||
shapes: &'a mut [Shape],
|
||||
want: &dyn Fn(ShapeId) -> bool,
|
||||
out: &mut Vec<(&'a mut Shape, f64, f64)>,
|
||||
to_slide: deckcraft_geom::Affine,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
out.push(sh);
|
||||
} else if let Some(ch) = sh.children_mut() {
|
||||
pictures_mut(ch, want, out, depth + 1);
|
||||
let Some(xfrm) = sh.xfrm else { continue };
|
||||
let local_to_slide = to_slide * xfrm.affine();
|
||||
if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
let origin = local_to_slide * deckcraft_geom::Point::ZERO;
|
||||
let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0);
|
||||
let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h);
|
||||
out.push((sh, (across - origin).hypot(), (down - origin).hypot()));
|
||||
continue;
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &mut sh.kind {
|
||||
let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect);
|
||||
pictures_mut(children, want, out, child_to_slide, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(slide) = doc.slides.get(i).cloned() else { continue };
|
||||
let mut slide = slide.as_ref().clone();
|
||||
let mut pics = vec![];
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0);
|
||||
let mut touched = false;
|
||||
for sh in pics {
|
||||
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
|
||||
for (sh, w, h) in pics {
|
||||
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
|
||||
let PictureFill { media, crop, .. } = fill.clone();
|
||||
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
|
||||
@@ -232,6 +246,114 @@ mod tests {
|
||||
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_pictures_use_their_displayed_size() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(600, 600);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?;
|
||||
let picture = Shape {
|
||||
id: ShapeId(70_101),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
let nested = Shape {
|
||||
id: ShapeId(70_102),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(70_103),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)),
|
||||
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
|
||||
assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density");
|
||||
assert!(
|
||||
s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()),
|
||||
"the under-target original media must remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anisotropic_nested_group_scaling_controls_sampling_dimensions() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(2000, 1000);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
|
||||
let picture = Shape {
|
||||
id: ShapeId(70_111),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
let nested = Shape {
|
||||
id: ShapeId(70_112),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(70_113),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)),
|
||||
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
|
||||
assert_eq!(result["pictures"], 1);
|
||||
let slide = s.doc().unwrap().doc.slides.first().unwrap();
|
||||
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap();
|
||||
let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") };
|
||||
let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") };
|
||||
let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") };
|
||||
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
|
||||
let image = image::load_from_memory(&media.data).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (960, 480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_picture_still_compresses_and_deletes_its_crop() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(800, 800);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(Shape {
|
||||
id: ShapeId(70_104),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap();
|
||||
assert_eq!(result["pictures"], 1);
|
||||
let slide = s.doc().unwrap().doc.slides.first().unwrap();
|
||||
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap();
|
||||
let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") };
|
||||
assert_eq!(fill.crop, [0.0; 4]);
|
||||
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
|
||||
let image = image::load_from_memory(&media.data).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (96, 192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_deletes_cropped_areas_or_keeps_them() {
|
||||
let src = png(400, 400);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use super::{
|
||||
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
|
||||
// that layout. With neither key, follow the current slide's layout.
|
||||
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
|
||||
None
|
||||
} else {
|
||||
usize_param(p, "layout")
|
||||
opt_usize_param(p, "layout", "view.slideMaster")?
|
||||
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
|
||||
};
|
||||
let target = match layout {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use super::{
|
||||
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
||||
f64_param, ok, usize_param, with_param,
|
||||
f64_param, ok, opt_usize_param, usize_param, with_param,
|
||||
};
|
||||
|
||||
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "copy", false) {
|
||||
return copy_slide_background(s);
|
||||
}
|
||||
// Check the index before anything is edited: a picture is added to the media first.
|
||||
let index = opt_usize_param(p, "index", "design.background")?;
|
||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let reset = bool_or(p, "reset", false);
|
||||
@@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
};
|
||||
let all = bool_or(p, "all", false);
|
||||
let index = usize_param(p, "index");
|
||||
if matches!(op, BgOp::Adjust) {
|
||||
let next = {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let before = s.doc().unwrap().doc.slides[0].background.clone();
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
let shapes: Vec<Shape> = st
|
||||
let mut shapes: Vec<Shape> = st
|
||||
.selection
|
||||
.shapes
|
||||
.iter()
|
||||
@@ -120,14 +120,33 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
|
||||
// paste can be in another deck. Slide Master view pastes the originals instead.
|
||||
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
|
||||
detach_on_slide(&st.doc, &st.selection, &mut shapes);
|
||||
let mids = crate::media_refs::shapes_media(&shapes);
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
|
||||
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
|
||||
// The theme of what is being edited: a slide's or layout's colour map, else the master's.
|
||||
let source_theme = super::text::ctx_for(&st.doc, &st.selection).map(|ctx| crate::SourceTheme {
|
||||
colors: ctx.master.theme.colors.clone(),
|
||||
color_map: ctx.color_map.clone(),
|
||||
fonts: ctx.master.theme.fonts.clone(),
|
||||
});
|
||||
s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() };
|
||||
Ok(plain)
|
||||
}
|
||||
|
||||
/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit
|
||||
/// there. In Slide Master view a copy stays a placeholder, so this changes nothing.
|
||||
pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) {
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(ctx) = super::text::ctx_for(doc, sel)
|
||||
{
|
||||
shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh));
|
||||
}
|
||||
}
|
||||
|
||||
fn copying_caret(s: &Session) -> bool {
|
||||
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
|
||||
}
|
||||
@@ -319,9 +338,16 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return s.edit(|doc, sel| {
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
let mut shapes = clip.shapes.clone();
|
||||
if sel.target != crate::Target::Slides {
|
||||
for sh in &mut shapes {
|
||||
if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) {
|
||||
sh.clone_from(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
let mut taken = super::current_names(doc, sel);
|
||||
let (mut taken, mut count) = super::current_names(doc, sel);
|
||||
for sh in &mut shapes {
|
||||
// Only a name already on the slide is changed (two shapes with one name are what the
|
||||
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
|
||||
@@ -330,14 +356,18 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& !n.is_empty()
|
||||
&& n.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
sh.name = super::insert::first_free_name(base, &taken);
|
||||
sh.name = super::insert::first_free_name(base, &taken, count);
|
||||
}
|
||||
taken.insert(sh.name.clone());
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |m, _| {
|
||||
taken.insert(m.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
}
|
||||
// A pasted placeholder becomes a regular shape keeping its look.
|
||||
// A placeholder pasted on a slide becomes a regular shape. One copied from a slide
|
||||
// already has its look; see `copy_inner`.
|
||||
if sh.ph.is_some() && sel.target == crate::Target::Slides {
|
||||
sh.ph = None;
|
||||
}
|
||||
@@ -401,8 +431,9 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"text" | "keepTextOnly" => paste_text(s, &json!({})),
|
||||
"keepSource" | "keepSourceFormatting" => {
|
||||
let saved = s.clipboard.clone();
|
||||
if let Some((scheme, fonts)) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
|
||||
if let Some(theme) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, theme);
|
||||
bake_theme(&mut s.clipboard.placeholders, theme);
|
||||
}
|
||||
let r = paste(s, &json!({}));
|
||||
let pastes = s.clipboard.pastes;
|
||||
@@ -417,8 +448,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
|
||||
/// shapes keep their look under another theme.
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
|
||||
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
|
||||
fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) {
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
@@ -427,7 +458,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
|
||||
if o.get("kind").and_then(Value::as_str) == Some("scheme")
|
||||
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
|
||||
&& slot != deckcraft_color::SchemeSlot::PhClr
|
||||
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
|
||||
&& let Ok(rgb) = serde_json::to_value(theme.colors.get(deckcraft_model::map_slot(&theme.color_map, slot)))
|
||||
{
|
||||
o.clear();
|
||||
o.insert("kind".into(), json!("rgb"));
|
||||
@@ -435,14 +466,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
|
||||
return;
|
||||
}
|
||||
for x in o.values_mut() {
|
||||
rec(x, scheme, fonts, depth + 1);
|
||||
rec(x, theme, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, theme, depth + 1)),
|
||||
Value::String(t) if t.starts_with('+') => {
|
||||
let set = match t.get(1..3) {
|
||||
Some("mj") => &fonts.major,
|
||||
Some("mn") => &fonts.minor,
|
||||
Some("mj") => &theme.fonts.major,
|
||||
Some("mn") => &theme.fonts.minor,
|
||||
_ => return,
|
||||
};
|
||||
let face = match t.get(3..) {
|
||||
@@ -460,7 +491,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
|
||||
}
|
||||
for sh in shapes.iter_mut() {
|
||||
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
|
||||
rec(&mut v, scheme, fonts, 0);
|
||||
rec(&mut v, theme, 0);
|
||||
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
|
||||
*sh = baked;
|
||||
}
|
||||
@@ -473,8 +504,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
if clip.shapes.is_empty() {
|
||||
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
|
||||
}
|
||||
if let Some((scheme, fonts)) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, scheme, fonts);
|
||||
if let Some(theme) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, theme);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut doc = (*st.doc).clone();
|
||||
@@ -508,6 +539,19 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
paste_plain(s, &t)
|
||||
}
|
||||
|
||||
/// Duplicate gives a copy the next automatic name ("Rectangle 4" after "Rectangle 1"), as
|
||||
/// PowerPoint does, and keeps a name the user chose. PowerPoint tells the two apart by comparing
|
||||
/// the number with the shape's id; ids here are numbered across the deck, so any "{kind} {n}"
|
||||
/// counts as automatic.
|
||||
fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet<String>, count: usize) {
|
||||
let base = if sh.is_group() { "Group".to_string() } else { super::insert::auto_name_base(sh) };
|
||||
let n = sh.name.strip_prefix(&base).and_then(|r| r.strip_prefix(' '));
|
||||
if n.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) {
|
||||
sh.name = super::insert::first_free_name(&base, taken, count);
|
||||
taken.insert(sh.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let off = s.prefs.duplicate_offset;
|
||||
@@ -517,6 +561,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
|
||||
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
|
||||
detach_on_slide(doc, sel, &mut copies);
|
||||
// Duplicating the copies of the last Duplicate again repeats how far they were moved
|
||||
// since (#966); anything else gets the usual offset.
|
||||
let (dx, dy) = last
|
||||
@@ -532,6 +577,17 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
// Every name on the slide, group members included, so a copy never repeats one.
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
deckcraft_model::walk(&list, &mut |sh, _| {
|
||||
taken.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
walk_mut(&mut copies, &mut |c| {
|
||||
rename_automatic(c, &mut taken, count);
|
||||
count += 1;
|
||||
});
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += dx;
|
||||
@@ -540,9 +596,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sel.target == crate::Target::Slides {
|
||||
c.ph = None;
|
||||
}
|
||||
if !c.name.is_empty() {
|
||||
c.name = format!("{} copy", c.name.trim_end_matches(" copy"));
|
||||
}
|
||||
}
|
||||
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, opt_usize_param, str_param, usize_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -253,8 +253,15 @@ pub struct LoadedFile {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let (doc, damaged) = open_checked(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
loaded_file(path, &bytes)
|
||||
}
|
||||
|
||||
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
|
||||
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
|
||||
/// that name like a file opened on the desktop.
|
||||
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
|
||||
let (doc, damaged) = open_checked(path, bytes)?;
|
||||
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
|
||||
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
|
||||
}
|
||||
|
||||
@@ -345,6 +352,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
"png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -359,7 +367,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
|
||||
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
|
||||
o.layout = match str_param(p, "layout").unwrap_or("slides") {
|
||||
"notes" => deckcraft_pdf::PageLayout::Notes,
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
|
||||
"handouts" => {
|
||||
let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6);
|
||||
if ![1, 2, 3, 4, 6, 9].contains(&per_page) {
|
||||
return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9")));
|
||||
}
|
||||
deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 }
|
||||
}
|
||||
_ => deckcraft_pdf::PageLayout::Slides,
|
||||
};
|
||||
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
|
||||
@@ -390,18 +404,23 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
|
||||
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
|
||||
let mut out: Vec<usize> = vec![];
|
||||
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
|
||||
if !digits(a) || !digits(b) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
|
||||
}
|
||||
for i in lo - 1..hi {
|
||||
if !out.contains(&i) {
|
||||
if seen.insert(i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
@@ -458,7 +477,10 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given)
|
||||
/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the
|
||||
/// page has the bytes.
|
||||
pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.saved_doc = st.doc.clone();
|
||||
if path.is_some() {
|
||||
@@ -536,7 +558,7 @@ fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec
|
||||
|
||||
fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "file.render")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("file.render", format!("no slide {i}")));
|
||||
}
|
||||
@@ -547,7 +569,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
|
||||
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "slide.colorAt")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide.colorAt", format!("no slide {i}")));
|
||||
}
|
||||
@@ -578,7 +600,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
match format.as_str() {
|
||||
"png" | "jpeg" | "jpg" => {
|
||||
let slides: Vec<usize> = match usize_param(p, "slide") {
|
||||
let slides: Vec<usize> = match opt_usize_param(p, "slide", "file.export")? {
|
||||
Some(i) => vec![i],
|
||||
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
|
||||
None => vec![st.selection.slide],
|
||||
@@ -763,7 +785,24 @@ mod color_at_tests;
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::write_file;
|
||||
use super::{parse_slide_range, write_file};
|
||||
|
||||
#[test]
|
||||
fn broad_and_repeated_slide_ranges_expand_once() {
|
||||
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
|
||||
let text = vec!["1-100000"; 200].join(",");
|
||||
let v = parse_slide_range(&text, 100_000).unwrap();
|
||||
assert_eq!(v.len(), 100_000);
|
||||
assert!(v.windows(2).all(|w| w[0] < w[1]));
|
||||
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_range_rejects_plus_sign() {
|
||||
assert!(parse_slide_range("+2", 3).is_err());
|
||||
assert!(parse_slide_range("+1-+3", 3).is_err());
|
||||
assert_eq!(parse_slide_range("1-3", 3).unwrap(), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
use super::text::{format_body, format_para, format_run, format_run_levels};
|
||||
use super::{
|
||||
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text,
|
||||
has_text_or_shapes, ids_param, ok, str_param,
|
||||
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Numbering",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}",
|
||||
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n (a numbered paragraph whose start differs from that of the last numbered paragraph at its level begins a new run of numbers at n, as PowerPoint's Start at does), color?, size?: % of text}",
|
||||
has_text_or_shapes,
|
||||
numbering
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -55,12 +55,18 @@ pub(super) fn size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format_run(s, &move |r| r.size = Some(v))
|
||||
}
|
||||
|
||||
/// PowerPoint's font size list (grow/shrink steps through it).
|
||||
pub const SIZES: [f64; 20] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0];
|
||||
/// PowerPoint's font size list, as the Font Size box lists it (grow/shrink steps through it).
|
||||
pub const SIZES: [f64; 24] =
|
||||
[8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 32.0, 36.0, 40.0, 44.0, 48.0, 54.0, 60.0, 66.0, 72.0, 80.0, 88.0, 96.0];
|
||||
|
||||
/// Grow steps up the list, then 8 points at a time past its top; shrink steps back down the same
|
||||
/// way. A size past the top used to shrink straight to 66 (#2059).
|
||||
fn step(cur: f64, up: bool) -> f64 {
|
||||
let top = SIZES[SIZES.len() - 1];
|
||||
if up {
|
||||
SIZES.iter().copied().find(|v| *v > cur + 0.01).unwrap_or((cur + 8.0).min(4000.0))
|
||||
} else if cur - 0.01 > top {
|
||||
(cur - 8.0).max(top)
|
||||
} else {
|
||||
SIZES.iter().rev().copied().find(|v| *v < cur - 0.01).unwrap_or((cur - 1.0).max(1.0))
|
||||
}
|
||||
@@ -72,10 +78,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
|
||||
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
|
||||
let _ = st;
|
||||
let base = ctx_sizes;
|
||||
format_run(s, &move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
})
|
||||
// A layout/master level steps from its own effective size, not the first run's.
|
||||
format_run_levels(
|
||||
s,
|
||||
&move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
&move |r, eff| {
|
||||
let cur = r.size.or(eff.size).unwrap_or(18.0);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -100,6 +114,7 @@ pub(super) fn color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
|
||||
if let ShapeKind::Chart(ch) = &mut sh.kind {
|
||||
ch.text_color = Some(tc.clone());
|
||||
ch.legend_entries.iter_mut().for_each(|e| e.color = None);
|
||||
ch.raw = None;
|
||||
}
|
||||
Ok(())
|
||||
@@ -227,6 +242,9 @@ pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
if c.is_none() && p.get("color").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad("format.highlight", "`color` is not a valid colour"));
|
||||
}
|
||||
format_run(s, &move |r| r.highlight = c.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justifyLow" | "justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => Align::Left,
|
||||
};
|
||||
align_to(s, a)
|
||||
@@ -45,12 +46,18 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format_para(s, &move |para| {
|
||||
if on {
|
||||
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
|
||||
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
|
||||
if explicit_char {
|
||||
para.props.bullet_font = Some("Arial".into());
|
||||
para.props.bullet_font_follow_text = None;
|
||||
}
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_points = None;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
|
||||
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
|
||||
@@ -76,9 +83,12 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_points = None;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-27.0);
|
||||
@@ -110,13 +120,7 @@ pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = str_param(p, "align").map(|a| match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
"justify" => Align::Justify,
|
||||
"distributed" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
});
|
||||
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
|
||||
let ml = f64_param(p, "indentLeft");
|
||||
let ind = f64_param(p, "indentFirst");
|
||||
let sb = f64_param(p, "spaceBefore");
|
||||
@@ -172,12 +176,13 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
|
||||
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
Ok(match s {
|
||||
"left" | "l" => Align::Left,
|
||||
"center" | "ctr" => Align::Center,
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"justLow" | "justifyLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -189,6 +194,7 @@ fn align_name(a: Align) -> &'static str {
|
||||
Align::Justify => "justify",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "distributed",
|
||||
Align::ThaiDistributed => "thaiDist",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
|
||||
use super::{CommandSpec, bad, cmd, has_slide, opt_usize_param, str_param, xfrm_of};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -245,13 +245,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
|
||||
|
||||
fn ideas_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "slide", "design.ideas")?.unwrap_or(st.selection.slide);
|
||||
Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string();
|
||||
let slide = usize_param(p, "slide");
|
||||
let slide = opt_usize_param(p, "slide", "design.applyIdea")?;
|
||||
s.edit(|doc, sel| {
|
||||
let i = slide.unwrap_or(sel.slide);
|
||||
apply_idea(doc, sel, i, &id)?;
|
||||
@@ -372,6 +372,19 @@ mod tests {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_slide_index_is_refused_not_replaced_by_the_selection() {
|
||||
let mut s = deck(false);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for bad_slide in [json!(-1), json!(1.5), json!("0")] {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
assert!(s.execute("design.ideas", &json!({"slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc) || *before == *s.doc().unwrap().doc);
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": 0})).is_ok());
|
||||
assert!(s.execute("design.ideas", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_full_bleed_removes_the_title_band() {
|
||||
let mut s = deck(true);
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Date & Time",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog",
|
||||
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog; in the Handout or Notes Master view it fills the selected date placeholder (else the master's own) and answers where: \"master\"",
|
||||
has_slide,
|
||||
date_time
|
||||
),
|
||||
@@ -187,9 +187,10 @@ pub(crate) fn auto_name_base(shape: &Shape) -> String {
|
||||
}
|
||||
|
||||
/// "{base} n" with the first `n` from the shape count + 1 that no shape on the slide already uses,
|
||||
/// so a gap left by a deleted shape never makes a second shape with the same name.
|
||||
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>) -> String {
|
||||
(taken.len() + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
|
||||
/// so a gap left by a deleted shape never makes a second shape with the same name. `count` is the
|
||||
/// number of shapes, which exceeds the number of names when two shapes share one.
|
||||
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>, count: usize) -> String {
|
||||
(count + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result<ShapeId> {
|
||||
@@ -197,8 +198,8 @@ pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bo
|
||||
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
if shape.name.is_empty() {
|
||||
let base = auto_name_base(&shape);
|
||||
let taken = super::current_names(doc, sel);
|
||||
shape.name = first_free_name(&base, &taken);
|
||||
let (taken, count) = super::current_names(doc, sel);
|
||||
shape.name = first_free_name(&base, &taken, count);
|
||||
}
|
||||
let id = shape.id;
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
|
||||
@@ -449,6 +450,17 @@ mod tests {
|
||||
assert_eq!(undo(&s), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_british_date_puts_the_day_first() {
|
||||
let mut s = session();
|
||||
edit_title(&mut s);
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-GB"})).unwrap();
|
||||
let text = v["text"].as_str().unwrap().to_string();
|
||||
assert!(text.chars().next().is_some_and(|c| c.is_ascii_digit()) && !text.contains(','), "{text}");
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-US"})).unwrap();
|
||||
assert!(v["text"].as_str().is_some_and(|t| t.contains(',')), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_format_in_french_and_a_bad_value_change_nothing() {
|
||||
let mut s = session();
|
||||
@@ -473,6 +485,70 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ZZ1: in the handout and notes master views the date goes into the master's date placeholder
|
||||
/// (not the current slide), so the exported handout and notes pages show it.
|
||||
#[test]
|
||||
fn on_a_page_master_the_date_fills_its_date_placeholder() {
|
||||
for view in ["view.handoutMaster", "view.notesMaster"] {
|
||||
let mut s = session();
|
||||
s.execute(view, &json!({})).unwrap();
|
||||
let (id, master) = {
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap().clone();
|
||||
(m.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).unwrap().id.0, m)
|
||||
};
|
||||
assert!(master.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()));
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap();
|
||||
assert_eq!(v["where"], "master", "{view}: {v}");
|
||||
assert!(date_run(&s).is_none(), "the slide keeps no date");
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
let run = m
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|x| x.id.0 == id)
|
||||
.and_then(|x| x.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{view}: {run:?}");
|
||||
let placed = deckcraft_render::place_master_texts(&st.doc, m, 1);
|
||||
assert!(placed.iter().any(|p| p.body.text() == run.text && run.text.contains(':')), "{view}: the page shows the date");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
assert!(m.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()), "one undo");
|
||||
// A fixed date in a placeholder whose run is en-GB reads day first, and the answer
|
||||
// reports the text that was written, not the en-US sample.
|
||||
s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap();
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
let run = m
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|x| x.id.0 == id)
|
||||
.and_then(|x| x.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert_eq!(run.props.lang.as_deref(), Some("en-GB"), "{view}: {run:?}");
|
||||
assert_eq!(v["text"].as_str(), Some(run.text.as_str()), "{view}: the answer reports the written text: {v}");
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().unwrap();
|
||||
let gb = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now);
|
||||
assert_eq!(Some(run.text.as_str()), gb.as_deref(), "{view}: the date in en-GB order (dd/mm/yyyy)");
|
||||
// No date placeholder: refused, nothing changed.
|
||||
let key = if view == "view.handoutMaster" { "handout.placeholders" } else { "notes.placeholders" };
|
||||
s.execute(key, &json!({"date": false})).unwrap();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
assert!(s.execute("insert.dateTime", &json!({"format": "datetime1"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_caret_the_date_lands_on_this_slide_only() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -80,6 +80,12 @@ pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
let (id, ids) = album_target(s, p)?;
|
||||
// The editor is seeded within the album limits; replacing the album from a trimmed seed would
|
||||
// delete what it left out, so refuse rather than lose it.
|
||||
let st = s.doc()?;
|
||||
if items_from_slides(&st.doc, &album_slides(st, &ids)).1 {
|
||||
return Err(bad("insert.photoAlbumEdit", "this album holds more than the editor can carry; editing it would delete the rest"));
|
||||
}
|
||||
commit_album(s, items, opts, Some((id, ids)))
|
||||
}
|
||||
|
||||
@@ -545,6 +551,19 @@ fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
|
||||
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
|
||||
}
|
||||
|
||||
/// Whether a slide holds anything besides album items: shapes, notes, comments, a background, a
|
||||
/// transition, animations, a colour map or tags.
|
||||
fn has_slide_content(s: &deckcraft_model::Slide) -> bool {
|
||||
!s.shapes.is_empty()
|
||||
|| !s.notes.text().trim().is_empty()
|
||||
|| !s.comments.is_empty()
|
||||
|| s.background.is_some()
|
||||
|| s.transition.is_some()
|
||||
|| !s.animations.is_empty()
|
||||
|| s.color_map.is_some()
|
||||
|| !s.tags.is_empty()
|
||||
}
|
||||
|
||||
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
|
||||
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
@@ -592,9 +611,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
|
||||
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
||||
}
|
||||
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
||||
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
|
||||
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
|
||||
/// name one large picture many times.
|
||||
///
|
||||
/// The flag is true when something was left out for a limit.
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> (Vec<Value>, bool) {
|
||||
let mut items = Vec::new();
|
||||
for slide in slides {
|
||||
let mut total = 0usize;
|
||||
let mut omitted = false;
|
||||
'slides: for slide in slides {
|
||||
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
||||
if pics.is_empty() {
|
||||
let text = slide
|
||||
@@ -605,13 +631,27 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
.find(|t| !t.trim().is_empty())
|
||||
.unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
if items.len() >= ALBUM_MAX_ITEMS {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
items.push(json!({"text": text.trim()}));
|
||||
} else if items.len() >= ALBUM_MAX_ITEMS && has_slide_content(slide) {
|
||||
// Past the item limit, a slide with other content would be deleted by a replace.
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sh in pics {
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
let media = doc.media(fill.media);
|
||||
let size = media.map_or(0, |m| m.data.len());
|
||||
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
total += size;
|
||||
let name = if !sh.descr.is_empty() {
|
||||
sh.descr.clone()
|
||||
} else {
|
||||
@@ -626,14 +666,19 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
}));
|
||||
}
|
||||
}
|
||||
items
|
||||
(items, omitted)
|
||||
}
|
||||
|
||||
/// The slides of album `ids` as the editor would seed them.
|
||||
fn album_slides<'a>(st: &'a crate::DocState, ids: &[u64]) -> Vec<&'a deckcraft_model::Slide> {
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect()
|
||||
}
|
||||
|
||||
fn album_seed(s: &Session) -> Result<Value> {
|
||||
let (id, ids) = album_target(s, &Value::Null)?;
|
||||
let st = s.doc()?;
|
||||
let slides: Vec<&deckcraft_model::Slide> =
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
|
||||
let slides = album_slides(st, &ids);
|
||||
let (items, _) = items_from_slides(&st.doc, &slides);
|
||||
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
|
||||
let inferred = infer_album(&slides, &theme_now);
|
||||
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
|
||||
@@ -655,7 +700,7 @@ fn album_seed(s: &Session) -> Result<Value> {
|
||||
"captions": captions,
|
||||
"grayscale": grayscale,
|
||||
"theme": theme,
|
||||
"items": items_from_slides(&st.doc, &slides),
|
||||
"items": items,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -722,3 +767,96 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
|
||||
s.execute("slide.go", &json!({"index": first}))?;
|
||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seed_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
|
||||
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
|
||||
let pic = |k: u32| Shape {
|
||||
id: ShapeId(10_000 + k),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
|
||||
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
|
||||
let (items, omitted) = items_from_slides(&p, &slides);
|
||||
assert_eq!(items.len(), ALBUM_MAX_ITEMS);
|
||||
assert!(omitted);
|
||||
let few: Vec<&deckcraft_model::Slide> = Vec::new();
|
||||
assert!(!items_from_slides(&p, &few).1);
|
||||
}
|
||||
|
||||
/// An album too big for the editor's seed cannot be replaced from that seed: the replace would
|
||||
/// delete what the seed left out.
|
||||
#[test]
|
||||
fn editing_an_album_the_editor_cannot_hold_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let n = doc.slides.len();
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
|
||||
}
|
||||
|
||||
/// Past the item limit, a slide holding only a drawing counts as content the replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_drawing_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes = vec![Shape { id: ShapeId(90_000), ..Default::default() }];
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// Notes or comments on a shapeless slide past the limit are content a replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_notes_only_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes.clear();
|
||||
extra.notes = deckcraft_model::text::TextBody::from_text("speaker notes");
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// A shapeless slide with a background, transition, colour map or tags counts as content; a plain empty one does not.
|
||||
#[test]
|
||||
fn slide_level_settings_count_as_slide_content() {
|
||||
use deckcraft_model::Slide;
|
||||
assert!(!has_slide_content(&Slide::default()));
|
||||
assert!(has_slide_content(&Slide { tags: vec![("k".into(), "v".into())], ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { color_map: Some(vec![("bg1".into(), "lt1".into())]), ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { transition: Some(Default::default()), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ pub(super) fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(t) = &text_color {
|
||||
c.text_color = t.clone();
|
||||
c.legend_entries.iter_mut().for_each(|e| e.color = None);
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
|
||||
@@ -152,6 +152,11 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
}
|
||||
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
|
||||
}
|
||||
// Handout or Notes Master view: the master's date placeholder (the selected one, else the
|
||||
// master's own), which every handout or notes page then shows.
|
||||
if s.doc()?.selection.target.is_page_master() {
|
||||
return write_master_date(s, o);
|
||||
}
|
||||
// Not editing: the slide's date placeholder (this slide only). A layout with no date
|
||||
// placeholder gets a text box instead, so the chosen format is still inserted.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
|
||||
@@ -161,6 +166,49 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Put the date in the date placeholder of the handout or notes master being edited.
|
||||
fn write_master_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
let (format, sample, lang, update) = (o.format.clone(), o.sample.clone(), o.lang.clone(), o.update);
|
||||
let has_date = {
|
||||
let st = s.doc()?;
|
||||
crate::page_master(&st.doc, st.selection.target).is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)))
|
||||
};
|
||||
if !has_date {
|
||||
return Err(bad(
|
||||
"insert.dateTime",
|
||||
"this master has no date placeholder; show it with handout.placeholders or notes.placeholders {date: true}",
|
||||
));
|
||||
}
|
||||
// The text the master holds afterwards, which the answer reports (it can differ from the
|
||||
// caller's sample when the placeholder's own language formats a fixed date).
|
||||
let mut written = o.sample.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let Some(m) = crate::page_master_mut(doc, sel.target) else { return Ok(false) };
|
||||
let m = std::sync::Arc::make_mut(m);
|
||||
let date = |x: &deckcraft_model::Shape| x.ph_type() == Some(deckcraft_model::PhType::Date);
|
||||
let chosen = sel.shapes.iter().find_map(|id| m.shapes.iter().position(|x| x.id == *id && date(x)));
|
||||
let Some(i) = chosen.or_else(|| m.shapes.iter().position(date)) else { return Ok(false) };
|
||||
let Some(sh) = m.shapes.get_mut(i) else { return Ok(false) };
|
||||
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
// A fixed date reads in the placeholder's own language when the caller named none.
|
||||
let text = match (&lang, &props.lang) {
|
||||
(None, Some(l)) if !update => date_sample(&format, Some(l)).unwrap_or_else(|_| sample.clone()),
|
||||
_ => sample.clone(),
|
||||
};
|
||||
if let Some(lang) = lang {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
|
||||
let body = sh.text.clone().unwrap_or_default();
|
||||
let mut para = body.paragraphs.first().cloned().unwrap_or_default();
|
||||
written.clone_from(&text);
|
||||
para.runs = vec![deckcraft_model::Run { text, props, kind }];
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
|
||||
Ok(true)
|
||||
})?;
|
||||
Ok(json!({"where": "master", "format": o.format, "update": o.update, "text": written}))
|
||||
}
|
||||
|
||||
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
|
||||
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
|
||||
let format = o.format.clone();
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots).
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::Hyperlink;
|
||||
use deckcraft_model::{Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, opt_usize_param, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn actions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "index", "slide.actions")?.unwrap_or(st.selection.slide);
|
||||
let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?;
|
||||
let mut found = Vec::new();
|
||||
for sh in &sl.shapes {
|
||||
collect_actions(sh, &mut Vec::new(), &mut found);
|
||||
}
|
||||
Ok(json!({"index": i, "slideId": sl.id, "actions": found}))
|
||||
}
|
||||
|
||||
fn collect_actions(sh: &Shape, path: &mut Vec<Value>, found: &mut Vec<Value>) {
|
||||
path.push(json!({"id": sh.id, "name": sh.name}));
|
||||
let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path});
|
||||
if let Some(link) = &sh.click {
|
||||
found.push(action_entry(owner.clone(), "click", link));
|
||||
}
|
||||
if let Some(link) = &sh.hover {
|
||||
found.push(action_entry(owner.clone(), "hover", link));
|
||||
}
|
||||
if let Some(text) = &sh.text {
|
||||
collect_text_actions(text, &owner, found);
|
||||
}
|
||||
if let ShapeKind::Table(table) = &sh.kind {
|
||||
for (row_index, row) in table.rows.iter().enumerate() {
|
||||
for (cell_index, cell) in row.cells.iter().enumerate() {
|
||||
let mut cell_owner = owner.clone();
|
||||
cell_owner["tableRowIndex"] = json!(row_index);
|
||||
cell_owner["tableCellIndex"] = json!(cell_index);
|
||||
collect_text_actions(&cell.text, &cell_owner, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind {
|
||||
for child in children {
|
||||
collect_actions(child, path, found);
|
||||
}
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
|
||||
fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec<Value>) {
|
||||
for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() {
|
||||
for (run_index, run) in paragraph.runs.iter().enumerate() {
|
||||
for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] {
|
||||
if let Some(link) = link {
|
||||
let mut text_owner = owner.clone();
|
||||
text_owner["paragraphIndex"] = json!(paragraph_index);
|
||||
text_owner["runIndex"] = json!(run_index);
|
||||
text_owner["text"] = json!(run.content());
|
||||
found.push(action_entry(text_owner, event, link));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value {
|
||||
let action = serde_json::to_value(&link.action).unwrap_or_default();
|
||||
let kind = action.get("kind").cloned().unwrap_or(Value::Null);
|
||||
let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null);
|
||||
json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link})
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id =
|
||||
@@ -154,3 +220,82 @@ fn commands(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::table::Row;
|
||||
use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody};
|
||||
use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table};
|
||||
|
||||
fn link(action: Action) -> Hyperlink {
|
||||
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_lists_group_shape_and_text_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() };
|
||||
child.click = Some(link(Action::NextSlide));
|
||||
child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() }));
|
||||
let run_props = RunProps { link: Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })), ..Default::default() };
|
||||
let mut paragraph = Paragraph::new("linked");
|
||||
paragraph.runs = vec![Run::with("linked", run_props)];
|
||||
child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() });
|
||||
let group = Shape {
|
||||
id: ShapeId(11),
|
||||
name: "Group".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide_index = st.selection.slide;
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group];
|
||||
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let actions = v["actions"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 3, "{v}");
|
||||
assert_eq!(actions[0]["event"], "click");
|
||||
assert_eq!(actions[0]["type"], "nextSlide");
|
||||
assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group");
|
||||
assert_eq!(actions[0]["owner"]["path"][1]["id"], 12);
|
||||
assert_eq!(actions[1]["event"], "hover");
|
||||
assert_eq!(actions[1]["target"], "https://example.com/hover");
|
||||
assert_eq!(actions[2]["event"], "textLink");
|
||||
assert_eq!(actions[2]["owner"]["runIndex"], 0);
|
||||
assert_eq!(actions[2]["target"], 7);
|
||||
|
||||
let cell_props = RunProps { hover: Some(link(Action::Url { url: "https://example.com/cell".into() })), ..Default::default() };
|
||||
let mut cell_text = TextBody::from_text("cell link");
|
||||
cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)];
|
||||
let table =
|
||||
Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() };
|
||||
let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
let st = s.doc_mut().unwrap();
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape);
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap();
|
||||
assert_eq!(table_action["event"], "textHover");
|
||||
assert_eq!(table_action["owner"]["tableRowIndex"], 0);
|
||||
assert_eq!(table_action["owner"]["tableCellIndex"], 0);
|
||||
assert_eq!(table_action["target"], "https://example.com/cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_returns_empty_array_for_slide_without_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
assert_eq!(v["actions"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_refuses_a_bad_index_instead_of_using_the_selection() {
|
||||
let mut s = Session::with_new();
|
||||
for index in [json!(-1), json!("0"), json!(1.5)] {
|
||||
assert!(s.execute("slide.actions", &json!({"index": index})).is_err(), "{index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
list.insert(at + k, sh);
|
||||
}
|
||||
list.retain(|x| !ids.contains(&x.id));
|
||||
sel.shapes = new_ids.clone();
|
||||
let _ = list;
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||
{
|
||||
let sl = std::sync::Arc::make_mut(sl);
|
||||
sl.animations.retain(|a| !ids.contains(&a.shape));
|
||||
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) {
|
||||
a.trigger = None;
|
||||
}
|
||||
}
|
||||
sel.shapes = new_ids.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
@@ -338,4 +347,36 @@ mod tests {
|
||||
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
|
||||
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() {
|
||||
for op in ["union", "combine", "fragment", "intersect", "subtract"] {
|
||||
let mut s = Session::with_new();
|
||||
let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation");
|
||||
s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation");
|
||||
s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation");
|
||||
|
||||
s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge");
|
||||
let animations = &s.doc().expect("doc").doc.slides[0].animations;
|
||||
assert_eq!(animations.len(), 1, "{op} removed animations for merged operands");
|
||||
assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect");
|
||||
assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger");
|
||||
|
||||
s.execute("edit.undo", &json!({})).expect("undo merge");
|
||||
let animations = &s.doc().expect("doc").doc.slides[0].animations;
|
||||
assert_eq!(animations.len(), 2, "{op} undo restored removed effects");
|
||||
assert_eq!(animations[0].shape.0, second);
|
||||
assert_eq!(animations[1].shape.0, unrelated);
|
||||
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool {
|
||||
let e = Effects {
|
||||
outer_shadow: lk.shadow.map(shadow),
|
||||
soft_edge: lk.soft_edge,
|
||||
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
reflection: lk.reflection.then_some(Reflection {
|
||||
blur: 0.5,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.35,
|
||||
dist: 0.0,
|
||||
..Default::default()
|
||||
}),
|
||||
bevel: lk.bevel.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -6,16 +6,155 @@
|
||||
//! renumbered with animations, triggers and connector ends following them. Links to another
|
||||
//! reused slide follow its copy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::table::TableStyleDef;
|
||||
use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
/// Scale physical measurements in imported content to the destination slide's coordinate space.
|
||||
/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
|
||||
fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
|
||||
let unit = (sx * sy).sqrt();
|
||||
for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
|
||||
*v *= sx;
|
||||
}
|
||||
for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
|
||||
*v *= sy;
|
||||
}
|
||||
scale_list(&mut t.list_style, sx, unit);
|
||||
for p in &mut t.paragraphs {
|
||||
for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(stops) = &mut p.props.tabs {
|
||||
for stop in stops {
|
||||
stop.pos *= sx;
|
||||
}
|
||||
}
|
||||
for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
|
||||
if let Some(Spacing::Pts(v)) = spacing {
|
||||
*v *= unit;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &mut p.props.bullet_size_points {
|
||||
*v *= unit;
|
||||
}
|
||||
scale_run(&mut p.def_props, unit);
|
||||
scale_run(&mut p.end_props, unit);
|
||||
for r in &mut p.runs {
|
||||
scale_run(&mut r.props, unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
|
||||
for level in list.levels.iter_mut().flatten() {
|
||||
for v in
|
||||
[&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
|
||||
{
|
||||
*v *= sx;
|
||||
}
|
||||
for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
|
||||
if let Some(Spacing::Pts(v)) = spacing {
|
||||
*v *= unit;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &mut level.para.bullet_size_points {
|
||||
*v *= unit;
|
||||
}
|
||||
scale_run(&mut level.run, unit);
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_run(run: &mut RunProps, factor: f64) {
|
||||
for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
|
||||
*v *= factor;
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
let unit = (sx * sy).sqrt();
|
||||
for shape in shapes {
|
||||
if let Some(x) = &mut shape.xfrm {
|
||||
x.x *= sx;
|
||||
x.w *= sx;
|
||||
x.y *= sy;
|
||||
x.h *= sy;
|
||||
}
|
||||
if let Some(text) = &mut shape.text {
|
||||
scale_text(text, sx, sy);
|
||||
}
|
||||
match &mut shape.kind {
|
||||
ShapeKind::Table(table) => {
|
||||
for col in &mut table.cols {
|
||||
*col *= sx;
|
||||
}
|
||||
for row in &mut table.rows {
|
||||
row.height *= sy;
|
||||
for cell in &mut row.cells {
|
||||
if let Some(margins) = &mut cell.margins {
|
||||
if let Some(v) = margins.get_mut(0) {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(1) {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(2) {
|
||||
*v *= sy;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(3) {
|
||||
*v *= sy;
|
||||
}
|
||||
}
|
||||
scale_text(&mut cell.text, sx, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShapeKind::Group { children, child } => {
|
||||
child.x *= sx;
|
||||
child.w *= sx;
|
||||
child.y *= sy;
|
||||
child.h *= sy;
|
||||
scale_shapes(children, sx, sy, depth + 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(effects) = &mut shape.effects {
|
||||
for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
|
||||
shadow.blur *= unit;
|
||||
shadow.dist *= unit;
|
||||
}
|
||||
if let Some(glow) = &mut effects.glow {
|
||||
glow.radius *= unit;
|
||||
}
|
||||
if let Some(reflection) = &mut effects.reflection {
|
||||
reflection.blur *= unit;
|
||||
reflection.dist *= unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_master(master: &mut Master, sx: f64, sy: f64) {
|
||||
scale_shapes(&mut master.shapes, sx, sy, 0);
|
||||
let unit = (sx * sy).sqrt();
|
||||
scale_list(&mut master.title_style, sx, unit);
|
||||
scale_list(&mut master.body_style, sx, unit);
|
||||
scale_list(&mut master.other_style, sx, unit);
|
||||
for layout in &mut master.layouts {
|
||||
scale_shapes(&mut layout.shapes, sx, sy, 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -152,6 +291,90 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec<String>, seen: &mut HashSet<String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &shape.kind
|
||||
&& seen.insert(table.style.to_ascii_lowercase())
|
||||
{
|
||||
styles.push(table.style.clone());
|
||||
}
|
||||
collect_table_styles(shape.children(), styles, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap<String, String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &mut shape.kind
|
||||
&& let Some(style) = styles.get(&table.style.to_ascii_lowercase())
|
||||
{
|
||||
table.style = style.clone();
|
||||
}
|
||||
if let Some(children) = shape.children_mut() {
|
||||
remap_table_styles(children, styles, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool {
|
||||
a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw
|
||||
}
|
||||
|
||||
/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so
|
||||
/// existing destination tables continue to resolve to their original definitions.
|
||||
fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result<HashMap<String, String>> {
|
||||
let mut map = HashMap::new();
|
||||
let mut source_defs = HashMap::new();
|
||||
let mut source_ids = HashSet::new();
|
||||
for (index, style) in src.table_styles.iter().enumerate() {
|
||||
let key = style.id.to_ascii_lowercase();
|
||||
source_defs.entry(key.clone()).or_insert(index);
|
||||
source_ids.insert(key);
|
||||
}
|
||||
let mut destination_defs = HashMap::new();
|
||||
for (index, style) in doc.table_styles.iter().enumerate() {
|
||||
destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index);
|
||||
}
|
||||
for id in used {
|
||||
let key = id.to_ascii_lowercase();
|
||||
if map.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let Some(source_index) = source_defs.get(&key).copied() else { continue };
|
||||
let Some(source) = src.table_styles.get(source_index) else { continue };
|
||||
let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index));
|
||||
let mut imported = source.clone();
|
||||
if let Some(existing) = destination {
|
||||
if same_table_style(existing, source) {
|
||||
map.insert(key, existing.id.clone());
|
||||
continue;
|
||||
}
|
||||
let candidate = loop {
|
||||
let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
|
||||
let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}");
|
||||
let key = candidate.to_ascii_lowercase();
|
||||
if !destination_defs.contains_key(&key) && !source_ids.contains(&key) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
// The definition's own XML names it too, and PPTX export writes that XML as is.
|
||||
imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1);
|
||||
imported.id.clone_from(&candidate);
|
||||
map.insert(key, candidate);
|
||||
} else {
|
||||
map.insert(key, imported.id.clone());
|
||||
}
|
||||
destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len());
|
||||
doc.table_styles.push(imported);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
@@ -176,8 +399,23 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let keep = bool_or(p, "keepFormatting", false);
|
||||
let at = usize_param(p, "at");
|
||||
let source_size = src.slide_size;
|
||||
s.edit(|doc, sel| {
|
||||
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
|
||||
let mut used_table_styles = Vec::new();
|
||||
let mut seen_table_styles = HashSet::new();
|
||||
for &i in &picked {
|
||||
if let Some(slide) = src.slides.get(i) {
|
||||
collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0);
|
||||
}
|
||||
}
|
||||
let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?;
|
||||
let scale = |source: f64, destination: f64| {
|
||||
let ratio = destination / source;
|
||||
if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 }
|
||||
};
|
||||
let sx = scale(source_size.width, doc.slide_size.width);
|
||||
let sy = scale(source_size.height, doc.slide_size.height);
|
||||
// Media: what the slides use is copied under fresh ids.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Keep Source Formatting: bring each source master used, once, with fresh ids.
|
||||
@@ -190,7 +428,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
scale_master(&mut m, sx, sy);
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -201,6 +440,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
remap_table_styles(&mut sl.shapes, &table_style_map, 0);
|
||||
scale_shapes(&mut sl.shapes, sx, sy, 0);
|
||||
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
|
||||
@@ -284,13 +525,205 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_geom::{Size, Xfrm};
|
||||
use deckcraft_model::style::{Artistic, Fill, PictureFill};
|
||||
use deckcraft_model::table::TableStyleDef;
|
||||
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
|
||||
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
|
||||
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}";
|
||||
|
||||
fn styled_table_presentation(id: &str, color: &str) -> Presentation {
|
||||
let mut p = deckcraft_model::defaults::new_presentation(None);
|
||||
let raw = format!(
|
||||
r#"<a:tblStyle xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" styleId="{id}" styleName="Owned"><a:wholeTbl><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:fill></a:tcStyle></a:wholeTbl></a:tblStyle>"#
|
||||
);
|
||||
p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() });
|
||||
let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
|
||||
let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
let group = deckcraft_model::Shape {
|
||||
id: ShapeId(p.alloc_id().expect("group id")),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = p.slides.first_mut().expect("default slide");
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
p
|
||||
}
|
||||
|
||||
fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option<String> {
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &shape.kind {
|
||||
return Some(table.style.clone());
|
||||
}
|
||||
if let Some(style) = first_table_style(shape.children()) {
|
||||
return Some(style);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() {
|
||||
for (name, bytes) in [
|
||||
("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")),
|
||||
("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")),
|
||||
] {
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
let mut session = Session::with_new();
|
||||
session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse");
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
|
||||
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000")));
|
||||
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
|
||||
session.execute("edit.undo", &json!({})).expect("undo reuse");
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE));
|
||||
session.execute("edit.redo", &json!({})).expect("redo reuse");
|
||||
let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination");
|
||||
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination");
|
||||
assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE));
|
||||
assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() {
|
||||
let source = styled_table_presentation(CUSTOM_STYLE, "FF0000");
|
||||
let source_bytes = deckcraft_format::save(&source).expect("save source");
|
||||
|
||||
let mut identical = Session::with_new();
|
||||
identical
|
||||
.edit(|doc, _| {
|
||||
doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default());
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed identical definition");
|
||||
identical
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
|
||||
.expect("reuse identical definition");
|
||||
let doc = &identical.doc().expect("doc").doc;
|
||||
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
|
||||
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
|
||||
let mut conflicting = Session::with_new();
|
||||
conflicting
|
||||
.edit(|doc, _| {
|
||||
let mut own = source.table_styles.first().cloned().unwrap_or_default();
|
||||
own.raw = own.raw.replace("FF0000", "0000FF");
|
||||
doc.table_styles.push(own);
|
||||
let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
|
||||
let id = ShapeId(doc.alloc_id().expect("destination table id"));
|
||||
if let Some(slide) = doc.slides.first_mut() {
|
||||
Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() });
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed conflicting definition");
|
||||
conflicting
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
|
||||
.expect("reuse conflicting definition");
|
||||
let doc = &conflicting.doc().expect("doc").doc;
|
||||
assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style");
|
||||
assert_ne!(imported_style, CUSTOM_STYLE);
|
||||
assert!(
|
||||
doc.table_styles.iter().any(|style| style.id == imported_style
|
||||
&& style.raw.contains("FF0000")
|
||||
&& style.raw.contains(&format!("styleId=\"{imported_style}\"")))
|
||||
);
|
||||
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_slide_content_fits_destination_size() {
|
||||
let mut src = defaults::new_presentation(None);
|
||||
src.slide_size = Size::new(960.0, 540.0);
|
||||
let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
|
||||
if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
|
||||
let mut text = TextBody::from_text("scaled");
|
||||
if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
run.props.size = Some(24.0);
|
||||
}
|
||||
slide.shapes = vec![
|
||||
Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
|
||||
Shape {
|
||||
xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
|
||||
text: Some(text),
|
||||
kind: ShapeKind::Table({
|
||||
let mut table = Table::new(2, 2, 400.0, 40.0);
|
||||
table.rows[0].cells[0].text = TextBody::from_text("cell");
|
||||
if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
run.props.size = Some(18.0);
|
||||
}
|
||||
table
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Shape {
|
||||
xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
|
||||
kind: ShapeKind::Group {
|
||||
child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
|
||||
children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
}
|
||||
if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
|
||||
master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
|
||||
if let Some(layout) = master.layouts.first_mut() {
|
||||
layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
|
||||
}
|
||||
}
|
||||
let bytes = deckcraft_format::save(&src).expect("source save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
let mut session = Session::with_new();
|
||||
session
|
||||
.edit(|doc, _| {
|
||||
doc.slide_size = Size::new(480.0, 270.0);
|
||||
Ok(())
|
||||
})
|
||||
.expect("destination size");
|
||||
session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
|
||||
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
let reused = doc.slides.get(1).expect("reused slide");
|
||||
let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
|
||||
assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
|
||||
let table_shape = reused.shapes.get(1).expect("table");
|
||||
let table_box = table_shape.xfrm.expect("table transform");
|
||||
assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
|
||||
assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
|
||||
let ShapeKind::Table(table) = &table_shape.kind else { return };
|
||||
assert_eq!(table.cols, vec![100.0, 100.0]);
|
||||
assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
|
||||
let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
|
||||
assert_eq!(font_size, Some(12.0));
|
||||
let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size);
|
||||
assert_eq!(cell_size, Some(9.0));
|
||||
let group = reused.shapes.get(2).expect("group");
|
||||
assert!(matches!(&group.kind, ShapeKind::Group { .. }));
|
||||
let ShapeKind::Group { children, child } = &group.kind else { return };
|
||||
assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
|
||||
assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
|
||||
assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
|
||||
|
||||
let imported_master = doc.masters.last().expect("imported master");
|
||||
assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
|
||||
assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
|
||||
assert_ne!(reused.id, slide_id);
|
||||
|
||||
let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
|
||||
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
|
||||
let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
|
||||
assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
|
||||
}
|
||||
|
||||
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
|
||||
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
|
||||
#[test]
|
||||
|
||||
+201
-19
@@ -58,9 +58,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
insert_synonym
|
||||
),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare the open deck with another file → {name, changes: [{kind, slide, number, onlyIn?, revised, details}]}; each change is described from the open deck: `onlyIn: \"deck\"` is in the deck and not in the file, `onlyIn: \"file\"` is in the file and not in the deck, `changed` is in both and differs; `slide` is 0-based, `number` the editor's slide number; review.accept takes the FILE's side, so it deletes what is only in the deck (Accept All keeps the deck's last remaining slide rather than deleting it)", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide (0-based), number (editor's), onlyIn?: deck|file, revised, details}]}; `onlyIn: deck` = only in the open deck, `file` = only in the compared file; accepting takes the file's side", has_doc, revisions),
|
||||
cmd!(
|
||||
"review.accept",
|
||||
"Accept Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} — accept the compared file's side of a change (or all): what is only in the file is added, what is only in the open deck is deleted (all: true leaves the deck's last remaining slide in place), a changed slide becomes the file's",
|
||||
comparing,
|
||||
accept
|
||||
),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
@@ -211,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
|
||||
let cmd = if forward { "comment.next" } else { "comment.prev" };
|
||||
let (threads, slide, index) = {
|
||||
let st = s.doc()?;
|
||||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
|
||||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
|
||||
};
|
||||
if threads.is_empty() {
|
||||
return Err(bad(cmd, "no comments"));
|
||||
@@ -264,19 +272,31 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(bg) = bg {
|
||||
for sh in &sl.shapes {
|
||||
let Some(t) = &sh.text else { continue };
|
||||
let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue };
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, ¶.runs.first().map(|r| r.props.clone()).unwrap_or_default());
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
let Some(t) = &sh.text else { return };
|
||||
let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh);
|
||||
if fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let fg = deckcraft_model::resolve::text_color(&ctx, &rp);
|
||||
if fg.contrast(bg) < 3.0 {
|
||||
out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))}));
|
||||
let low = t
|
||||
.paragraphs
|
||||
.iter()
|
||||
.flat_map(|para| {
|
||||
// A line break or blank run shows no glyphs, so its colour can't be hard to read.
|
||||
para.runs.iter().filter(|run| run.kind != deckcraft_model::text::RunKind::Break && !run.text.trim().is_empty()).map(
|
||||
|run| {
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, &run.props);
|
||||
deckcraft_model::resolve::text_color(&ctx, &rp).contrast(bg)
|
||||
},
|
||||
)
|
||||
})
|
||||
.find(|contrast| *contrast < 3.0);
|
||||
if let Some(contrast) = low {
|
||||
out.push(
|
||||
json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({contrast:.1}:1)")}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||||
let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||||
for w in crate::spell::paragraph_words(para, &default) {
|
||||
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
|
||||
continue;
|
||||
@@ -401,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
|
||||
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
// The result is built in one pass; the text is not edited in place for every match.
|
||||
let mut out = String::new();
|
||||
let (mut n, mut at, mut copied) = (0, 0, 0);
|
||||
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
|
||||
let end = i + from.len();
|
||||
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
|
||||
// The character before the match as the edited text would have it.
|
||||
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
|
||||
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
|
||||
if before || after {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
text.replace_range(i..end, to);
|
||||
out.push_str(text.get(copied..i).unwrap_or_default());
|
||||
out.push_str(to);
|
||||
n += 1;
|
||||
at = i + to.len();
|
||||
(at, copied) = (end, end);
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
out.push_str(text.get(copied..).unwrap_or_default());
|
||||
*text = out;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
@@ -744,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The replacement as it ran before: edited in place for every match.
|
||||
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
|
||||
let end = i + from.len();
|
||||
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
|
||||
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
|
||||
if before || after {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
text.replace_range(i..end, to);
|
||||
n += 1;
|
||||
at = i + to.len();
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_word_matches_the_in_place_version() {
|
||||
let cases = [
|
||||
("a a b a", "a", "x", true),
|
||||
("aa a. a", "a", "bb", true),
|
||||
("a-a -a-", "-", "ab", true),
|
||||
("foo. foo.", "foo.", "", true),
|
||||
("it's it", "it", "so", true),
|
||||
("x.x. x.", "x.", "y", true),
|
||||
("a a a", "a", "b", false),
|
||||
("none", "z", "q", true),
|
||||
("é é", "é", "e", true),
|
||||
];
|
||||
for (text, from, to, all) in cases {
|
||||
let (mut a, mut b) = (text.to_string(), text.to_string());
|
||||
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
|
||||
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -792,6 +862,40 @@ mod tests {
|
||||
assert_eq!(here["text"], "on first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_comment_navigation_index_does_not_change_selection() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("comment.add", &json!({"text": "first"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "second"})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
|
||||
s.doc_mut().unwrap().selection.shapes = vec![shape];
|
||||
|
||||
for (command, value) in [
|
||||
("comment.next", json!(-1)),
|
||||
("comment.next", json!(1.5)),
|
||||
("comment.next", json!("bad")),
|
||||
("comment.prev", json!(-1)),
|
||||
("comment.prev", json!(1.5)),
|
||||
("comment.prev", json!("bad")),
|
||||
] {
|
||||
let before = s.doc().unwrap().selection.clone();
|
||||
let error = s.execute(command, &json!({"index": value})).unwrap_err();
|
||||
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
|
||||
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
|
||||
}
|
||||
|
||||
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
|
||||
assert_eq!(zero["slide"], 1);
|
||||
assert_eq!(zero["index"], 0);
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let omitted = s.execute("comment.next", &json!({})).unwrap();
|
||||
assert_eq!(omitted["slide"], 0);
|
||||
assert_eq!(omitted["index"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_iso_utc() {
|
||||
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
|
||||
@@ -860,3 +964,81 @@ mod spelling_change_range_tests {
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod accessibility_contrast_tests {
|
||||
use super::*;
|
||||
use deckcraft_model::text::{Paragraph, Run, RunKind, RunProps};
|
||||
use deckcraft_model::{ColorRef, Fill, Rgba, Shape, ShapeId, ShapeKind, TextBody};
|
||||
use serde_json::json;
|
||||
|
||||
fn run(text: &str, color: Rgba) -> Run {
|
||||
Run::with(text, RunProps { fill: Some(Fill::solid(ColorRef::rgb(color))), ..Default::default() })
|
||||
}
|
||||
|
||||
fn text_shape(id: u32, name: &str, paragraphs: Vec<Paragraph>) -> Shape {
|
||||
Shape { id: ShapeId(id), name: name.into(), text: Some(TextBody { paragraphs, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_checks_mixed_runs_later_paragraphs_and_group_descendants_once() {
|
||||
let mut s = Session::with_new();
|
||||
s.edit(|doc, _| {
|
||||
let black = Rgba::BLACK;
|
||||
let white = Rgba::WHITE;
|
||||
let mixed = Paragraph { runs: vec![run("visible", black), run("hidden", white)], ..Default::default() };
|
||||
let later = vec![
|
||||
Paragraph { runs: vec![run("visible", black)], ..Default::default() },
|
||||
Paragraph { runs: vec![run("hidden", white)], ..Default::default() },
|
||||
];
|
||||
let child = text_shape(5, "nested text", vec![Paragraph { runs: vec![run("hidden", white)], ..Default::default() }]);
|
||||
let group = Shape {
|
||||
id: ShapeId(4),
|
||||
name: "group".into(),
|
||||
descr: "group of text".into(),
|
||||
kind: ShapeKind::Group {
|
||||
children: vec![Shape {
|
||||
id: ShapeId(7),
|
||||
name: "nested group".into(),
|
||||
descr: "nested group of text".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
}],
|
||||
child: Default::default(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(0).ok_or_else(|| bad("test", "no slide"))?);
|
||||
sl.shapes = vec![
|
||||
text_shape(2, "mixed", vec![mixed]),
|
||||
text_shape(3, "later paragraph", later),
|
||||
group,
|
||||
text_shape(6, "black control", vec![Paragraph { runs: vec![run("visible", black)], ..Default::default() }]),
|
||||
text_shape(
|
||||
8,
|
||||
"invisible white runs",
|
||||
vec![Paragraph {
|
||||
runs: vec![run("visible", black), Run { kind: RunKind::Break, ..run("\n", white) }, run(" ", white), run("visible", black)],
|
||||
..Default::default()
|
||||
}],
|
||||
),
|
||||
];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let issues = s.execute("review.accessibility", &json!({})).unwrap();
|
||||
let contrasts: Vec<_> = issues
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|issue| issue["issue"].as_str().unwrap_or("").starts_with("Hard-to-read text contrast"))
|
||||
.collect();
|
||||
assert_eq!(contrasts.len(), 3, "{issues}");
|
||||
for id in [2, 3, 5] {
|
||||
assert!(contrasts.iter().any(|issue| issue["shape"] == id), "missing shape {id}: {issues}");
|
||||
}
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 6), "black control warned: {issues}");
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 8), "white break or spaces warned: {issues}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
|
||||
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
|
||||
const MAX_HTML: usize = 1 << 20;
|
||||
@@ -14,20 +16,51 @@ const MAX_RUNS: usize = 10_000;
|
||||
pub fn body_to_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str("<br>");
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
out.push_str("<p>");
|
||||
for r in &p.runs {
|
||||
let (open, close) = tags(&r.props);
|
||||
out.push_str(&open);
|
||||
if !push_if_fits(&mut out, &open, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
match &r.kind {
|
||||
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
|
||||
_ => push_escaped(&mut out, &r.text),
|
||||
deckcraft_model::text::RunKind::Break => {
|
||||
if !push_if_fits(&mut out, "<br>", close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !push_escaped_bounded(&mut out, &r.text, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(&close);
|
||||
if out.len() > MAX_HTML {
|
||||
break;
|
||||
}
|
||||
out.push_str("</p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text without formatting, or "" (no HTML on the clipboard) when even that is too big: a
|
||||
/// cut-off prefix would paste as if it were the whole text.
|
||||
fn plain_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return String::new();
|
||||
}
|
||||
for r in &p.runs {
|
||||
let fits = if matches!(&r.kind, deckcraft_model::text::RunKind::Break) {
|
||||
push_if_fits(&mut out, "<br>", 4)
|
||||
} else {
|
||||
push_escaped_bounded(&mut out, &r.text, 4)
|
||||
};
|
||||
if !fits {
|
||||
return String::new();
|
||||
}
|
||||
}
|
||||
out.push_str("</p>");
|
||||
@@ -35,6 +68,43 @@ pub fn body_to_html(body: &TextBody) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn push_if_fits(out: &mut String, value: &str, reserve: usize) -> bool {
|
||||
if out.len().checked_add(value.len()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push_str(value);
|
||||
true
|
||||
}
|
||||
|
||||
fn push_escaped_bounded(out: &mut String, text: &str, reserve: usize) -> bool {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => {
|
||||
if !push_if_fits(out, "&", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if !push_if_fits(out, "<", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'>' => {
|
||||
if !push_if_fits(out, ">", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if out.len().checked_add(c.len_utf8()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn tags(props: &RunProps) -> (String, String) {
|
||||
let mut open = String::new();
|
||||
let mut close = String::new();
|
||||
@@ -53,17 +123,6 @@ fn tags(props: &RunProps) -> (String, String) {
|
||||
(open, close)
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatted text of an HTML fragment, or `None` when it has no text.
|
||||
///
|
||||
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
|
||||
@@ -77,7 +136,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
let mut bold = 0i32;
|
||||
let mut italic = 0i32;
|
||||
let mut underline = 0i32;
|
||||
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
|
||||
let mut stack: Vec<(Cow<'static, str>, i32, i32, i32)> = Vec::new();
|
||||
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
|
||||
let mut runs = 0usize;
|
||||
let mut skip = 0i32;
|
||||
@@ -106,23 +165,27 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
continue;
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty() {
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => name.as_str(),
|
||||
kind => kind,
|
||||
};
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0.as_ref() == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (db, di, du) = (stack[pos].1, stack[pos].2, stack[pos].3);
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
if is_break(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
continue;
|
||||
@@ -133,7 +196,11 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => Cow::Owned(name.clone()),
|
||||
kind => Cow::Borrowed(kind),
|
||||
};
|
||||
stack.push((kind, db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
@@ -368,6 +435,34 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn style_on_other_tags_ends_at_their_close() {
|
||||
type Prop = fn(&RunProps) -> bool;
|
||||
let cases: [(&str, &str, Prop); 3] = [
|
||||
("a", "font-weight:bold", |p| p.bold == Some(true)),
|
||||
("td", "font-style:italic", |p| p.italic == Some(true)),
|
||||
("mark", "text-decoration:underline", |p| p.underline.is_some()),
|
||||
];
|
||||
for (tag, style, prop) in cases {
|
||||
let body = html_to_body(&format!("<p><{tag} style=\"{style}\">link</{tag}> plain</p>")).unwrap();
|
||||
let runs = &body.paragraphs[0].runs;
|
||||
assert_eq!(runs.len(), 2, "{tag}");
|
||||
assert!(prop(&runs[0].props), "{tag}");
|
||||
assert_eq!(runs[0].text, "link");
|
||||
assert_eq!(runs[1].text, " plain");
|
||||
assert!(runs[1].props.bold.is_none() && runs[1].props.italic.is_none() && runs[1].props.underline.is_none(), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_before_opening_block_tag_is_separate_paragraph() {
|
||||
for tag in ["div", "p", "li", "h1"] {
|
||||
let body = html_to_body(&format!("Hello<{tag}>world</{tag}>")).unwrap();
|
||||
let texts: Vec<String> = body.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect()).collect();
|
||||
assert_eq!(texts, ["Hello", "world"], "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_after_ampersand_is_kept() {
|
||||
let text = "é".repeat(20);
|
||||
@@ -436,6 +531,16 @@ mod tests {
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_escaped_rich_text_puts_no_html_on_the_clipboard() {
|
||||
let mut body = TextBody::from_text(&"&<🦀".repeat(100_000));
|
||||
body.paragraphs[0].runs[0].props.bold = Some(true);
|
||||
body.paragraphs.push(Paragraph::new("later paragraph"));
|
||||
|
||||
// Neither the formatted nor the plain HTML fits, and a truncated prefix would lose text.
|
||||
assert!(body_to_html(&body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
|
||||
let text = |html: &str| html_to_body(html).unwrap().text();
|
||||
|
||||
@@ -135,6 +135,14 @@ fn eye_show_all_and_hide_all() {
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_group_with_its_child_targeted_leaves_child_locked() {
|
||||
let (mut s, ids) = deck();
|
||||
let (d, g) = (ids[3], ids[7]);
|
||||
s.execute("shape.lock", &json!({"ids": [g, d, d]})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
|
||||
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
|
||||
has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
has_selection,
|
||||
fill
|
||||
),
|
||||
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Effects",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
|
||||
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, size?: 0.01..2 (1 = 100%), blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
|
||||
has_selection,
|
||||
effects
|
||||
),
|
||||
@@ -165,7 +165,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
picture_adjust
|
||||
),
|
||||
cmd!("picture.reset", "Reset Picture", ["Picture Format", "Adjust"], None, "{ids?}", has_selection, picture_reset),
|
||||
cmd!(
|
||||
"picture.reset",
|
||||
"Reset Picture",
|
||||
["Picture Format", "Adjust"],
|
||||
None,
|
||||
"{ids?} (discards the picture's formatting: adjustments, artistic effect, crop, transparency, picture-style effects and outline; the frame shape from Change Shape or Crop to Shape, the size and the position stay)",
|
||||
has_selection,
|
||||
picture_reset
|
||||
),
|
||||
cmd!(
|
||||
"picture.change",
|
||||
"Change Picture",
|
||||
@@ -253,6 +261,9 @@ fn requested_fill(s: &mut Session, p: &Value) -> Result<Option<Option<Fill>>> {
|
||||
if bool_or(p, "none", false) {
|
||||
return Ok(Some(Some(Fill::None)));
|
||||
}
|
||||
if bool_or(p, "background", false) {
|
||||
return Ok(Some(Some(Fill::Background)));
|
||||
}
|
||||
if let Some(c) = color_param(p, "color") {
|
||||
return Ok(Some(Some(Fill::solid(c))));
|
||||
}
|
||||
@@ -363,6 +374,25 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_param(p, "locked");
|
||||
// A targeted shape inside another targeted group is reached through the group: editing it again
|
||||
// would toggle it back.
|
||||
let mut ids = super::targets(s, p)?;
|
||||
if ids.len() > 1 {
|
||||
let doc = s.doc()?;
|
||||
// Walk each distinct group once, however often `ids` repeats it.
|
||||
let mut once = std::collections::HashSet::new();
|
||||
ids.retain(|id| once.insert(*id));
|
||||
let mut inside = std::collections::HashSet::new();
|
||||
for id in &ids {
|
||||
if let Some(g) = doc.shape(*id) {
|
||||
deckcraft_model::walk(g.children(), &mut |c, _| {
|
||||
inside.insert(c.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
ids.retain(|id| !inside.contains(id));
|
||||
}
|
||||
let p = &json!({"ids": ids, "locked": p.get("locked")});
|
||||
edit_shapes(s, p, "shape.lock", |sh| {
|
||||
let to = v.unwrap_or(!sh.locked);
|
||||
set_locked(sh, to, 0);
|
||||
@@ -495,6 +525,103 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_fill_shows_the_slide_background_and_survives_a_save() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0000c8"})).unwrap();
|
||||
let id = ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap() as u32);
|
||||
let inside = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 200, "y": 150})).unwrap()["color"].clone();
|
||||
assert_ne!(inside(&mut s), "0000C8", "a new shape takes the theme's fill");
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
|
||||
assert_eq!(inside(&mut s), "0000C8", "the slide background shows through the shape");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, None, "one undo step returns to the theme's fill");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// Transparency changes a solid or picture fill only.
|
||||
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
|
||||
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
let filled = back.slides.iter().flat_map(|sl| sl.shapes.iter()).filter(|sh| sh.fill == Some(Fill::Background)).count();
|
||||
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
|
||||
}
|
||||
|
||||
/// A picture cannot save a slide background fill, so `background` leaves its fill alone.
|
||||
#[test]
|
||||
fn background_fill_skips_pictures() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png();
|
||||
let pic = s
|
||||
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "red.png", "rect": [400, 100, 100, 100]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
|
||||
let err = s.execute("shape.fill", &json!({"background": true, "ids": [pic]})).unwrap_err();
|
||||
assert!(err.to_string().contains("shapes and text boxes"), "{err}");
|
||||
assert_eq!(fill(&s, pic), None);
|
||||
|
||||
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
|
||||
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
assert_eq!(fill(&s, shape), Some(Fill::Background));
|
||||
assert!(matches!(fill(&s, pic), Some(Fill::Solid { .. })), "the picture keeps its fill");
|
||||
assert_eq!(fill(&s, group), None, "the group's own fill is cleared");
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
let mut filled = 0;
|
||||
for slide in &back.slides {
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| filled += usize::from(sh.fill == Some(Fill::Background)));
|
||||
}
|
||||
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
|
||||
}
|
||||
|
||||
/// A picture filled with its group's fill keeps that fill behind its transparent pixels when
|
||||
/// the group's shapes take the slide background fill.
|
||||
#[test]
|
||||
fn background_fill_keeps_the_group_fill_a_picture_uses() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [0, 0, 0, 0].repeat(4) }.to_png();
|
||||
let pic = s
|
||||
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "clear.png", "rect": [400, 100, 100, 100]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
|
||||
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0))));
|
||||
s.edit(|doc, _| {
|
||||
for slide in &mut doc.slides {
|
||||
let slide = std::sync::Arc::make_mut(slide);
|
||||
if let Some(g) = slide.shape_mut(ShapeId(group as u32)) {
|
||||
g.fill.clone_from(&red);
|
||||
}
|
||||
if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) {
|
||||
p.fill = Some(Fill::Group);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let behind = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 450, "y": 150})).unwrap()["color"].clone();
|
||||
assert_eq!(behind(&mut s), "FF0000", "the group's fill shows through the picture");
|
||||
s.execute("edit.select", &json!({"ids": [group]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
|
||||
assert_eq!(fill(&s, shape), Some(Fill::Background));
|
||||
assert_eq!(fill(&s, pic), Some(Fill::Group));
|
||||
assert_eq!(fill(&s, group), red, "the group keeps the fill its picture uses");
|
||||
assert_eq!(behind(&mut s), "FF0000", "the group's fill still shows through the picture");
|
||||
}
|
||||
|
||||
fn rotation(s: &Session) -> f64 {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.selection.shapes[0];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, usize_param,
|
||||
edit_shapes, f64_param, inherited_fills, json, opt_usize_param, requested_fill, str_param, targets,
|
||||
};
|
||||
|
||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
|
||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
@@ -74,7 +74,8 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
|
||||
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
|
||||
/// rotateWithShape?}`.
|
||||
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
|
||||
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
|
||||
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
let stops: Vec<GradientStop> = v
|
||||
.get("stops")?
|
||||
@@ -108,7 +109,13 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
scaled: false,
|
||||
},
|
||||
};
|
||||
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||||
Some(Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops,
|
||||
shape,
|
||||
rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true),
|
||||
flip: v.get("flip").and_then(Value::as_str).and_then(deckcraft_model::style::GradientFlip::from_xml),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||||
@@ -134,11 +141,47 @@ pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
|
||||
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
|
||||
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
|
||||
return Err(bad(
|
||||
"shape.fill",
|
||||
"give `color`, `none`, `background`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`",
|
||||
));
|
||||
}
|
||||
// Transparency on an inherited (style) fill needs the effective colour.
|
||||
let inherited = inherited_fills(s, p)?;
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited))
|
||||
let background = matches!(new_fill, Some(Some(Fill::Background)));
|
||||
if background {
|
||||
let st = s.doc()?;
|
||||
let mut any = false;
|
||||
for id in targets(s, p)? {
|
||||
if let Some(sh) = st.shape(id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| any |= matches!(sh.kind, deckcraft_model::ShapeKind::Shape));
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(bad("shape.fill", "slide background fill applies to shapes and text boxes"));
|
||||
}
|
||||
}
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| {
|
||||
if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) {
|
||||
if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) && !group_fill_used(sh, 0) {
|
||||
sh.fill = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)
|
||||
})
|
||||
}
|
||||
|
||||
/// Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures,
|
||||
/// connectors, tables and charts keep their fill, and a group's own fill is cleared unless one
|
||||
/// of them is filled with it (`grpFill`).
|
||||
fn group_fill_used(sh: &deckcraft_model::Shape, depth: usize) -> bool {
|
||||
depth <= 64
|
||||
&& sh.children().iter().any(|c| match c.kind {
|
||||
deckcraft_model::ShapeKind::Shape => false,
|
||||
deckcraft_model::ShapeKind::Group { .. } => matches!(c.fill, None | Some(Fill::Group)) && group_fill_used(c, depth + 1),
|
||||
_ => c.fill == Some(Fill::Group),
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
|
||||
@@ -329,9 +372,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
|
||||
let refl = str_param(p, "reflection").map(|r| match r {
|
||||
"none" => None,
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }),
|
||||
});
|
||||
let three_d = p.get("threeD").filter(|v| v.is_object());
|
||||
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
|
||||
@@ -401,6 +444,12 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = num(v, "dir") {
|
||||
x.dir = d.rem_euclid(360.0);
|
||||
}
|
||||
// PowerPoint's Size: 0–200%, both axes; a zero scale would collapse the shadow's matrix.
|
||||
if let Some(z) = num(v, "size") {
|
||||
let z = z.clamp(0.01, 2.0);
|
||||
x.sx = z;
|
||||
x.sy = z;
|
||||
}
|
||||
*slot = Some(x);
|
||||
}
|
||||
if let Some(v) = &glow_props {
|
||||
@@ -419,7 +468,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
e.glow = (g.radius > 0.0).then_some(g);
|
||||
}
|
||||
if let Some(v) = &refl_props {
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection {
|
||||
blur: 0.5,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.35,
|
||||
dist: 0.0,
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
|
||||
}
|
||||
@@ -500,7 +556,7 @@ pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>)
|
||||
}
|
||||
|
||||
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(1).min(41);
|
||||
let i = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
|
||||
let (st, f, l) = quick_style_for(i);
|
||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||
sh.style = Some(st.clone());
|
||||
@@ -513,3 +569,14 @@ pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gradient_parameters_keep_flip_mode() {
|
||||
let gradient = gradient_param(&json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "flip": "xy"})).expect("gradient");
|
||||
assert_eq!(gradient.flip, Some(deckcraft_model::style::GradientFlip::Xy));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
|
||||
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
let st = s.doc()?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
// In slide space, as shape.connect uses them, for a shape inside a group too.
|
||||
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -12,8 +13,13 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("shape.connect", "missing `from` / `to`"));
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
|
||||
let (a, b) = (site_list(from), site_list(to));
|
||||
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
|
||||
// glues to grouped shapes), its sites where the group draws them.
|
||||
let site_list = |id| {
|
||||
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
|
||||
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
|
||||
};
|
||||
let (a, b) = (site_list(from)?, site_list(to)?);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
|
||||
}
|
||||
@@ -35,6 +41,11 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let (p0, p1) = (a[ia], b[ib]);
|
||||
let id = match id_param(p, "id", "shape.connect")? {
|
||||
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
|
||||
// inside a group would be drawn in the group's space and never follow its shapes.
|
||||
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
|
||||
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
|
||||
}
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||||
@@ -93,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
a.iter()
|
||||
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
|
||||
.filter(|q| q.x.is_finite() && q.y.is_finite())
|
||||
.take(20_000)
|
||||
.take(crate::tools::MAX_GESTURE_POINTS)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -191,6 +191,13 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
|
||||
// also inside a group: ungrouping would hand the flip to the graphic.
|
||||
let mut graphic = false;
|
||||
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
|
||||
if graphic {
|
||||
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
|
||||
}
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
|
||||
+220
-24
@@ -1,5 +1,6 @@
|
||||
//! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::TextBody;
|
||||
@@ -22,7 +23,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate),
|
||||
cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete),
|
||||
cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide),
|
||||
cmd!(
|
||||
"slide.move",
|
||||
"Move Slide",
|
||||
[],
|
||||
None,
|
||||
"{from?: index, ids?: [slide id], to: index (counted without the moved slides)}",
|
||||
has_slide,
|
||||
move_slide
|
||||
),
|
||||
cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go),
|
||||
cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next),
|
||||
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
|
||||
@@ -258,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| !ids.contains(x));
|
||||
}
|
||||
for show in &mut doc.custom_shows {
|
||||
show.slides.retain(|x| !ids.contains(x));
|
||||
}
|
||||
sel.slide = first.min(doc.slides.len().saturating_sub(1));
|
||||
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
|
||||
sel.shapes.clear();
|
||||
@@ -266,36 +278,75 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves one slide (`from`, else the current slide) or several (`ids`) to `to`, in one undo step.
|
||||
/// `to` counts in the deck without the moved slides; they land there as one run, in the order
|
||||
/// they had, join the section of the slide before them, and stay selected.
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let ids = p.get("ids").filter(|v| !v.is_null());
|
||||
let from = match ids {
|
||||
None => opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide),
|
||||
Some(_) => 0,
|
||||
};
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
if from >= doc.slides.len() {
|
||||
return Err(bad("slide.move", format!("no slide {from}")));
|
||||
}
|
||||
let sl = doc.slides.remove(from);
|
||||
let to = to.min(doc.slides.len());
|
||||
let id = sl.id;
|
||||
doc.slides.insert(to, sl);
|
||||
// Keep sections consistent with order: move the id into the section of its new neighbour.
|
||||
if !doc.sections.is_empty() {
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| *x != id);
|
||||
let moving: HashSet<SlideId> = match ids {
|
||||
Some(v) => slide_ids_param(v, doc, "slide.move")?,
|
||||
None => {
|
||||
let id = doc.slides.get(from).map(|x| x.id).ok_or_else(|| bad("slide.move", format!("no slide {from}")))?;
|
||||
HashSet::from([id])
|
||||
}
|
||||
let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.push(id);
|
||||
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
};
|
||||
let (block, mut rest): (Vec<_>, Vec<_>) = std::mem::take(&mut doc.slides).into_iter().partition(|x| moving.contains(&x.id));
|
||||
let to = to.min(rest.len());
|
||||
let tail = rest.split_off(to);
|
||||
let moved: Vec<SlideId> = block.iter().map(|x| x.id).collect();
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
doc.slides = rest;
|
||||
reassign_sections(doc, &moved, to);
|
||||
sel.slide = to;
|
||||
sel.slides = vec![id];
|
||||
Ok(json!({"index": to}))
|
||||
sel.slides = moved;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"index": to, "moved": moving.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The slides an `ids` param names: a non-empty list of ids of slides in `doc` (repeats fold).
|
||||
/// The first id that is not a slide of `doc` is an error, so the set never outgrows the deck.
|
||||
fn slide_ids_param(v: &Value, doc: &Presentation, cmd: &str) -> Result<HashSet<SlideId>> {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, "`ids` must be a list of slide ids"))?;
|
||||
let known: HashSet<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
let mut out = HashSet::new();
|
||||
for x in a {
|
||||
let id = x.as_u64().and_then(|n| u32::try_from(n).ok()).map(SlideId).filter(|id| known.contains(id));
|
||||
out.insert(id.ok_or_else(|| bad(cmd, format!("no slide with id {x}")))?);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, "`ids` is empty"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Keeps sections in step after `moved` landed as a run at `at`: the run joins the section of
|
||||
/// the slide just before it (the first section when it opens the deck), in document order.
|
||||
fn reassign_sections(doc: &mut Presentation, moved: &[SlideId], at: usize) {
|
||||
if doc.sections.is_empty() {
|
||||
return;
|
||||
}
|
||||
let moving: HashSet<SlideId> = moved.iter().copied().collect();
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| !moving.contains(x));
|
||||
}
|
||||
let neighbour = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: HashMap<SlideId, usize> = doc.slides.iter().enumerate().map(|(i, x)| (x.id, i)).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.extend_from_slice(moved);
|
||||
sec.slides.sort_by_key(|x| order.get(x).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn go_to(s: &mut Session, i: usize) -> Result<Value> {
|
||||
s.select(|doc, sel| {
|
||||
let n = doc.slides.len();
|
||||
@@ -357,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
s.select(|doc, sel| {
|
||||
if add {
|
||||
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
|
||||
for i in &ids {
|
||||
if !sel.slides.contains(i) {
|
||||
if have.insert(*i) {
|
||||
sel.slides.push(*i);
|
||||
}
|
||||
}
|
||||
@@ -710,6 +762,150 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A deck of `n` slides and its slide ids, in order.
|
||||
fn deck(n: usize) -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < n {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
let ids = order(&s);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> Vec<u32> {
|
||||
s.doc().expect("doc").doc.slides.iter().map(|x| x.id.0).collect()
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> (usize, Vec<u32>) {
|
||||
let sel = &s.doc().expect("doc").selection;
|
||||
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_slide_prunes_every_custom_show_in_one_undo_step() {
|
||||
let (mut s, ids) = deck(3);
|
||||
let shows = vec![
|
||||
deckcraft_model::CustomShow {
|
||||
name: "First".into(),
|
||||
slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])],
|
||||
},
|
||||
deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] },
|
||||
deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] },
|
||||
];
|
||||
s.edit(|doc, _| {
|
||||
doc.custom_shows = shows;
|
||||
Ok(())
|
||||
})
|
||||
.expect("set custom shows");
|
||||
|
||||
s.execute("slide.delete", &json!({"index": 1})).expect("delete slide");
|
||||
let shows = &s.doc().expect("doc").doc.custom_shows;
|
||||
assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
|
||||
assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
|
||||
assert!(shows[2].slides.is_empty());
|
||||
|
||||
s.execute("edit.undo", &json!({})).expect("undo deletion");
|
||||
assert_eq!(order(&s), ids);
|
||||
assert_eq!(
|
||||
s.doc().expect("doc").doc.custom_shows[0].slides,
|
||||
[deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])]
|
||||
);
|
||||
assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]);
|
||||
|
||||
s.execute("edit.redo", &json!({})).expect("redo deletion");
|
||||
let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck");
|
||||
let reopened = deckcraft_format::load(&bytes).expect("reopen deck");
|
||||
assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
|
||||
assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
|
||||
assert!(reopened.custom_shows[2].slides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_from_to_moves_one_slide() {
|
||||
let (mut s, ids) = deck(4);
|
||||
s.execute("slide.move", &json!({"from": 3, "to": 0})).expect("move");
|
||||
assert_eq!(order(&s), [ids[3], ids[0], ids[1], ids[2]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_ids_lands_them_as_one_run_in_deck_order() {
|
||||
let (mut s, ids) = deck(6);
|
||||
// Listed backwards: they still land in the order they had.
|
||||
s.execute("slide.move", &json!({"ids": [ids[4], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(order(&s), [ids[1], ids[4], ids[0], ids[2], ids[3], ids[5]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[1], ids[4]]), "the moved slides stay selected");
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[2]], "to": 2})).expect("to the middle");
|
||||
assert_eq!(order(&s), [ids[1], ids[3], ids[0], ids[2], ids[4], ids[5]]);
|
||||
assert_eq!(selected(&s), (2, vec![ids[0], ids[2]]));
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[1], ids[3]], "to": 1000})).expect("past the end clamps");
|
||||
assert_eq!(order(&s), [ids[0], ids[2], ids[4], ids[5], ids[1], ids[3]]);
|
||||
assert_eq!(selected(&s), (4, vec![ids[1], ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_several_slides_is_one_undo_step() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("slide.selectSlides", &json!({"ids": [ids[0], ids[3]]})).expect("select");
|
||||
let before = selected(&s);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[3]], "to": 3})).expect("move");
|
||||
let after = order(&s);
|
||||
assert_eq!(after, [ids[1], ids[2], ids[4], ids[0], ids[3]]);
|
||||
s.execute("edit.undo", &json!({})).expect("undo");
|
||||
assert_eq!(order(&s), ids);
|
||||
assert_eq!(selected(&s), before);
|
||||
s.execute("edit.redo", &json!({})).expect("redo");
|
||||
assert_eq!(order(&s), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_slides_join_the_section_before_them() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("section.add", &json!({"name": "A", "at": 0})).expect("A");
|
||||
s.execute("section.add", &json!({"name": "B", "at": 3})).expect("B");
|
||||
// A = 0,1,2; B = 3,4. Move 0 and 1 to the end: B gets them, A keeps only slide 2.
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 3})).expect("move");
|
||||
let sections =
|
||||
|s: &Session| -> Vec<Vec<u32>> { s.doc().expect("doc").doc.sections.iter().map(|x| x.slides.iter().map(|id| id.0).collect()).collect() };
|
||||
assert_eq!(sections(&s), [vec![ids[2]], vec![ids[3], ids[4], ids[0], ids[1]]]);
|
||||
// Move the whole of A away: A stays, empty; the run at the front joins the first section.
|
||||
s.execute("slide.move", &json!({"ids": [ids[2]], "to": 4})).expect("empty A");
|
||||
assert_eq!(sections(&s), [vec![], vec![ids[3], ids[4], ids[0], ids[1], ids[2]]]);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(sections(&s), [vec![ids[0], ids[1]], vec![ids[3], ids[4], ids[2]]]);
|
||||
let all: Vec<u32> = sections(&s).concat();
|
||||
assert_eq!(all, order(&s), "sections follow slide order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_ids_are_rejected_and_change_nothing() {
|
||||
let (mut s, ids) = deck(3);
|
||||
let lots_of_unknown: Vec<u64> = (1_000_000..1_100_000).collect();
|
||||
for p in [
|
||||
json!({"ids": [], "to": 0}),
|
||||
json!({"ids": [ids[0], 999_999], "to": 0}),
|
||||
json!({"ids": [-1], "to": 0}),
|
||||
json!({"ids": ["1"], "to": 0}),
|
||||
json!({"ids": [1.5], "to": 0}),
|
||||
json!({"ids": [u64::MAX], "to": 0}),
|
||||
json!({"ids": ids[0], "to": 0}),
|
||||
json!({"ids": lots_of_unknown, "to": 0}),
|
||||
json!({"ids": [ids[1]]}),
|
||||
] {
|
||||
assert!(s.execute("slide.move", &p).is_err(), "slide.move must reject {p}");
|
||||
assert_eq!(order(&s), ids, "{p}");
|
||||
}
|
||||
// Repeats fold into one.
|
||||
let repeated = vec![ids[2]; 100_000];
|
||||
s.execute("slide.move", &json!({"ids": repeated, "to": 0})).expect("repeats");
|
||||
assert_eq!(order(&s), [ids[2], ids[0], ids[1]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[2]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -99,6 +99,7 @@ pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
}));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
|
||||
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align Cell Text",
|
||||
["Table Layout", "Alignment"],
|
||||
None,
|
||||
"{anchor: top|middle|bottom, cells?, id?}",
|
||||
"{anchor?: top|middle|bottom (vertical; top when neither anchor nor align is given), align?: left|center|right|justify (horizontal, applied to every paragraph of the cells), cells?, id?}",
|
||||
has_selection,
|
||||
cell_align
|
||||
),
|
||||
@@ -253,9 +253,10 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
match (idx(0), idx(1)) {
|
||||
(Some(r), Some(c)) => Ok(Some((r, c))),
|
||||
// Exactly [row, col]: an entry with more is as malformed as one with less.
|
||||
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
|
||||
match v.as_array().map(Vec::as_slice) {
|
||||
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
|
||||
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
|
||||
}
|
||||
}
|
||||
@@ -389,33 +390,54 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// At most this many `cells` in one table cell command.
|
||||
const MAX_CELLS: usize = 100_000;
|
||||
|
||||
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
|
||||
/// caret, else every cell.
|
||||
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
|
||||
let cells: Option<Vec<(usize, usize)>> = p
|
||||
.get("cells")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
|
||||
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
|
||||
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
|
||||
// dropped or read as absent (which would act on the selection or the whole table).
|
||||
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
|
||||
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
|
||||
}
|
||||
Some(Value::Array(a)) => Some(
|
||||
a.iter()
|
||||
.map(|c| {
|
||||
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
|
||||
let cmd = cmd.to_string();
|
||||
Ok(move |t: &Table| match (&cells, sel) {
|
||||
(Some(c), _) => c.clone(),
|
||||
// Each listed cell must be in the table, so a command never reports success for a cell
|
||||
// it could not edit.
|
||||
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
|
||||
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
|
||||
None => Ok(c.clone()),
|
||||
},
|
||||
(None, Some((a, b, range))) => {
|
||||
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
|
||||
// The selection can name cells outside the table; only walk the ones that exist.
|
||||
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
|
||||
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
|
||||
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
|
||||
}
|
||||
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
|
||||
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellFill")?;
|
||||
with_table(s, p, "table.cellFill", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.fill.clone_from(&fill);
|
||||
}
|
||||
@@ -447,9 +469,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// "none" clears to no line, so the table style's own lines don't show through either.
|
||||
let line = if which == "none" { Line::none() } else { line };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.borders")?;
|
||||
with_table(s, p, "table.borders", move |t| {
|
||||
let list = targets(t);
|
||||
let list = targets(t)?;
|
||||
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
|
||||
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
|
||||
else {
|
||||
@@ -463,9 +485,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let n = match side {
|
||||
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
|
||||
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
|
||||
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
|
||||
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
|
||||
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
|
||||
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
|
||||
};
|
||||
if let Some((r, c, side)) = n
|
||||
&& let Some(cell) = t.cell_mut(r, c)
|
||||
@@ -523,9 +545,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
|
||||
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellMargins")?;
|
||||
with_table(s, p, "table.cellMargins", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.margins = Some(m);
|
||||
}
|
||||
@@ -535,18 +557,43 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::Anchor;
|
||||
let a = match str_param(p, "anchor").unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
// A value that is not a string is refused, not taken as absent.
|
||||
for k in ["align", "anchor"] {
|
||||
if p.get(k).is_some_and(|v| !v.is_null() && !v.is_string()) {
|
||||
return Err(bad("table.cellAlign", format!("`{k}` is a name such as {}", if k == "align" { "left or center" } else { "top or middle" })));
|
||||
}
|
||||
}
|
||||
// Without `align`, the vertical anchor is the whole command (top when not given).
|
||||
let align = match str_param(p, "align") {
|
||||
None => None,
|
||||
Some("left" | "l") => Some(Align::Left),
|
||||
Some("center" | "centre" | "ctr") => Some(Align::Center),
|
||||
Some("right" | "r") => Some(Align::Right),
|
||||
Some("justify" | "just") => Some(Align::Justify),
|
||||
Some(o) => return Err(bad("table.cellAlign", format!("unknown align {o:?}; one of left, center, right, justify"))),
|
||||
};
|
||||
let targets = target_cells(s, p)?;
|
||||
let anchor = match (str_param(p, "anchor"), align) {
|
||||
(None, Some(_)) => None,
|
||||
(a, _) => Some(match a.unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
}),
|
||||
};
|
||||
let targets = target_cells(s, p, "table.cellAlign")?;
|
||||
with_table(s, p, "table.cellAlign", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.anchor = Some(a);
|
||||
if anchor.is_some() {
|
||||
cell.anchor = anchor;
|
||||
}
|
||||
if let Some(a) = align {
|
||||
for para in &mut cell.text.paragraphs {
|
||||
para.props.align = Some(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -30,7 +30,7 @@ use editing::{edit, exit};
|
||||
pub(crate) use editing::{insert_field, insert_text, paste_body};
|
||||
pub use formatting::format_range;
|
||||
use formatting::{fonts_used, replace_font};
|
||||
pub(crate) use formatting::{format_body, format_para, format_run, link_text};
|
||||
pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text};
|
||||
pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines};
|
||||
use ime::{commit, compose};
|
||||
pub(crate) use layout::ctx_for;
|
||||
|
||||
@@ -142,7 +142,7 @@ pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typi
|
||||
|
||||
/// After a space or punctuation, correct the word just typed.
|
||||
pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
if !s.prefs.autocorrect {
|
||||
if !s.prefs.autocorrect && !(typing && s.prefs.ac_lists) {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc()?;
|
||||
@@ -160,6 +160,9 @@ pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if !s.prefs.autocorrect {
|
||||
return Ok(());
|
||||
}
|
||||
let mut word_end = end - 1;
|
||||
let mut start = word_end;
|
||||
while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
|
||||
|
||||
@@ -8,22 +8,34 @@ use super::{
|
||||
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour),
|
||||
/// or sets the formatting for what is typed next at a word boundary.
|
||||
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
|
||||
format_run_levels(s, f, &|r, _| f(r))
|
||||
}
|
||||
|
||||
/// Like [`format_run`], with a separate change for the list-style levels of a layout or master
|
||||
/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at
|
||||
/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value.
|
||||
pub(crate) fn format_run_levels(
|
||||
s: &mut Session,
|
||||
f: &(dyn Fn(&mut RunProps) + Send + Sync),
|
||||
lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync),
|
||||
) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = format_range(st, &t);
|
||||
let collapsed = a == b;
|
||||
// Only the levels of the paragraphs the selected range touches.
|
||||
let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) };
|
||||
edit_text(s, |body, _| {
|
||||
if collapsed {
|
||||
// Next typed text: put the props on an empty paragraph's end mark or keep pending.
|
||||
if levels.is_empty() {
|
||||
ed::format(body, a, b, f);
|
||||
} else {
|
||||
ed::format(body, a, b, f);
|
||||
format_by_level(body, a, b, lf, &levels);
|
||||
}
|
||||
for (l, eff) in &levels {
|
||||
let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default();
|
||||
lf(&mut lvl.run, eff);
|
||||
body.list_style.set(*l, lvl);
|
||||
}
|
||||
})?;
|
||||
if collapsed {
|
||||
// Remember as pending typing props by inserting nothing: handled via end mark for
|
||||
// empty paragraphs; mid-paragraph carets keep surrounding formatting.
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
@@ -31,6 +43,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
// Effective run props of every level of the selected placeholders, resolved before editing.
|
||||
let effective: Vec<(ShapeId, Vec<RunProps>)> = if editing_master(st) {
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
|
||||
ids.iter()
|
||||
.filter_map(|id| {
|
||||
let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?;
|
||||
let ctx = ctx.as_ref()?;
|
||||
Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect()))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
match &mut sh.kind {
|
||||
@@ -47,7 +72,22 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
}
|
||||
_ => {
|
||||
let body = sh.text.get_or_insert_with(TextBody::default);
|
||||
ed::format_all(body, f);
|
||||
let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e);
|
||||
match eff {
|
||||
Some(eff) => {
|
||||
let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect();
|
||||
format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels);
|
||||
}
|
||||
None => ed::format_all(body, f),
|
||||
}
|
||||
// The whole shape is selected: every level of its list style.
|
||||
if let Some(eff) = eff {
|
||||
for (l, eff) in (0..LIST_LEVELS).zip(eff) {
|
||||
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
|
||||
lf(&mut lvl.run, eff);
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -58,6 +98,55 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so
|
||||
/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared
|
||||
/// value. `a`/`b` are clamped to the body.
|
||||
fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) {
|
||||
let Some(last) = body.paragraphs.len().checked_sub(1) else { return };
|
||||
let (first, end) = (a.0.min(last), b.0.min(last));
|
||||
for pi in first..=end {
|
||||
let p = &body.paragraphs[pi];
|
||||
let from = if pi == first { a.1.min(p.char_len()) } else { 0 };
|
||||
let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() };
|
||||
if from == to && p.char_len() > 0 {
|
||||
continue;
|
||||
}
|
||||
let lvl = p.level.min(LIST_LEVELS - 1);
|
||||
let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue };
|
||||
ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff));
|
||||
}
|
||||
}
|
||||
|
||||
/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it.
|
||||
const LIST_LEVELS: u8 = 9;
|
||||
|
||||
/// Formatting a placeholder on a layout or master (Slide Master view) also sets its list style,
|
||||
/// which is what the placeholders on slides inherit; its own runs may be few or none.
|
||||
fn editing_master(st: &DocState) -> bool {
|
||||
matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. })
|
||||
}
|
||||
|
||||
/// The distinct list-style levels of the paragraphs `range` touches in a layout or master
|
||||
/// placeholder, with each level's effective run props. Empty anywhere else.
|
||||
fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> {
|
||||
if !editing_master(st) || t.notes || t.cell.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (Some(sh), Some(body), Some(ctx)) =
|
||||
(st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut levels: Vec<u8> =
|
||||
body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect();
|
||||
if body.paragraphs.is_empty() {
|
||||
levels.push(0);
|
||||
}
|
||||
levels.sort_unstable();
|
||||
levels.dedup();
|
||||
levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect()
|
||||
}
|
||||
|
||||
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
|
||||
/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink
|
||||
/// edits this same span, so the dialog reads it too.
|
||||
@@ -110,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = t.ordered();
|
||||
// Only the list-style levels of the paragraphs touched, on a layout or master placeholder.
|
||||
let levels: Vec<u8> = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect();
|
||||
edit_text(s, |body, _| {
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Default::default());
|
||||
@@ -120,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
f(p);
|
||||
}
|
||||
}
|
||||
for l in levels {
|
||||
format_list_level(body, l, f);
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
@@ -128,8 +222,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
let on_master = editing_master(st);
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
let inherited = on_master && sh.ph.is_some();
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
let range = crate::cmd::table::selected_range(cells, id, t);
|
||||
@@ -147,6 +243,12 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
body.paragraphs.push(Default::default());
|
||||
}
|
||||
body.paragraphs.iter_mut().for_each(f);
|
||||
// The whole shape is selected: every level of its list style.
|
||||
if inherited {
|
||||
for l in 0..LIST_LEVELS {
|
||||
format_list_level(body, l, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -157,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Apply a paragraph change to one level of a body's list style.
|
||||
fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) {
|
||||
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
|
||||
let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() };
|
||||
f(&mut p);
|
||||
// A level change (indent/outdent) means nothing for a list-style level.
|
||||
if p.level == l {
|
||||
lvl.para = p.props;
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply body (text box) changes to the edited or selected shapes.
|
||||
pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
|
||||
use deckcraft_model::anim::TRANSITIONS;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
|
||||
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, opt_usize_param, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -44,9 +44,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn slide_indices(s: &Session, p: &Value) -> Result<Vec<usize>> {
|
||||
fn slide_indices(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let st = s.doc()?;
|
||||
if let Some(i) = usize_param(p, "index") {
|
||||
if let Some(i) = opt_usize_param(p, "index", cmd)? {
|
||||
return Ok(vec![i]);
|
||||
}
|
||||
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
|
||||
@@ -59,7 +59,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
TRANSITIONS.iter().find(|t| t.0.eq_ignore_ascii_case(kind)).ok_or_else(|| bad("transition.set", format!("unknown transition `{kind}`")))?;
|
||||
let option = str_param(p, "option").map(String::from).or_else(|| info.4.first().map(|o| o.to_string())).unwrap_or_default();
|
||||
let duration = p.get("duration").and_then(Value::as_u64).map(|v| v.min(59_000) as u32);
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.set")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -83,7 +83,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let option = str_param(p, "option").ok_or_else(|| bad("transition.options", "missing `option`"))?.to_string();
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.options")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i)
|
||||
@@ -98,7 +98,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.timing")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -127,7 +127,7 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it.
|
||||
fn sound(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.sound")?;
|
||||
let looping = bool_param(p, "loop");
|
||||
let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| {
|
||||
s.edit(|doc, _| {
|
||||
@@ -215,3 +215,29 @@ fn apply_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(TRANSITIONS.iter().map(|t| json!({"id": t.0, "label": t.1, "category": t.2, "duration": t.3, "options": t.4})).collect()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
for (cmd, p) in [
|
||||
("transition.set", json!({"kind": "fade", "index": bad})),
|
||||
("transition.options", json!({"option": "x", "index": bad})),
|
||||
("transition.timing", json!({"duration": 500, "index": bad})),
|
||||
("transition.sound", json!({"none": true, "index": bad})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||
assert!(s.doc().expect("doc").doc.slides.iter().all(|sl| sl.transition.is_none()), "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+331
-36
@@ -7,6 +7,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::{MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -92,6 +93,12 @@ pub struct Change {
|
||||
pub kind: ChangeKind,
|
||||
/// Index in the open deck (for inserted: where it would go).
|
||||
pub slide: usize,
|
||||
/// The editor's number of that slide (`slide` + 1).
|
||||
pub number: usize,
|
||||
/// Which side has it alone: `deck` (the open presentation; accepting deletes it) or `file`
|
||||
/// (the compared file; accepting adds it). Absent when both have it and differ.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub only_in: Option<&'static str>,
|
||||
/// Index in the revised deck (none for deleted).
|
||||
pub revised: Option<usize>,
|
||||
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
|
||||
@@ -114,23 +121,46 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
|
||||
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
|
||||
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
|
||||
let mut out = vec![];
|
||||
let mut media = Media::new(cur, rev);
|
||||
for (i, j) in &pairs {
|
||||
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
|
||||
let details = slide_details(cur, a, rev, b);
|
||||
let details = slide_details(cur, a, rev, b, &mut media);
|
||||
if !details.is_empty() {
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details });
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
|
||||
}
|
||||
}
|
||||
for (i, s) in cur.slides.iter().enumerate() {
|
||||
if !cur_matched.contains(&i) {
|
||||
out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] });
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Deleted,
|
||||
slide: i,
|
||||
number: i.saturating_add(1),
|
||||
only_in: Some("deck"),
|
||||
revised: None,
|
||||
details: vec![slide_title(s)],
|
||||
});
|
||||
}
|
||||
}
|
||||
// Each revised slide's open match (the first pair naming it).
|
||||
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
|
||||
for p in &pairs {
|
||||
rev_to_cur.entry(p.1).or_insert(p.0);
|
||||
}
|
||||
// After the open slide that matches the nearest matched revised slide before it.
|
||||
let mut at = 0;
|
||||
for (j, s) in rev.slides.iter().enumerate() {
|
||||
if let Some(i) = rev_to_cur.get(&j) {
|
||||
at = i + 1;
|
||||
}
|
||||
if !rev_matched.contains(&j) {
|
||||
// After the open slide that matches the revised slide before it.
|
||||
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
|
||||
out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] });
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Inserted,
|
||||
slide: at,
|
||||
number: at.saturating_add(1),
|
||||
only_in: Some("file"),
|
||||
revised: Some(j),
|
||||
details: vec![slide_title(s)],
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
|
||||
@@ -153,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
|
||||
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
|
||||
}
|
||||
|
||||
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
|
||||
p.media(id).map(|m| m.data.as_slice())
|
||||
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
|
||||
/// show one picture many times, and its bytes are compared once per pair.
|
||||
struct Media<'a> {
|
||||
cur: HashMap<MediaId, &'a [u8]>,
|
||||
rev: HashMap<MediaId, &'a [u8]>,
|
||||
same: HashMap<(MediaId, MediaId), bool>,
|
||||
}
|
||||
|
||||
impl<'a> Media<'a> {
|
||||
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
|
||||
// The first item with an id wins, as in `Presentation::media`.
|
||||
let index = |p: &'a Presentation| {
|
||||
let mut m = HashMap::new();
|
||||
for x in &p.media {
|
||||
m.entry(x.id).or_insert(x.data.as_slice());
|
||||
}
|
||||
m
|
||||
};
|
||||
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
|
||||
}
|
||||
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
|
||||
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
|
||||
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
|
||||
match (a, b) {
|
||||
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
|
||||
_ => ba.is_some() || bb.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape without its ids (shape and media), for comparing across decks.
|
||||
@@ -172,7 +228,18 @@ fn plain_shape(s: &Shape) -> Shape {
|
||||
c
|
||||
}
|
||||
|
||||
fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec<String>) {
|
||||
/// The box a shape is drawn with: its own, else the one it inherits from its layout or master
|
||||
/// placeholder (`ctx`).
|
||||
fn drawn_box(ctx: Option<&Ctx>, s: &Shape) -> Option<deckcraft_model::Xfrm> {
|
||||
s.xfrm.or_else(|| ctx.map(|c| deckcraft_model::resolve::xfrm(c, s)))
|
||||
}
|
||||
|
||||
/// The rotation and flips a shape is drawn with (none without a box).
|
||||
fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
|
||||
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
|
||||
}
|
||||
|
||||
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
|
||||
let label = shape_label(b);
|
||||
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
|
||||
let mut what = vec![];
|
||||
@@ -181,61 +248,92 @@ fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out
|
||||
} else if a.text != b.text {
|
||||
what.push("text formatting");
|
||||
}
|
||||
match (&a.xfrm, &b.xfrm) {
|
||||
// Boxes as drawn: a stored box equal to the inherited one is no change.
|
||||
match (&drawn_box(ca, a), &drawn_box(cb, b)) {
|
||||
(Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"),
|
||||
(Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"),
|
||||
(x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"),
|
||||
_ => {}
|
||||
}
|
||||
// Rotation and flips are reported alongside a move or resize, not hidden behind it. No stored
|
||||
// box means the inherited one (the layout's or master's), as the shape is drawn.
|
||||
let (ta, tb) = (turn(ca, a), turn(cb, b));
|
||||
if ta.0 != tb.0 {
|
||||
what.push("rotated");
|
||||
}
|
||||
if (ta.1, ta.2) != (tb.1, tb.2) {
|
||||
what.push("flipped");
|
||||
}
|
||||
if a.geom != b.geom {
|
||||
what.push("shape");
|
||||
}
|
||||
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
|
||||
what.push("formatting");
|
||||
}
|
||||
let pic = |p: &Presentation, s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
|
||||
let pic = |s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => Some(fill.media),
|
||||
_ => None,
|
||||
};
|
||||
if pic(cp, a) != pic(rp, b) {
|
||||
if media.differ(pic(a), pic(b)) {
|
||||
what.push("picture");
|
||||
}
|
||||
if what.is_empty() {
|
||||
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
|
||||
x.text = None;
|
||||
y.text = None;
|
||||
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
|
||||
x.kind = ShapeKind::Shape;
|
||||
y.kind = ShapeKind::Shape;
|
||||
}
|
||||
if x != y {
|
||||
what.push("properties");
|
||||
}
|
||||
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
|
||||
// beside them: the fields they compared are left out of this comparison.
|
||||
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
|
||||
for z in [&mut x, &mut y] {
|
||||
z.text = None;
|
||||
z.xfrm = None;
|
||||
z.geom = Default::default();
|
||||
z.fill = None;
|
||||
z.line = None;
|
||||
z.style = None;
|
||||
z.effects = Default::default();
|
||||
}
|
||||
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
|
||||
x.kind = ShapeKind::Shape;
|
||||
y.kind = ShapeKind::Shape;
|
||||
}
|
||||
if x != y {
|
||||
what.push("properties");
|
||||
}
|
||||
if !what.is_empty() {
|
||||
out.push(format!("{label}: {}", what.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut used: HashSet<usize> = HashSet::new();
|
||||
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
|
||||
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
|
||||
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
|
||||
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
|
||||
for (k, x) in b.shapes.iter().enumerate() {
|
||||
by_id.entry(x.id).or_insert(k);
|
||||
if !x.name.is_empty() {
|
||||
by_name.entry(x.name.as_str()).or_default().0.push(k);
|
||||
}
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
let k = b
|
||||
.shapes
|
||||
.iter()
|
||||
.position(|x| x.id == sa.id)
|
||||
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
|
||||
let k = by_id.get(&sa.id).copied().or_else(|| {
|
||||
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
|
||||
while ks.get(*next).is_some_and(|k| used.contains(k)) {
|
||||
*next += 1;
|
||||
}
|
||||
ks.get(*next).copied()
|
||||
});
|
||||
match k {
|
||||
Some(k) if !used.contains(&k) => {
|
||||
used.insert(k);
|
||||
if let Some(sb) = b.shapes.get(k) {
|
||||
shape_details(cp, sa, rp, sb, &mut out);
|
||||
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
|
||||
}
|
||||
}
|
||||
_ => out.push(format!("{} deleted", shape_label(sa))),
|
||||
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
|
||||
}
|
||||
}
|
||||
for (k, sb) in b.shapes.iter().enumerate() {
|
||||
if !used.contains(&k) {
|
||||
out.push(format!("{} inserted", shape_label(sb)));
|
||||
out.push(format!("{} inserted (only in the file)", shape_label(sb)));
|
||||
}
|
||||
}
|
||||
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
@@ -282,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
let mut sl = slide.clone();
|
||||
// Media.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
|
||||
// looked up once per reference instead of searched.
|
||||
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in rev.media.iter().enumerate() {
|
||||
rev_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in doc.media.iter().enumerate() {
|
||||
doc_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
for id in crate::media_refs::slide_media(&sl) {
|
||||
let Some(m) = rev.media(id) else { continue };
|
||||
match doc.media(id) {
|
||||
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
|
||||
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
|
||||
Some(x) if x.data == m.data => {}
|
||||
None => {
|
||||
// Keeps the revision's id, so later ids must start past it.
|
||||
doc_at.insert(id, doc.media.len());
|
||||
doc.media.push(m.clone());
|
||||
doc.next_id = doc.next_id.max(id.0);
|
||||
}
|
||||
Some(_) => {
|
||||
let before = doc.media.len();
|
||||
let new = doc
|
||||
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
|
||||
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
|
||||
if doc.media.len() > before {
|
||||
doc_at.entry(new).or_insert(before);
|
||||
}
|
||||
media_map.insert(id, new);
|
||||
}
|
||||
}
|
||||
@@ -354,6 +467,25 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id));
|
||||
sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default();
|
||||
}
|
||||
// A placeholder without a box of its own is drawn with its layout's; where this deck's layout
|
||||
// draws it otherwise (placed or turned differently), the file's box is stored on it, so the
|
||||
// accepted slide looks as the file does and compares equal to it.
|
||||
if let (Some(file), Some(here)) = (Ctx::for_slide(rev, slide), Ctx::for_slide(doc, &sl)) {
|
||||
let boxes: Vec<Option<deckcraft_model::Xfrm>> = slide
|
||||
.shapes
|
||||
.iter()
|
||||
.zip(&sl.shapes)
|
||||
.map(|(f, h)| {
|
||||
let want = deckcraft_model::resolve::xfrm(&file, f);
|
||||
(h.xfrm.is_none() && deckcraft_model::resolve::xfrm(&here, h) != want).then_some(want)
|
||||
})
|
||||
.collect();
|
||||
for (h, b) in sl.shapes.iter_mut().zip(boxes) {
|
||||
if b.is_some() {
|
||||
h.xfrm = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
match replacing {
|
||||
Some(id) => sl.id = id,
|
||||
None => {
|
||||
@@ -448,6 +580,39 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
|
||||
|
||||
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
|
||||
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
|
||||
let mut cur = defaults::new_presentation(None);
|
||||
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
|
||||
let mut rev = cur.clone();
|
||||
let pic = |id: u32| Shape {
|
||||
id: ShapeId(id),
|
||||
name: "Box".into(),
|
||||
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
|
||||
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
|
||||
let mut base = (*rev.slides[0]).clone();
|
||||
base.shapes.clear();
|
||||
for k in 0..3000 {
|
||||
let mut s = base.clone();
|
||||
s.id = SlideId(300_000 + k);
|
||||
rev.slides.push(Arc::new(s));
|
||||
}
|
||||
let changes = super::diff(&cur, &rev, false);
|
||||
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
|
||||
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
|
||||
}
|
||||
|
||||
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
|
||||
/// transition sound. `tag` starts the bytes of each.
|
||||
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
|
||||
@@ -494,6 +659,136 @@ mod tests {
|
||||
assert_eq!(bytes(back.media), b"open background");
|
||||
}
|
||||
|
||||
/// NN6: a slide reset drops the placeholders' stored boxes; comparing it with the copy from
|
||||
/// before the reset must not call that a rotation (no stored box is rotation 0).
|
||||
#[test]
|
||||
fn a_reset_slide_is_not_rotated_against_its_earlier_copy() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "b"})).unwrap();
|
||||
let ids: Vec<u32> = s.doc().unwrap().doc.slides[1].shapes.iter().map(|x| x.id.0).collect();
|
||||
for id in &ids {
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": -10, "dy": 0})).unwrap();
|
||||
}
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
|
||||
assert!(!v.to_string().contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// A shape both moved and rotated reports both, not only the move.
|
||||
#[test]
|
||||
fn a_moved_and_rotated_shape_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": 40, "dy": 0})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": 30})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("moved") && v.contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// A property no other check covers (here the alt text) is reported beside a flip.
|
||||
#[test]
|
||||
fn a_flipped_shape_with_new_alt_text_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let sl = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
sl.shapes.iter_mut().find(|x| x.id.0 as u64 == id).unwrap().descr = "A red box".into();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("flipped") && v.contains("properties"), "{v}");
|
||||
}
|
||||
|
||||
/// A flip and a change of shape (rectangle to ellipse) are both reported.
|
||||
#[test]
|
||||
fn a_flipped_shape_that_changed_shape_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("shape.change", &json!({"ids": [id], "preset": "ellipse"})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("flipped") && v.contains("shape\""), "{v}");
|
||||
}
|
||||
|
||||
/// A placeholder with no box of its own is drawn with its layout's rotation; setting it to 0
|
||||
/// on the slide is a rotation change, and compare says so.
|
||||
#[test]
|
||||
fn an_inherited_rotation_counts_when_compared() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let layout = doc.slides[1].layout;
|
||||
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
|
||||
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
|
||||
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
let mut x = ph.xfrm.unwrap();
|
||||
x.rot = 30.0;
|
||||
ph.xfrm = Some(x);
|
||||
assert!(doc.slides[1].shapes.iter().find(|x| title(x)).unwrap().xfrm.is_none(), "the title inherits its box");
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let sl = std::sync::Arc::make_mut(&mut doc.slides[1]);
|
||||
let t = sl.shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
x.rot = 0.0;
|
||||
t.xfrm = Some(x);
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
|
||||
assert!(v.to_string().contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// The file's placeholder inherits an unturned box from its layout while this deck's layout
|
||||
/// turns it: accepting stores the file's box on the placeholder, so the slide is drawn as in the
|
||||
/// file and nothing is left to accept.
|
||||
#[test]
|
||||
fn accepting_an_inherited_box_takes_the_files_box() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let file = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let layout = doc.slides[1].layout;
|
||||
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
|
||||
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
let mut x = ph.xfrm.unwrap();
|
||||
x.rot = 30.0;
|
||||
ph.xfrm = Some(x);
|
||||
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
|
||||
assert!(v.to_string().contains("rotated"), "{v}");
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.doc.slides[1].shapes.iter().find(|x| title(x)).unwrap();
|
||||
assert_eq!(t.xfrm.map(|x| x.rot), Some(0.0), "the file's box is stored: {:?}", t.xfrm);
|
||||
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
|
||||
assert!(v["changes"].as_array().is_some_and(|c| c.is_empty()), "nothing left to accept: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_change_says_which_side_it_is_on() {
|
||||
let (mut s, rev, _) = decks();
|
||||
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
for c in v["changes"].as_array().unwrap() {
|
||||
assert_eq!(c["number"].as_u64().unwrap(), c["slide"].as_u64().unwrap() + 1, "{c}");
|
||||
match c["kind"].as_str().unwrap() {
|
||||
"deleted" => assert_eq!(c["onlyIn"], "deck", "{c}"),
|
||||
"inserted" => assert_eq!(c["onlyIn"], "file", "{c}"),
|
||||
_ => assert!(c.get("onlyIn").is_none(), "{c}"),
|
||||
}
|
||||
}
|
||||
// A shape only in the open deck reads as only in the deck.
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||||
let v = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert!(v.to_string().contains("deleted (only in the deck)"), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
|
||||
let (mut s, rev, n) = decks();
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
|
||||
//! connectors whenever the shapes they connect move, resize or rotate.
|
||||
|
||||
use deckcraft_geom::{Point, Xfrm};
|
||||
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
|
||||
use crate::Selection;
|
||||
use crate::cmd::xfrm_of;
|
||||
|
||||
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
|
||||
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
|
||||
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
|
||||
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
sites_in(doc, sel, s, Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
|
||||
/// slide, or the child space of the groups around it) onto the slide.
|
||||
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
|
||||
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
if local.is_empty() {
|
||||
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
|
||||
}
|
||||
let a = x.affine();
|
||||
let a = to_abs * x.affine();
|
||||
local.into_iter().map(|p| a * p).collect()
|
||||
}
|
||||
|
||||
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
|
||||
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
|
||||
/// and its sites are where the group draws them.
|
||||
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
|
||||
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for s in shapes {
|
||||
if s.id == id {
|
||||
return Some((s, to_abs));
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &s.kind {
|
||||
let x = xfrm_of(doc, sel, s);
|
||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
|
||||
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
|
||||
}
|
||||
|
||||
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
|
||||
/// no such shape.
|
||||
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
|
||||
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
|
||||
}
|
||||
|
||||
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
|
||||
pub fn nearest_site(
|
||||
doc: &Presentation,
|
||||
@@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
|
||||
let snapshot: Vec<Shape> = list.clone();
|
||||
let view = doc.clone();
|
||||
let site = |id: ShapeId, idx: u32| -> Option<Point> {
|
||||
let s = snapshot.iter().find(|s| s.id == id)?;
|
||||
let v = sites(&view, sel, s);
|
||||
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
|
||||
let v = sites_of(&view, sel, &snapshot, id)?;
|
||||
v.get(idx as usize).or(v.first()).copied()
|
||||
};
|
||||
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
|
||||
|
||||
@@ -35,8 +35,8 @@ pub enum Access {
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
read: Vec<Root>,
|
||||
write: Vec<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -62,46 +62,53 @@ impl Root {
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// The read and write roots, which must be existing folders; `None` when both are empty
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
let read = read.iter().map(|p| Root::open(p, Access::Read)).collect::<Result<_, _>>()?;
|
||||
let write = write.iter().map(|p| Root::open(p, Access::Write)).collect::<Result<_, _>>()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
/// The read roots, absolute with their links followed.
|
||||
pub fn read_roots(&self) -> impl Iterator<Item = &Path> {
|
||||
self.read.iter().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
/// The write roots, absolute with their links followed.
|
||||
pub fn write_roots(&self) -> impl Iterator<Item = &Path> {
|
||||
self.write.iter().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
self.check_path(Path::new(path), access)
|
||||
}
|
||||
|
||||
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
|
||||
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
|
||||
let path_text = path.display();
|
||||
let roots = match access {
|
||||
Access::Read => &self.read,
|
||||
Access::Write => &self.write,
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
if roots.is_empty() {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
}
|
||||
let resolved = resolve(path)?;
|
||||
if roots.iter().any(|root| inside(&resolved, &root.resolved)) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
|
||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,17 +197,23 @@ pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
|
||||
pub fn check_write_path(path: &Path) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() {
|
||||
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
let shown = path.display();
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
@@ -211,11 +224,11 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
@@ -225,12 +238,12 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
||||
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +289,7 @@ mod tests {
|
||||
}
|
||||
std::fs::write(base.join("in/a.deckcraft"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
let roots = AutomationRoots::new(&[base.join("in")], &[base.join("out")]).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
@@ -300,6 +313,17 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_configured_root_grants_access() {
|
||||
let (base, _) = folders("multiple");
|
||||
let roots = AutomationRoots::new(&[base.join("in"), base.join("in2")], &[base.join("out"), base.join("outside")]).unwrap().unwrap();
|
||||
assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("out/new.deckcraft")), Access::Write), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("outside/new.deckcraft")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
@@ -336,18 +360,18 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
let e = AutomationRoots::new(&[base.join("nope")], &[]).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.deckcraft"))).unwrap_err();
|
||||
let e = AutomationRoots::new(&[], &[base.join("in/a.deckcraft")]).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
assert!(AutomationRoots::new(&[PathBuf::new()], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let read_only = AutomationRoots::new(&[base.join("in")], &[]).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.deckcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
@@ -384,7 +408,7 @@ mod tests {
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.deckcraft")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
let linked = AutomationRoots::new(&[base.join("in-link")], &[]).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||
//! draws in a stand-in; see `FontDb::document_face`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
@@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
/// font to load and are left for the round trip.
|
||||
pub fn install_embedded_fonts(p: &Presentation) {
|
||||
let db = FontDb::global();
|
||||
// Faces can name one media item many times; each is copied and enumerated once.
|
||||
let mut seen = HashSet::new();
|
||||
for font in &p.embedded_fonts {
|
||||
for id in font.faces().into_iter().flatten() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(item) = p.media(id) else { continue };
|
||||
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
|
||||
db.add_font(sfnt.to_vec());
|
||||
@@ -22,15 +29,13 @@ pub fn install_embedded_fonts(p: &Presentation) {
|
||||
}
|
||||
|
||||
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive). Names that
|
||||
/// start with a dot are macOS hidden system fonts (`.SF NS`); they never appear in a font list
|
||||
/// and can't be installed, so they aren't reported.
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
// used_fonts is already free of case-insensitive duplicates.
|
||||
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect();
|
||||
out.sort_by_key(|f| f.to_lowercase());
|
||||
out
|
||||
}
|
||||
@@ -38,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
pub fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) {
|
||||
out.push(f);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user