Compare commits
646 commits
fix/librar
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 43656a5e88 | |||
| 68461af990 | |||
| 8bc460025d | |||
| 3578c7b4e9 | |||
| cddcc9a29e | |||
| 0e844c0fc3 | |||
| 551af09dc7 | |||
| 4d6a999665 | |||
| f971d57bb9 | |||
| 7ab70948a0 | |||
| 13bbd4216e | |||
| fcd8e8dd2e | |||
| 67ac007706 | |||
| b4f2fb12ff | |||
| aa7f836493 | |||
| c2409bd037 | |||
| 42064acefa | |||
| 2e2b091653 | |||
|
|
c502d4a16f | ||
|
|
9d098e9778 | ||
|
|
02631f7b96 | ||
|
|
9436434599 | ||
|
|
f837e57969 | ||
|
|
ca71e47035 | ||
|
|
34352e3299 | ||
|
|
d505a488ac | ||
|
|
793011b78b | ||
|
|
5538683d78 | ||
|
|
d62af34e98 | ||
|
|
209f9fda52 | ||
|
|
29187a90df | ||
|
|
512267159a | ||
|
|
72fc608d8a | ||
|
|
3fe7d6bba2 | ||
|
|
2615143c6d | ||
|
|
0c3a4b625f | ||
|
|
fff0828d79 | ||
|
|
ec026195eb | ||
| 9d6bbf8112 | |||
| b449ef0ce3 | |||
| 39ef551489 | |||
| 8f26f1bd9a | |||
| a7ef0397e1 | |||
| cf1fe136d0 | |||
| 0818f15498 | |||
| 4473427515 | |||
| 9b47250388 | |||
| 8ea750f5df | |||
| a28dc43ed5 | |||
| 35fd9c0253 | |||
| 0ee0cb91ef | |||
| 9210b41589 | |||
| f2542bc929 | |||
| 0f6c715a30 | |||
| fdec2e307d | |||
| 92b460f503 | |||
| 500599a955 | |||
| 634f1842bd | |||
| 453103aee6 | |||
| 6f64b55824 | |||
| 303f12e0f9 | |||
| 342b56af35 | |||
| f54c49d2dc | |||
| 888ca65045 | |||
| b6f5b9b407 | |||
| 354731a363 | |||
|
|
1fcb927d26 | ||
|
|
6bc6478270 | ||
|
|
446a563647 | ||
|
|
71d8944a01 | ||
|
|
686b90294b | ||
|
|
fcf4c8bbe7 | ||
|
|
94b6710e2d | ||
|
|
6412b5c252 | ||
|
|
56d7479a35 | ||
|
|
aeecb6e32a | ||
|
|
0abef056e7 | ||
|
|
540d333758 | ||
|
|
e4e69973e5 | ||
|
|
c3b087020d | ||
|
|
c2a6c1557b | ||
|
|
b04882a310 | ||
| 60e5093c6b | |||
| 382f432693 | |||
|
|
e533566ae2 | ||
| c3e4306d9f | |||
| eeb0d9f65f | |||
| 7f0ca5922f | |||
| 469521d524 | |||
| 07840441b9 | |||
| 5774f61ac7 | |||
| 1fb790a569 | |||
| 11cb93aa51 | |||
| dbc67636b2 | |||
| 460b590d46 | |||
| f3a640a7c5 | |||
| baa289f6c3 | |||
| e5f218655e | |||
| 8119b57b45 | |||
| 9765fd91f7 | |||
| a25e4b6071 | |||
| 046d99f57a | |||
| fcc737e05b | |||
| 8f93302f45 | |||
| 17ca9bfc75 | |||
| f8fa0fa010 | |||
| 907058de83 | |||
| bfe0316067 | |||
| 5d94838830 | |||
| 76fff5efc2 | |||
| 5432c2dfa1 | |||
| b3b2655272 | |||
| 16366267c4 | |||
| 066718c968 | |||
| 60d0b09c63 | |||
| 2608d7a465 | |||
| cd18988d6d | |||
| be57eb0a50 | |||
| 25356ca439 | |||
|
|
4bea3c94f8 | ||
|
|
f1a3d6a24a | ||
|
|
91e4691230 | ||
|
|
8b48f03f6b | ||
|
|
9085835074 | ||
|
|
f5959620c8 | ||
|
|
e3afe38697 | ||
|
|
e7eff0ee8c | ||
|
|
e8ceb991a3 | ||
|
|
ac7730195d | ||
|
|
c24c6156dc | ||
|
|
7e3e6b2a28 | ||
| 5571768706 | |||
| 350c23f9d1 | |||
|
|
8028c4c4dd | ||
| e6da1432e5 | |||
| e22cf625bf | |||
| 552506ec7a | |||
|
|
e5c9c770d0 | ||
| a0a6bc9f20 | |||
|
|
c8e98ffa0d | ||
| 9dc572b913 | |||
|
|
14ece1a160 | ||
|
|
03d0d098f5 | ||
|
|
8ede44ae87 | ||
|
|
2aec4636cb | ||
|
|
cfe21e315e | ||
|
|
7e240d86c8 | ||
|
|
96effaaa3c | ||
|
|
d209a192c3 | ||
|
|
56b661ef65 | ||
|
|
b7f5a84d2d | ||
|
|
0bbaf80d2a | ||
|
|
d75a0241eb | ||
|
|
bcfc19e530 | ||
|
|
f8b6f7d5ef | ||
|
|
c9f9698b58 | ||
|
|
49a9543942 | ||
|
|
cb7cc9a43e | ||
|
|
9de4fe9ab9 | ||
|
|
88c3aa5149 | ||
|
|
a094df03ea | ||
|
|
1a723fe4c2 | ||
|
|
0248a68f57 | ||
|
|
3bca290e09 | ||
|
|
3fc8116dd3 | ||
|
|
14931d6362 | ||
|
|
1d3c0385dd | ||
|
|
5011d45391 | ||
|
|
99fae69960 | ||
| 1e51a4ca5d | |||
|
|
c2fd48b0ce | ||
|
|
183e10f8e6 | ||
| ad9e1ef5f1 | |||
| ada8105948 | |||
| c84519b606 | |||
| 33239a780e | |||
| 7a6113fc90 | |||
| de311321f4 | |||
| c48c7e6d7d | |||
| 48d54a32cf | |||
| 4172b0d70a | |||
|
|
9726dbb2df | ||
|
|
002e5acb82 | ||
| a48e1d9dd7 | |||
|
|
d1f9557dd1 | ||
| 34bf1c7b7f | |||
|
|
e71c330bdd | ||
| 5de1e3dc3d | |||
| e5e0656a6a | |||
|
|
65684aa577 | ||
|
|
cfcbec0c85 | ||
|
|
a86c1c72f9 | ||
|
|
04ce096e67 | ||
| 64d739b40d | |||
|
|
1535bbaefa | ||
|
|
a44d8bd7c9 | ||
| d257a19d9d | |||
| f0f615688e | |||
| a6f045b3d7 | |||
| 558c18e417 | |||
| 5ff507b81b | |||
| 726343db96 | |||
| 55ff2e717f | |||
| e4d4c00f52 | |||
| 03aa7a0673 | |||
| 37247fdfea | |||
| a03dd36f11 | |||
| a03c85f08a | |||
| 564cf6b18f | |||
| 89645f160e | |||
| e9eeb84c5f | |||
| 4f98f2b773 | |||
| b3c61134fc | |||
| 5edb4df35a | |||
| 07f8ffa6d5 | |||
| 8e0e94de3d | |||
| 602370be26 | |||
| 3ebe5d6639 | |||
| 6ee284e3f6 | |||
| bacdb9f49c | |||
| 6eb98d866b | |||
| cb0efdfdae | |||
| a6c9529c50 | |||
| e289554e44 | |||
| bec64e668d | |||
| a0b7b42524 | |||
| 09e2987c14 | |||
| ee0c2a12de | |||
| 782ff5b7b6 | |||
| a20b0d3fe3 | |||
| f420584e1a | |||
| 0d85899627 | |||
| be9ae32a3b | |||
| 7fc502513e | |||
| 2e1ac72585 | |||
| fba671ad40 | |||
| 33c82cab1a | |||
| 75c23448b4 | |||
| 548c2ab8a4 | |||
| 15b4d45375 | |||
| 4c8c3b72bb | |||
| 7ea3a235da | |||
| 0481fb3ecf | |||
| 37c406bf4d | |||
| b345f5f6a4 | |||
|
|
87f14b7c71 | ||
| c501d88c63 | |||
| 78539ec8b0 | |||
| de895dd7f8 | |||
| 3dad82d992 | |||
| 4673efac6a | |||
| 721f847b28 | |||
| c36c732f47 | |||
| 60e306d1db | |||
| ce31a45124 | |||
| 7189df7957 | |||
| f21157f3c7 | |||
| a5ab57d144 | |||
| 0ebc7ef777 | |||
| d94ed00312 | |||
| af905cf936 | |||
| c312991bac | |||
| 77130ac769 | |||
| a016175fc8 | |||
| 543248b8c2 | |||
| eadafffb18 | |||
| a6d789279c | |||
| 91325a4267 | |||
| 2b85fb49df | |||
| eb6c723713 | |||
| 6322b61a04 | |||
| ff2865b5d8 | |||
| 53049d1c4d | |||
| bec4bfaf31 | |||
| 0537378d82 | |||
| 3ffffd5b32 | |||
| d1fcfcc8fd | |||
| 97f08b32de | |||
| 9a6ae3b786 | |||
| 8aece9cbc4 | |||
| 5699cff4d0 | |||
| 5882c68217 | |||
| 0ff2625876 | |||
| c0d1251c1f | |||
| 9266a1d471 | |||
| f874009329 | |||
| 9ad88e4df4 | |||
| 7a2710dc9a | |||
| 674dccca4e | |||
| f525506718 | |||
| 908cf8a62d | |||
| 0551512fef | |||
| e8299fb9f6 | |||
| 6a1d271576 | |||
| 7e64675aa5 | |||
| 2515258dd4 | |||
| ccbebe172d | |||
| 74fc8323f0 | |||
| 740ab31f8c | |||
| 72fc9cb755 | |||
| 7a6296585c | |||
| 1afb150237 | |||
| 508e978fe5 | |||
| d07fb13401 | |||
| a8a2061eec | |||
| 14d689aaf3 | |||
| eed4180b70 | |||
| 854775e322 | |||
| 004bdd0778 | |||
| 6fe5f7d450 | |||
|
|
13906cd0fe | ||
|
|
7170a9945c | ||
|
|
7700548dee | ||
|
|
90a9e4361a | ||
|
|
7da171cf1f | ||
|
|
24820e921e | ||
|
|
47ad01d0b2 | ||
| f474a77bcb | |||
|
|
f186cdeacd | ||
| 630dc75787 | |||
| 899876c6cf | |||
| 61d02d522b | |||
| 45c0e0f914 | |||
|
|
992fbdfa20 | ||
|
|
9877ed351f | ||
|
|
b128c9f5a9 | ||
|
|
ef4c301149 | ||
|
|
53196d38ce | ||
|
|
6398879b56 | ||
| dc0bd51648 | |||
| c3991a1e75 | |||
| 328f7b4f31 | |||
| 3fc8fbc230 | |||
| ceceedf201 | |||
| 4864db03f3 | |||
| 8b57a9a35a | |||
| fa787bbe1e | |||
| 0aa0922fd3 | |||
| 1abf22623d | |||
| 4afd0c7b21 | |||
| 6f2de45819 | |||
| e3c3d60103 | |||
| 81324c8e52 | |||
| bec58ab138 | |||
| 451bed834f | |||
| d00e1c666e | |||
| ddb4cf0c51 | |||
| fea0f2962b | |||
| 506ee2d695 | |||
| 88689a4eb2 | |||
| dc269bec00 | |||
| 665ab5238d | |||
| bb508d3256 | |||
| 994fd799d0 | |||
| 6510871448 | |||
| 26399f8d0a | |||
| 529d14cb6b | |||
| fb44bd8aff | |||
| 24a1d57165 | |||
| 48ee66e744 | |||
| 0342aa0a5a | |||
| 406f28c663 | |||
| 835545e061 | |||
| 1392e28a88 | |||
| bc03ee866b | |||
| 69f0d130ee | |||
| 07af51b05c | |||
| 3574ae8a43 | |||
| 7dda7cc89c | |||
| 98025001e8 | |||
| 068e3a0828 | |||
| 6ad277275b | |||
| f58fe95f0d | |||
| 6e763e8270 | |||
| 6ac3050a05 | |||
| e13d111b9f | |||
| 1eaf9dff5c | |||
| 20dfa504e5 | |||
| 575e350831 | |||
| 7899066107 | |||
| 9289bd0c74 | |||
| 0945f488f6 | |||
| bd9dfd2cce | |||
| b8e1796c33 | |||
| f8bd80e38e | |||
| 90d2c1cf82 | |||
| 55e82bdeb7 | |||
| 7007d2df93 | |||
| ed3084e60f | |||
| 3392a8944d | |||
| c801eb4781 | |||
| 4a77c1bed8 | |||
| 100fc054cc | |||
| 001533fdf0 | |||
| c0345e47c9 | |||
| dfdf0845a0 | |||
| f24912ea9e | |||
| a9e0313fe4 | |||
| d54d960b8f | |||
| 2706903353 | |||
| b6dcecb672 | |||
| 14bfcabcaf | |||
| 3b1610a167 | |||
| d5fd705d66 | |||
| a700124f50 | |||
| 10952df591 | |||
| afd3fd3374 | |||
| 352d21496f | |||
| 016adff949 | |||
| fcd5a9aa09 | |||
| 304d3713b7 | |||
| 6befb0f46a | |||
| e655ccdf64 | |||
| 2c88fb0a03 | |||
| 7b13d8bd0f | |||
| 21db567396 | |||
| 68df3797f1 | |||
| dccca554e0 | |||
| 1b63429def | |||
| 87da3c0b58 | |||
| 06551c66a6 | |||
| 136820c8f9 | |||
| 7c88692c1c | |||
| 1e0015322c | |||
| 6176791174 | |||
| 9ff80f8cc1 | |||
| 738d6298d2 | |||
| a84bc3ecfe | |||
| daa203a43e | |||
| 33d2a4004d | |||
| 6e43ab30c2 | |||
| cc45cc6347 | |||
| c31933a53c | |||
| efe005378a | |||
| 5874c93956 | |||
| cd5fc3a05c | |||
| e0a2d0c95c | |||
| 4572c88f58 | |||
| c752227e20 | |||
| d4e5af459e | |||
| 29360e38e8 | |||
| e5f4c00729 | |||
| c6aeedb5fc | |||
| 32cf6bf63e | |||
| 024833cc95 | |||
| b4642b3c78 | |||
| b82cc73cf1 | |||
| 873920d27f | |||
| 37767f9939 | |||
| 00f3f2905f | |||
| 30b4deffc6 | |||
| 96f0f58e68 | |||
| a8656fc1a8 | |||
| 1074104d34 | |||
| 486e3c27e4 | |||
| bbed2a7059 | |||
| 8186b181cc | |||
| 539429c058 | |||
| 01a9d6c3db | |||
|
|
ddd3b3eca1 | ||
| a8f5bce9ee | |||
| 683f0ff101 | |||
| 47c0e1f933 | |||
| 6cad11f687 | |||
| eea1ed6bcb | |||
| c6bcbbd214 | |||
| e7495dfe29 | |||
| 5650b279c3 | |||
| 596fe228ed | |||
| e0cfe80a9e | |||
| 16a34a2fad | |||
| 75b94a5025 | |||
| 265f4174d5 | |||
| 447b2b2b81 | |||
| 3b89cf2d5f | |||
| f9236101b9 | |||
| 6561cecf33 | |||
| a4bb6e7b0c | |||
| 1f995c9029 | |||
| 891a8f82b7 | |||
| 23ae848f5b | |||
| a16c235f71 | |||
| e56704b69f | |||
| 1c0ed05ac9 | |||
| a6c9f88068 | |||
| 310eca0810 | |||
| a76e6b9a81 | |||
| 836a163cc8 | |||
| 052a880b0f | |||
| 2f3e04cfc3 | |||
| 080f82e198 | |||
| c08025eeb2 | |||
| 30cb6663dd | |||
| e256a771d5 | |||
| 3df6a4434e | |||
| 9d99811272 | |||
| c97759dc4e | |||
| b77a370eb7 | |||
| b36e859c06 | |||
| fd955076dd | |||
| 89ceef444e | |||
| 00bf112b5a | |||
| 16a1fe604f | |||
| f6c0594088 | |||
| 8403355ba9 | |||
| 4a3a672cbe | |||
| 8aa378348e | |||
| 97628bb67d | |||
| 46676bf80d | |||
| 0ebb3cffe4 | |||
| d39f86d9c5 | |||
| f4a83eedc4 | |||
| 4c65753358 | |||
| 0efef0d81b | |||
| 485af25d4a | |||
| 3b4af6ef11 | |||
| 40a66bae03 | |||
| 049beb8818 | |||
| a39c9831c5 | |||
| 066b9b17d3 | |||
| 629022ab5f | |||
| cc8ee63639 | |||
| 21d31f1678 | |||
| 4d101bc812 | |||
| 1e4e2e436f | |||
| 74299629e6 | |||
| a4b9b5be82 | |||
| a926da1c30 | |||
| 11e1de1cf8 | |||
| 7032cee6b3 | |||
| 02cfa68b92 | |||
| 737e69d72f | |||
| ab504841c3 | |||
| b1457f0aad | |||
| beb58d3cd6 | |||
| 2f48d0243b | |||
| cfdd0d1a55 | |||
| 0318d15c76 | |||
| 0433fc8805 | |||
| 777fa7fc2b | |||
| 53392608e5 | |||
| b7c7bb1662 | |||
| 81d51577b8 | |||
| dd1c40c9c8 | |||
| 7c37eababd | |||
| 6d371beda9 | |||
| 53805f2c59 | |||
| 74e87359e2 | |||
| 5e2683aba7 | |||
| fe921d0444 | |||
| 12a52c40c9 | |||
| 7486539b32 | |||
| a55c182be9 | |||
| 76281b7564 | |||
| 1725ec1de9 | |||
| dd3c2894f6 | |||
| a941f609f0 | |||
| 86d2960b60 | |||
| 28a97e2ba3 | |||
| 5161644205 | |||
| 96ef569720 | |||
| 115c7340ee | |||
| 4dd377e28d | |||
| 3128ab43b3 | |||
| 0b49f28a80 | |||
| 0b255e063f | |||
| c5a358888b | |||
| 0bc1ac9161 | |||
| feb78b8bcb | |||
| 86b80e043e | |||
| 398ee8b932 | |||
| 44277bced6 | |||
| ea04b8f9e1 | |||
| ede55a8a5f | |||
| 9ba3bf6f83 | |||
| 16888d62e2 | |||
| 5bb22c17c8 | |||
| a855ea7885 | |||
| f7aedb1936 | |||
| 879c547e08 | |||
| 0c761d553c | |||
| e3cdf70883 | |||
| 1e9710ce0c | |||
| 090452969c | |||
| 66844b93d3 | |||
| bd8b492ff6 | |||
| 910a906600 | |||
| 89771a2380 | |||
| a5823effe9 | |||
| 36e668455f | |||
| 4d0e715982 | |||
| bfc2649909 | |||
| 81c771a7be | |||
| 16b8530d43 | |||
| 8a2ef38326 | |||
| d382c6b559 | |||
| d21c61a8b2 | |||
| b175eaf54c | |||
| 90bb0769e5 | |||
| 07ded22f8e | |||
| 5019563c38 | |||
| fd6693ee17 | |||
| 18c4779f58 | |||
| aec55fea83 | |||
| 76e6568ac6 | |||
| 43a17ecd14 | |||
| de4cb1b6a0 | |||
| 4407e8ce6d | |||
| 36f165807a | |||
| 76b0a5e05e | |||
| 9c83698b81 | |||
| f39d086bc8 | |||
| 1e4fcb62f5 | |||
| 08e8377309 | |||
| 280fc9dff2 | |||
| f1e0453b0a | |||
| 9f7cb91cc2 | |||
| f8e42b886d | |||
| d18fa2f761 | |||
| 130906ef42 | |||
| d3e12deb18 | |||
| 2bb731c7fc | |||
| 1e8cde81be | |||
| 58e2e539f8 | |||
| 4f8964e807 | |||
| 0ea8d7ce33 | |||
| 3c689ccddf | |||
| b23700f30a | |||
| 0f37d01b2d | |||
| fb3b998cfd | |||
| ff892a1ad5 | |||
| 08e5ba6298 | |||
| e472075087 | |||
| e6314be92d | |||
| 660afb94bb | |||
| 508cf8d41b | |||
| 79d44826fe | |||
| 7260b188c5 | |||
| e895a2f2df | |||
| a9ca7be1d5 | |||
| 29b5910fff | |||
| ffad0051f9 | |||
| 717fdcd611 | |||
| 817eaff8b1 | |||
| 48b69879cb | |||
| 596f755a6c |
936 changed files with 53042 additions and 227330 deletions
50
.env.example
50
.env.example
|
|
@ -22,5 +22,51 @@ SESSION_SECRET=changeme
|
|||
# MAM API Configuration
|
||||
MAM_API_URL=http://mam-api:3000
|
||||
|
||||
# Auth (set to 'true' to require login; false for open/dev mode)
|
||||
AUTH_ENABLED=false
|
||||
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
|
||||
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
|
||||
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
|
||||
#
|
||||
# RBAC v2 note: with AUTH_ENABLED=true, per-project access is enforced. Service
|
||||
# API tokens (capture sidecar, Premiere panel, integrations) must belong to a
|
||||
# user with the access they need — an 'admin' user (full access), or a user with
|
||||
# the right project grants. A non-admin service token with no grants will get
|
||||
# 403 on asset registration (ingest) and streaming. In dev mode the synthetic
|
||||
# user is admin, so this only matters once auth is on.
|
||||
AUTH_ENABLED=true
|
||||
|
||||
# CORS allowlist — comma-separated origins that may carry credentials to the API.
|
||||
# Same-origin requests via the nginx reverse proxy do not need to be listed here.
|
||||
# Leave empty to allow any origin (DEV ONLY).
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS,
|
||||
# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate
|
||||
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Google OAuth (OIDC) sign-in — OPTIONAL. Leave the client id/secret blank to
|
||||
# disable; the "Sign in with Google" button and the /auth/google routes only
|
||||
# activate when all three of CLIENT_ID, CLIENT_SECRET, and REDIRECT_URL are set.
|
||||
# Create an OAuth 2.0 Client (type: Web application) in Google Cloud Console and
|
||||
# add OAUTH_REDIRECT_URL to its authorized redirect URIs.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Must exactly match a redirect URI on the OAuth client, e.g.
|
||||
# https://dragonflight.live/api/v1/auth/google/callback
|
||||
OAUTH_REDIRECT_URL=
|
||||
# Restrict sign-in to one Google Workspace domain (recommended). First login from
|
||||
# an allowed-domain account auto-provisions a NEW 'viewer' account (matched only
|
||||
# by Google's stable subject id, never by email — so a Google login can never
|
||||
# seize a pre-existing local account). An admin then grants project access.
|
||||
# Leave blank to allow any verified Google account to self-provision (NOT advised).
|
||||
GOOGLE_ALLOWED_DOMAIN=
|
||||
# Note: if a Google-linked account also has TOTP enabled, sign-in still requires
|
||||
# the authenticator code (Google is treated as the first factor). Accounts without
|
||||
# TOTP complete sign-in in one Google step.
|
||||
|
||||
# Playout / Master Control (MCR)
|
||||
# Image tag the mam-api spawns when a channel starts. Build with:
|
||||
# docker compose --profile build-only build playout
|
||||
PLAYOUT_IMAGE=wild-dragon-playout:latest
|
||||
# Base AMCP port — each channel binds to BASE + channel_id (in CasparCG terms).
|
||||
PLAYOUT_AMCP_BASE_PORT=5250
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -14,6 +14,9 @@ yarn-error.log*
|
|||
# Build output
|
||||
dist/
|
||||
build/
|
||||
# ...but the Premiere panel's packaging pipeline lives at build/ — keep it tracked.
|
||||
!services/premiere-plugin/build/
|
||||
!services/premiere-plugin/build/**
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
|
@ -23,3 +26,12 @@ services/editor/node_modules
|
|||
services/editor/**/node_modules
|
||||
services/editor/**/dist
|
||||
services/editor/.pnpm-store
|
||||
|
||||
# Blackmagic DeckLink SDK + runtime libs (operator-supplied; see services/capture/build-with-decklink.sh)
|
||||
services/capture/sdk/
|
||||
services/capture/lib/
|
||||
|
||||
# Editor backups
|
||||
*.bak
|
||||
*.bak2
|
||||
.env.bak.*
|
||||
|
|
|
|||
153
DESIGN.md
Normal file
153
DESIGN.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Design system
|
||||
|
||||
Documented from the live tokens in `services/web-ui/public/styles.css` and the patterns used across `screens-*.jsx`. Treat this as the source of truth for shared primitives; pages may override locally but should not invent new color or type scales.
|
||||
|
||||
## Color
|
||||
|
||||
Dark theme only. Tokens live in `:root` in `styles.css`. Tinted neutrals — no `#000`, no `#fff`.
|
||||
|
||||
### Surfaces
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `--bg-0` | `#0B0D11` | Page background, deepest surface |
|
||||
| `--bg-1` | `#14171E` | Panels, sidebars, primary chrome |
|
||||
| `--bg-2` | `#1B1F27` | Panel headers, hover state |
|
||||
| `--bg-3` | `#232833` | Inputs, raised buttons, badges |
|
||||
| `--bg-4` | `#2D3340` | Strongly raised elements (rare) |
|
||||
|
||||
### Borders + overlays
|
||||
|
||||
| Token | Use |
|
||||
|---|---|
|
||||
| `--border` (rgba white 6%) | Default 1px separator |
|
||||
| `--border-strong` (rgba white 10%) | Emphasized separator |
|
||||
| `--border-stronger` (rgba white 14%) | Hover/active border |
|
||||
| `--hover` (rgba white 4%) | Subtle hover fill |
|
||||
| `--hover-strong` (rgba white 7%) | Stronger hover fill |
|
||||
|
||||
### Text
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `--text-1` | `#F2F3F6` | Primary content |
|
||||
| `--text-2` | `#A8AEBC` | Secondary content |
|
||||
| `--text-3` | `#6B7280` | Labels, metadata |
|
||||
| `--text-4` | `#4B5260` | Section labels, off-month, disabled |
|
||||
|
||||
### Accent
|
||||
|
||||
`--accent: #5B7CFA` (Frame.io-ish blue). Soft variants `--accent-soft` (14%) and `--accent-soft-2` (22%) for fills. `--accent-text: #B4C3FF` for high-contrast accent text.
|
||||
|
||||
**Restrained strategy.** Accent is the only saturated color in chrome. Status colors below appear only where they reflect actual state. Project colors appear only on project chips.
|
||||
|
||||
### Status
|
||||
|
||||
| Token | Hex | Use |
|
||||
|---|---|---|
|
||||
| `--success` | `#2DD4A8` | Done, healthy, ready |
|
||||
| `--warning` | `#F5A623` | Processing, attention-needed |
|
||||
| `--danger` | `#FF5B5B` | Failed, error |
|
||||
| `--live` | `#FF3B30` | Currently recording (broadcast red, intentionally hotter than `--danger`) |
|
||||
| `--purple` | `#B57CFA` | Editor / dev tags |
|
||||
|
||||
Each has a `*-soft` variant at ~14% for background fills.
|
||||
|
||||
### Project palette
|
||||
|
||||
Six fixed colors cycled by index in `data.jsx` (`PROJECT_COLORS`):
|
||||
|
||||
`#5B7CFA · #2DD4A8 · #FF5B5B · #F5A623 · #B57CFA · #6B7280`
|
||||
|
||||
Used only on the project chip / rail dot. Do not reuse for status meaning.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Sans:** Geist (with `cv11`, `ss01` features enabled). All UI text by default.
|
||||
- **Mono:** Geist Mono. URLs, IDs, timestamps, durations, technical metadata.
|
||||
|
||||
Body scale runs small for density:
|
||||
|
||||
| Size | Use |
|
||||
|---|---|
|
||||
| 10.5px, 600, uppercase, 0.06–0.08em letterspacing | Column heads, section labels |
|
||||
| 11–11.5px | Metadata, secondary rows |
|
||||
| 12–12.5px | Body in lists/tables |
|
||||
| 13px, 500–600 | Row labels, button text |
|
||||
| 14px | Default body |
|
||||
| 15px, 600 | Modal titles, panel heads |
|
||||
| 22–28px, 600+ | Page H1 (`.page-header h1`) |
|
||||
|
||||
Never use `gradient text` (impeccable absolute ban). Emphasis via weight and size only.
|
||||
|
||||
## Layout
|
||||
|
||||
- Sidebar: 232px fixed (`--sidebar-w`).
|
||||
- Topbar: 56px (`--topbar-h`).
|
||||
- Row height: 44px default, 36px compact (`--row-h`, `data-density="compact"`).
|
||||
- Gap unit: 16px default, 12px compact (`--gap`).
|
||||
- Border radius scale: 4 / 6 / 8 / 12 / 16 px (`--r-xs` → `--r-xl`).
|
||||
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
|
||||
- Do NOT nest panels.
|
||||
|
||||
### Page header
|
||||
|
||||
Standard screens use `.page > .page-header > h1`. Three screens are documented exceptions because they need full-bleed layouts and their own top-chrome:
|
||||
|
||||
- **Home** uses `.launcher` (lobby pattern: hero logo + tile grid + status pip).
|
||||
- **Library** uses `.library-layout` (dual-pane rail + main). The h1 sits inside `.library-toolbar` as `.toolbar-title`.
|
||||
- **Editor** uses `.editor-shell` (NLE with timeline + monitors). The beta banner doubles as its top chrome.
|
||||
|
||||
All other screens should render `<div className="page"><div className="page-header"><h1>…</h1>…</div>…</div>` for consistent IA and screen-reader hierarchy.
|
||||
|
||||
## Shadow
|
||||
|
||||
Two tokens, used sparingly:
|
||||
|
||||
- `--shadow-card`: subtle inset highlight + soft outer. Default for raised inputs.
|
||||
- `--shadow-pop`: modal / popover / context menu.
|
||||
|
||||
No drop shadows on flat surfaces. No glow effects (except: the EPG now-line uses a tight `box-shadow` for the broadcast-red glow, and live status dots have a pulse halo; these are state cues, not decoration).
|
||||
|
||||
## Motion
|
||||
|
||||
- Default transition: 80–120ms on background/border (`transition: background 80ms, border 80ms`).
|
||||
- Heavier reveals: 200ms.
|
||||
- Easing: prefer ease-out (no bounce, no elastic).
|
||||
- Don't animate layout (width/height/top); animate transforms and opacity.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Status badges (`.badge`)
|
||||
|
||||
Variants: `live` (red, animated dot), `success`, `danger`, `warning`, `accent`, `neutral`, `outline`. Tiny — 9–10px font, ~2px vertical padding. Reserved for state, not labels.
|
||||
|
||||
### Row tables (`.user-row`, `.token-row`, `.job-row`, `.schedule-row`, etc.)
|
||||
|
||||
CSS-grid with explicit columns. Header row uses `.head` (uppercase 10.5px). No card stacking — these are dense data lists.
|
||||
|
||||
### Schedule EPG (`.epg-*`)
|
||||
|
||||
Broadcast timeline pattern. Recorder rows × time-of-day axis. Single scrolling container with sticky left gutter (220px) and sticky top ruler (32px). Hour rhythm via `repeating-linear-gradient`. Now-line is a 1px hot-red vertical bar with `--live` glow, animated by re-rendering the line component every second (transform-only positioning, no layout thrash). Event blocks are absolute-positioned within each row, colored via `--epg-block-color` set per recorder's project color. Live events get a red gradient + pulse; failures get a glyph + full red border; past events fade to 0.55 opacity.
|
||||
|
||||
### Inputs
|
||||
|
||||
`.field-input` — `--bg-3` fill, 1px `--border`, `--r-sm`, 12.5px font. Focus: `--border-strong`.
|
||||
|
||||
### Status dot (`.signal-dot`, `.rec-dot`, etc.)
|
||||
|
||||
Small (~6–8px) circle, used inline with text. Recording dots pulse with a keyframe animation.
|
||||
|
||||
## Impeccable absolute bans (apply project-wide)
|
||||
|
||||
- No `border-left` / `border-right` greater than 1px as a colored accent (rewrite with full borders, leading icons, or background tint).
|
||||
- No `background-clip: text` gradient text.
|
||||
- No glassmorphism (blur + translucent) decoratively.
|
||||
- No hero-metric template (big number, small label, gradient accent, supporting stats).
|
||||
- No identical card grids.
|
||||
- Modal as last resort — exhaust inline alternatives first.
|
||||
- No em dashes in code or copy. Use commas, colons, parentheses, periods.
|
||||
|
||||
## To extend
|
||||
|
||||
When a new design need arises, prefer adding a variant to an existing primitive over inventing a new token. New tokens land in `styles.css`. New components land in the relevant `screens-*.jsx` only if reused; otherwise keep them local.
|
||||
61
PRODUCT.md
Normal file
61
PRODUCT.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Product
|
||||
|
||||
## What it is
|
||||
|
||||
Dragonflight (codebase name; UI brand: "Dragonflight · Wild Dragon Broadcast") is an on-prem broadcast media asset manager and live ingest controller. Operators capture from SRT, RTMP, and SDI sources, schedule windowed recordings against named recorders, transcode/proxy in the background, browse and clip in a library, import from YouTube, and hand off to Premiere or an in-house editor.
|
||||
|
||||
The deployable surface is a React (Babel-in-browser) single-page app served by nginx, talking to a Node/Express API backed by Postgres + Redis + BullMQ, with capture and worker containers handling the actual media.
|
||||
|
||||
Stack lives at `services/web-ui` (UI), `services/mam-api` (API), `services/capture`, `services/worker`, `services/editor`.
|
||||
|
||||
## Register
|
||||
|
||||
**Product.** This is operator UI for a working broadcast tool, not a brand site or marketing surface. Design serves the operator's job, not the brand's identity.
|
||||
|
||||
## Users
|
||||
|
||||
Primary: broadcast operators and engineers running live productions. They schedule and supervise back-to-back recordings across multiple recorders in a single shift. They care about: what's recording right now, what's about to start, what failed, and which recorder is bound to what source.
|
||||
|
||||
Secondary: editors and producers who consume the resulting library, comment on assets, request proxy regeneration. They mostly live in the Library and Asset detail screens, not the scheduler.
|
||||
|
||||
Tertiary: admins managing recorders, users, cluster nodes, and storage. Live in the Admin screens.
|
||||
|
||||
## Product purpose
|
||||
|
||||
Replace a stack of one-trick tools (NewBlue scheduler, vMix capture, ad-hoc Premiere ingest, manual S3 syncs) with a single operator surface that supervises recorders, owns the asset catalog through proxy generation, and stays out of the editor's way once footage lands.
|
||||
|
||||
## Tone
|
||||
|
||||
Function-first. Dense. Operations-room. Mono fonts where data lives. Small type. Restrained chrome. The operator should be able to glance at any screen and read the state of the system in under a second; they should never wonder "is this still happening" or "did that finish."
|
||||
|
||||
Not: marketing-warm, conversational, gamified, congratulatory.
|
||||
|
||||
## Strategic principles
|
||||
|
||||
1. **Glance-readable status.** Every list, every cell, every badge must answer "what is the state of this thing right now" without a hover.
|
||||
2. **Trust the operator.** No confirmation modals for reversible actions. No nag, no toasts for routine success. Errors stay visible until acknowledged.
|
||||
3. **Time is the spine.** This product is about time-based events (recordings, schedules, jobs). UIs should privilege time as a primary axis, not bury it under categorical filters.
|
||||
4. **Density over whitespace.** Operators run multi-monitor setups and want maximum signal per pixel. Generous whitespace is a brand-site reflex; reject it here.
|
||||
5. **No half-states.** Pending UIs disable controls; live UIs show live data; failed UIs show the failure inline, not in a separate notification feed.
|
||||
|
||||
## Anti-references
|
||||
|
||||
Steer away from:
|
||||
|
||||
- **Linear-pastel SaaS aesthetics.** Purples, mint accents, friendly empty states with cartoon line illustrations.
|
||||
- **Google-Calendar generic.** A neutral month grid with rounded event chips, no operational signal, optimized for "is Friday free" rather than "is recorder A in conflict at 14:00."
|
||||
- **Gantt-chart project-management feel.** Implies long-horizon planning of tasks with dependencies; this product is hour-scale broadcast operations.
|
||||
- **Cards-for-everything.** Identical card grids of icon+label+value. Particularly the SaaS hero-metric template.
|
||||
- **Decorative blur / glassmorphism / gradient text.** Read as decorative AI slop in a broadcast-ops context.
|
||||
- **NewBlue / Wirecast skinning.** Heavy bevels, gradient buttons, drop shadows. Read as outdated broadcast software.
|
||||
|
||||
## Decision context (Schedule v2)
|
||||
|
||||
The Schedule screen was rebuilt as an EPG (electronic program guide) timeline. Operator confirmed:
|
||||
|
||||
- Density: **heavy / back-to-back, many recorders all day** — month grid was the wrong primary.
|
||||
- At-a-glance signals: now/next, recorder bookings (conflicts), project context, failure history.
|
||||
- Aesthetic: **studio / cinematic — dark, type-led, accent moments.** DaVinci-Resolve-panel territory.
|
||||
- Scope: full rethink — replace the primary view.
|
||||
|
||||
Implementation: recorder rows × time-of-day horizontal axis, sticky gutter + ruler, vertical hot-red now-line, event blocks colored by project, status pills in a top strip. Today / Week / List views.
|
||||
264
README.md
264
README.md
|
|
@ -1,60 +1,258 @@
|
|||
# Wild Dragon
|
||||
# Dragonflight
|
||||
|
||||
Self-hosted Media Asset Management platform built to replace Grass Valley AMPP FramelightX.
|
||||
Self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. Handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
|
||||
|
||||
## Services
|
||||
> Repo renamed from `wild-dragon` → `dragonflight` (2026-05-23). The old URL still redirects.
|
||||
|
||||
| Service | Port | Description |
|
||||
|---------|------|-------------|
|
||||
| **web-ui** | 8080 | Browser-based MAM interface + capture controls |
|
||||
| **mam-api** | 3000 | REST API — assets, projects, bins, jobs |
|
||||
| **capture** | 3001 | SDI capture daemon (Blackmagic DeckLink + FFmpeg) |
|
||||
| **worker** | — | Async job processor (proxy gen, thumbnails, conform) |
|
||||
| **db** | 5432 | PostgreSQL 16 metadata store |
|
||||
| **queue** | 6379 | Redis 7 job queue (BullMQ) |
|
||||
## Home Dashboard
|
||||
|
||||
<img src="docs/screenshots/01-home.png" alt="Home Dashboard" width="800" />
|
||||
|
||||
The home screen provides quick access to all major features and displays system status at a glance:
|
||||
- **Library** — Browse projects, bins, and assets with hover-scrub previews
|
||||
- **Recorders** — View configured capture devices and their status
|
||||
- **Editor** — Timeline editor with cross-clip preview and render queue
|
||||
- **Jobs** — Proxy and thumbnail queue with retry controls
|
||||
- **Settings** — Configure storage, encoder, growing files, and capture SDK
|
||||
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Live Ingest & Capture
|
||||
**Multi-protocol source capture with per-recorder codec settings**
|
||||
|
||||
Dragonflight ingests from multiple sources simultaneously:
|
||||
- **SRT** (Secure Reliable Transport) — caller and listener modes
|
||||
- **RTMP** — standard streaming protocol
|
||||
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
|
||||
|
||||
Each recorder can be configured with independent codec settings:
|
||||
- ProRes (hi-res masters)
|
||||
- H.264 / H.265 (proxies)
|
||||
- DNxHR (Avid compatibility)
|
||||
|
||||
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
|
||||
|
||||
### 2. Growing-File Editing
|
||||
**Live editing in Premiere Pro while capture is still writing**
|
||||
|
||||
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
|
||||
- Real-time clip detection and frame-accurate trimming
|
||||
- One-click relink to final S3 master after promotion
|
||||
- No waiting for capture to finish before editorial begins
|
||||
|
||||
### 3. Recorder Scheduler
|
||||
**Time-windowed recording automation**
|
||||
|
||||
Schedule recordings with:
|
||||
- One-shot, daily, or weekly recurrence
|
||||
- Automatic start/stop via 15-second tick loop
|
||||
- Conflict detection across recorders
|
||||
- Project and bin assignment at schedule time
|
||||
|
||||
### 4. Library & Asset Management
|
||||
**Browse, search, and organize captured footage**
|
||||
|
||||
The Library screen provides:
|
||||
- Project and bin hierarchy
|
||||
- Asset detail view with frame-anchored persistent comments
|
||||
- Right-click context menu (move-to-bin, rename, delete)
|
||||
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
|
||||
- Hover-scrub preview with HLS playback
|
||||
|
||||
### 5. Jobs Queue
|
||||
**BullMQ-backed proxy and thumbnail generation**
|
||||
|
||||
Automated background processing:
|
||||
- Per-job retry logic with exponential backoff
|
||||
- Bulk "retry all failed" for batch recovery
|
||||
- Inline error messages with actionable diagnostics
|
||||
- Status tracking: ingesting → processing → ready
|
||||
|
||||
Proxy encoder options:
|
||||
- CPU-based: libx264 (H.264)
|
||||
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
|
||||
|
||||
### 6. Timeline Conform & Export
|
||||
**FCP XML export with server-side FFmpeg rendering**
|
||||
|
||||
The Premiere Pro panel exports FCP XML with:
|
||||
- Server-side conform via FFmpeg
|
||||
- Multiple output formats: H.264, H.265, ProRes
|
||||
- Resolution presets: Broadcast, Web, Archive
|
||||
- Batch processing with job queue integration
|
||||
|
||||
### 7. Hi-Res Auto-Relink
|
||||
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
|
||||
|
||||
After editing on proxies:
|
||||
- Select clips in Premiere
|
||||
- Trigger relink from the CEP panel
|
||||
- Server trims hi-res segments to exact in/out points
|
||||
- Concurrent trim worker pool for speed
|
||||
- 24-hour TTL with automatic cleanup
|
||||
|
||||
### 8. Settings & Configuration
|
||||
**Centralized control for storage, encoding, and capture**
|
||||
|
||||
Configure:
|
||||
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
|
||||
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
|
||||
- **Growing Files** — SMB path, retention, auto-promotion
|
||||
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
|
||||
|
||||
### 9. Cluster & Distributed Capture
|
||||
**Primary + worker topology with remote DeckLink nodes**
|
||||
|
||||
- Primary node runs API, scheduler, and web UI
|
||||
- Worker nodes handle proxy/thumbnail jobs
|
||||
- Remote capture nodes run DeckLink cards off-host
|
||||
- Heartbeat health monitoring
|
||||
- Automatic failover and recovery
|
||||
|
||||
### 10. Admin & User Management
|
||||
**Role-based access, token auth, and cluster monitoring**
|
||||
|
||||
- User creation and role assignment
|
||||
- API token generation for integrations
|
||||
- Container and cluster node status
|
||||
- System health dashboard
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone
|
||||
git clone https://forge.wilddragon.net/zgaetano/wild-dragon.git
|
||||
cd wild-dragon
|
||||
# Clone (repo renamed; old URL still redirects)
|
||||
git clone https://forge.wilddragon.net/zgaetano/dragonflight.git
|
||||
cd dragonflight
|
||||
|
||||
# Configure
|
||||
cp .env.example .env
|
||||
# Edit .env with your S3 credentials and secrets
|
||||
# Edit .env — S3 credentials + SESSION_SECRET at minimum
|
||||
|
||||
# Launch
|
||||
docker compose up -d
|
||||
|
||||
# Open
|
||||
open http://localhost:8080
|
||||
open http://localhost:47434
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SDI Input (DeckLink) → capture service → dual FFmpeg streams
|
||||
├─ HiRes (ProRes) → S3
|
||||
└─ Proxy (H.264) → S3
|
||||
↓
|
||||
web-ui ← mam-api ← PostgreSQL ← worker (BullMQ)
|
||||
├─ proxy_gen
|
||||
├─ thumbnail
|
||||
└─ conform (EDL → FFmpeg → export)
|
||||
SDI / SRT / RTMP ──► capture (FFmpeg)
|
||||
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
|
||||
└─ master output
|
||||
├─ growing_enabled=true:
|
||||
│ /growing/<projectId>/<clip>.mov
|
||||
│ (Premiere mounts SMB, edits live)
|
||||
│ └─► promotion worker uploads to S3
|
||||
│
|
||||
└─ growing_enabled=false:
|
||||
multipart stream → S3
|
||||
|
||||
assets POST ──► proxy job ──► worker
|
||||
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
|
||||
├─ thumbnail job
|
||||
└─ status: ingesting → processing → ready
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend:** Node.js / Express
|
||||
- **Frontend:** Vanilla HTML/CSS/JS
|
||||
- **Database:** PostgreSQL 16
|
||||
- **Queue:** Redis 7 + BullMQ
|
||||
- **Storage:** S3-compatible (RustFS)
|
||||
- **Media Processing:** FFmpeg
|
||||
- **Capture:** Blackmagic DeckLink SDK
|
||||
- **Deployment:** Docker Compose
|
||||
- **Runtime:** Node.js 22, Docker Compose
|
||||
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
|
||||
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
|
||||
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
|
||||
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
|
||||
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| **web-ui** | 47434 | Browser SPA + capture controls |
|
||||
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
|
||||
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
|
||||
| **worker** | — | BullMQ proxy + thumbnail workers |
|
||||
| **db** | 5432 | PostgreSQL 16 |
|
||||
| **queue** | 6379 | Redis 7 |
|
||||
|
||||
---
|
||||
|
||||
## Workflow Example: Live-to-Edit
|
||||
|
||||
1. **Operator** schedules a recording on Recorder A for 14:00–15:30, assigns to "News/Segment-A" project
|
||||
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
|
||||
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
|
||||
4. **Editor** trims and marks in/out points while capture is still writing
|
||||
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
|
||||
6. **Editor** clicks "Relink to Master" in CEP panel
|
||||
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
|
||||
8. **Premiere** relinks proxy clips to trimmed master
|
||||
9. **Editor** exports final timeline via FCP XML conform
|
||||
|
||||
Total time from end of capture to relinked master: ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
|
||||
- `deploy/onboard-node.sh` — provision a remote worker host
|
||||
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
|
||||
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
|
||||
|
||||
## Authentication
|
||||
|
||||
Dragonflight uses local username/password authentication with two transports:
|
||||
|
||||
- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout.
|
||||
- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`.
|
||||
|
||||
### First-run setup
|
||||
|
||||
On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser.
|
||||
With no users in the database, the login screen renders a "First-run setup" form
|
||||
instead — fill it in to create the first admin and you are logged in immediately.
|
||||
|
||||
Subsequent users are created from `Settings → Users` (any signed-in user can
|
||||
create others — flat access).
|
||||
|
||||
### Dev mode
|
||||
|
||||
Setting `AUTH_ENABLED=false` disables all auth checks; a synthetic `dev` user
|
||||
is attached to every request. **Never deploy this way.** The dev user row is
|
||||
seeded with a hash that no real password can match, so flipping
|
||||
`AUTH_ENABLED=true` later does not expose the dev account.
|
||||
|
||||
### Recovering a forgotten admin password
|
||||
|
||||
Any signed-in user can reset another user's password from `Settings → Users`.
|
||||
If no one can sign in (all admins forgot their passwords), reset directly in
|
||||
Postgres:
|
||||
|
||||
```sql
|
||||
-- generate a fresh bcrypt hash with:
|
||||
-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here'
|
||||
UPDATE users SET password_hash = '<bcrypt-hash>', password_updated_at = NOW()
|
||||
WHERE username = 'admin';
|
||||
```
|
||||
|
||||
### `AUTH_ENABLED` transition
|
||||
|
||||
When flipping `AUTH_ENABLED=false` → `true` on an existing install:
|
||||
|
||||
1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out).
|
||||
2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI.
|
||||
3. Set `TRUST_PROXY=true` when behind nginx (required for rate-limit accuracy).
|
||||
4. Restart `mam-api`.
|
||||
5. Visit the UI — first-run setup will appear if no real users exist yet.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Proprietary — Wild Dragon LLC, all rights reserved.
|
||||
|
|
|
|||
101
WORK_LOG_PLAYOUT.md
Normal file
101
WORK_LOG_PLAYOUT.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Playout / Master Control — Implementation Work Log
|
||||
|
||||
**Branch:** `feat/playout-mcr` (off `main`)
|
||||
**Started:** 2026-05-30
|
||||
**Status:** Code complete, awaiting runtime validation
|
||||
|
||||
Tracks the build of the playout (MCR) subsystem against the design at
|
||||
`docs/superpowers/specs/2026-05-30-playout-mcr-design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Commit sequence
|
||||
|
||||
| # | Commit | Scope |
|
||||
|---|--------|-------|
|
||||
| 1 | `docs(playout)` | Design spec, §7 questions answered |
|
||||
| 2 | `feat(mam-api): migration 029` | Six tables, failover columns, audio_normalized flag |
|
||||
| 3 | `feat(worker): playout-stage` | S3 → /media + EBU R128 loudnorm + index.js wiring |
|
||||
| 4 | `feat(playout): sidecar` | CasparCG image + AMCP shim, HLS preview consumer, fps-aware frame math |
|
||||
| 5 | `feat(mam-api): /playout control plane + auto-failover` | Routes + scheduler health tick + restartChannel helper |
|
||||
| 6 | `feat(web-ui): MCR page` | screens-playout, styles, app/shell/index.html wiring |
|
||||
| 7 | `build(playout): compose wiring + .env knobs` | /media volume, queue addition, build-only service |
|
||||
| 8 | `docs(playout): work log` | This file |
|
||||
|
||||
## Resolved §7 decisions (2026-05-30)
|
||||
|
||||
- **Audio loudness:** pre-normalize at stage time. ffmpeg `loudnorm` two-pass
|
||||
(I=-23 LUFS, TP=-1 dBTP, LRA=11), linear mode preserves dynamics. Output
|
||||
AAC 192k @ 48 kHz, video stream copied. Per-item `audio_normalized` flag
|
||||
so re-stages of the same asset skip the pass.
|
||||
- **Frame rate:** `1080p5994` default (was `1080i5994`). Per-channel
|
||||
override allowed via `video_format`. `fpsFor(videoFormat)` helper in
|
||||
the sidecar drives SEEK / LENGTH / transition-frames math.
|
||||
- **Preview latency:** HLS v1. CasparCG runs a second FFMPEG consumer
|
||||
alongside the primary output, writing `/media/live/<channel_id>/index.m3u8`
|
||||
(~600 kbps, 2s segments, 6-window list). Web UI plays via the existing
|
||||
HLS plumbing.
|
||||
- **Failover:** auto-restart on healthy node for NDI/SRT/RTMP. Alert-only
|
||||
for DeckLink (device-index pinning makes blind re-placement risky).
|
||||
Scheduler tick (PG advisory lock, same lock as recorder schedules) polls
|
||||
sidecar `/status`; ~3 missed checks → `restartChannel(id)` picks the most
|
||||
recently-seen-online other node, bumps `restart_count`, calls `/start`.
|
||||
|
||||
## Architecture notes
|
||||
|
||||
**Sidecar model.** One CasparCG container per channel. Spawned by mam-api
|
||||
via local Docker socket (primary node) or remote node-agent
|
||||
`/sidecar/start`. Tracked in `playout_sidecars` plus `playout_channels.container_id`.
|
||||
Killed on `/stop` or by `restartChannel` during failover.
|
||||
|
||||
**Media flow.**
|
||||
```
|
||||
S3 master/proxy → playout-stage worker → /media/playout/<assetId>.<ext>
|
||||
(loudnormed, AAC@-23 LUFS)
|
||||
↓
|
||||
CasparCG channel #1
|
||||
↓
|
||||
primary consumer HLS consumer
|
||||
(DeckLink/NDI/ ↓
|
||||
SRT/RTMP) /media/live/<ch_id>/*.m3u8
|
||||
```
|
||||
|
||||
**Port contention.** `assertDeckLinkFree()` blocks starting a SDI channel
|
||||
when a recorder or another channel on the same node+device_index is active.
|
||||
|
||||
**Failover scope.** NDI/SRT/RTMP have no hardware tie, so any healthy
|
||||
cluster_node is eligible. DeckLink channels surface an alert in the UI
|
||||
(`status='error'` + `error_message`) and require operator intervention.
|
||||
|
||||
## Testing checklist
|
||||
|
||||
- [ ] Apply migration 029 on dev DB
|
||||
- [ ] Build playout image: `docker compose --profile build-only build playout`
|
||||
- [ ] Build web-ui (`screens-playout` joins the esbuild list automatically)
|
||||
- [ ] Create channel via POST /api/v1/playout/channels (SRT first, no HW)
|
||||
- [ ] Stage 2-3 assets to a playlist, verify loudnorm metadata in stderr
|
||||
- [ ] Start channel → sidecar container appears in `docker ps`
|
||||
- [ ] AMCP smoke: `telnet <host> 5250`, `VERSION`, `INFO`
|
||||
- [ ] Play playlist; verify HLS at /media/live/<id>/index.m3u8
|
||||
- [ ] Skip / pause / resume / stop
|
||||
- [ ] As-run log: GET /api/v1/playout/channels/:id/asrun
|
||||
- [ ] Kill sidecar container → scheduler should restart on another node
|
||||
within ~3 ticks (~45s), restart_count increments
|
||||
- [ ] DeckLink channel kill: status flips to 'error', NO restart attempt
|
||||
- [ ] Try starting a decklink channel on a device_index already held by a
|
||||
recorder → 409
|
||||
- [ ] MCR UI smoke: nav entry visible, page renders, drag-drop adds items,
|
||||
transport buttons hit the API
|
||||
|
||||
## Known gaps (deferred)
|
||||
|
||||
- No WebRTC preview (HLS-only v1 — 4-6s lag, fine for confidence monitor).
|
||||
- No graphics/CG overlay layer in Phase A (templates land in Phase B).
|
||||
- No Phase B scheduler / 24/7 wall-clock channel (schema is in place,
|
||||
scheduler tick is not).
|
||||
- No multi-channel grid view (one channel at a time per page).
|
||||
- No timecode / remaining-duration overlay (would need CasparCG INFO poll).
|
||||
- No audio level meters on the UI.
|
||||
- `restartChannel` updates DB state and triggers `/start`; if the new node
|
||||
also fails repeatedly, there's no exponential backoff yet — bounded only
|
||||
by the manual stop button.
|
||||
104
deploy/api-smoke.sh
Executable file
104
deploy/api-smoke.sh
Executable file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env bash
|
||||
# Dragonflight MAM API smoke test
|
||||
#
|
||||
# Hits every read-only endpoint and a handful of safe write endpoints
|
||||
# against a running mam-api. Reports per-endpoint HTTP code + a one-line
|
||||
# pass/fail. Exits non-zero on any failure.
|
||||
#
|
||||
# Usage:
|
||||
# deploy/api-smoke.sh # against http://localhost:47432
|
||||
# API=http://10.0.0.25:47432 deploy/api-smoke.sh
|
||||
|
||||
set -u
|
||||
|
||||
API="${API:-http://localhost:47432}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# Per-endpoint check. Args: METHOD PATH EXPECTED_HTTP_CODE [BODY]
|
||||
# Treats anything < 500 as OK by default; auth-gated endpoints typically
|
||||
# return 401 with AUTH_ENABLED, also acceptable.
|
||||
hit() {
|
||||
local method="$1" path="$2" expect="${3:-2..}" body="${4:-}"
|
||||
local args=(-s -o /dev/null -w '%{http_code}' -X "$method" "${API}${path}")
|
||||
if [ -n "$body" ]; then args+=(-H 'Content-Type: application/json' -d "$body"); fi
|
||||
local code
|
||||
code=$(curl "${args[@]}" 2>/dev/null || echo "000")
|
||||
|
||||
if [[ "$code" =~ ^(2|3|401|400)[0-9][0-9]$ ]]; then
|
||||
printf " %s %-40s %s OK\n" "$method" "$path" "$code"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf " %s %-40s %s FAIL\n" "$method" "$path" "$code"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Dragonflight API smoke test — target ${API}"
|
||||
|
||||
echo ""
|
||||
echo "── auth ──────────────────────────────────────────"
|
||||
hit GET /api/v1/auth/me
|
||||
|
||||
echo ""
|
||||
echo "── core lists ────────────────────────────────────"
|
||||
hit GET /api/v1/projects
|
||||
hit GET /api/v1/assets
|
||||
hit GET /api/v1/assets?limit=5
|
||||
hit GET /api/v1/recorders
|
||||
hit GET /api/v1/jobs
|
||||
hit GET /api/v1/bins
|
||||
hit GET /api/v1/users
|
||||
hit GET /api/v1/groups
|
||||
hit GET /api/v1/cluster
|
||||
hit GET /api/v1/cluster/containers
|
||||
hit GET /api/v1/cluster/devices/blackmagic
|
||||
|
||||
echo ""
|
||||
echo "── settings ──────────────────────────────────────"
|
||||
hit GET /api/v1/settings/s3
|
||||
hit GET /api/v1/settings/transcoding
|
||||
hit GET /api/v1/settings/growing
|
||||
hit GET /api/v1/settings/ampp
|
||||
hit GET /api/v1/settings/hardware
|
||||
hit GET /api/v1/settings/capture-service
|
||||
|
||||
echo ""
|
||||
echo "── feature endpoints ─────────────────────────────"
|
||||
hit GET /api/v1/metrics/home
|
||||
hit GET /api/v1/metrics/home?hours=1
|
||||
hit GET /api/v1/schedules
|
||||
hit GET /api/v1/schedules?status=upcoming
|
||||
hit GET /api/v1/sdk
|
||||
|
||||
echo ""
|
||||
echo "── deep-link sanity (one asset) ──────────────────"
|
||||
ASSET_ID=$(curl -s "${API}/api/v1/assets?limit=1" 2>/dev/null \
|
||||
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo " using asset_id=$ASSET_ID"
|
||||
hit GET "/api/v1/assets/$ASSET_ID"
|
||||
hit GET "/api/v1/assets/$ASSET_ID/comments"
|
||||
hit GET "/api/v1/assets/$ASSET_ID/stream"
|
||||
hit GET "/api/v1/assets/$ASSET_ID/thumbnail"
|
||||
else
|
||||
echo " (no assets to deep-link; skipping per-asset endpoints)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── deep-link sanity (one recorder) ───────────────"
|
||||
REC_ID=$(curl -s "${API}/api/v1/recorders" 2>/dev/null \
|
||||
| sed -n 's/.*"id":"\([0-9a-f-]\{36\}\)".*/\1/p' | head -1)
|
||||
if [ -n "$REC_ID" ]; then
|
||||
echo " using recorder_id=$REC_ID"
|
||||
hit GET "/api/v1/recorders/$REC_ID"
|
||||
hit GET "/api/v1/recorders/$REC_ID/status"
|
||||
else
|
||||
echo " (no recorders to deep-link)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "── summary ───────────────────────────────────────"
|
||||
echo " PASS: $PASS"
|
||||
echo " FAIL: $FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
197
deploy/onboard-node.sh
Normal file
197
deploy/onboard-node.sh
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Wild Dragon MAM — Cluster Node Onboarding
|
||||
# =============================================================================
|
||||
#
|
||||
# Provisions a Linux machine as a cluster worker node in one command.
|
||||
#
|
||||
# Quick-start (pipe to bash):
|
||||
# export MAM_API_URL=http://10.0.0.25:47432
|
||||
# export NODE_TOKEN=wd_xxxx # create via Z-AMPP → Admin → Tokens
|
||||
# curl -sL https://forge.wilddragon.net/zgaetano/wild-dragon/raw/branch/main/deploy/onboard-node.sh | bash
|
||||
#
|
||||
# Or run from a cloned repo:
|
||||
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/onboard-node.sh
|
||||
#
|
||||
# Environment variables:
|
||||
# MAM_API_URL REQUIRED Primary MAM API base URL
|
||||
# NODE_TOKEN API bearer token (required if AUTH_ENABLED=true)
|
||||
# NODE_ROLE Role tag reported to the cluster (default: worker)
|
||||
# NODE_IP Override the LAN IP reported back (default: auto-detect)
|
||||
# AGENT_PORT Host port for the node agent (default: 7436)
|
||||
# INSTALL_DIR Where to clone/find the repo (default: /opt/wild-dragon)
|
||||
# PROFILES Extra compose profiles, space-sep e.g. "worker capture"
|
||||
# BMD_MODEL DeckLink card model name (e.g. "DeckLink Duo 2")
|
||||
# REPO_URL Override the Forgejo clone URL
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────────────────────
|
||||
REPO_URL="${REPO_URL:-https://forge.wilddragon.net/zgaetano/wild-dragon.git}"
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/wild-dragon}"
|
||||
MAM_API_URL="${MAM_API_URL:-}"
|
||||
NODE_TOKEN="${NODE_TOKEN:-}"
|
||||
NODE_ROLE="${NODE_ROLE:-worker}"
|
||||
NODE_IP="${NODE_IP:-}"
|
||||
AGENT_PORT="${AGENT_PORT:-7436}"
|
||||
PROFILES="${PROFILES:-}"
|
||||
BMD_MODEL="${BMD_MODEL:-}"
|
||||
PROJECT_NAME="wild-dragon-worker"
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'
|
||||
BLD='\033[1m'; NC='\033[0m'
|
||||
log() { echo -e "${GRN} ✓${NC} $*"; }
|
||||
info() { echo -e "${CYN} ▶${NC} $*"; }
|
||||
warn() { echo -e "${YEL} ⚠${NC} $*"; }
|
||||
header() { echo -e "\n${BLD}${CYN}── $* ──────────────────────────────────────${NC}"; }
|
||||
die() { echo -e "${RED} ✗ ERROR:${NC} $*" >&2; exit 1; }
|
||||
|
||||
# ── Auto-detect LAN IP ───────────────────────────────────────────────────────
|
||||
# Node-agent runs in a container; os.networkInterfaces() inside the container
|
||||
# returns the docker-bridge IP unless we pass NODE_IP through. We resolve the
|
||||
# host's primary LAN IP here so the cluster page shows the right address.
|
||||
detect_lan_ip() {
|
||||
local ip=""
|
||||
if command -v ip &>/dev/null; then
|
||||
ip=$(ip -4 route get 1.1.1.1 2>/dev/null \
|
||||
| awk '/src/ {for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' \
|
||||
|| true)
|
||||
fi
|
||||
if [[ -z "$ip" ]] && command -v hostname &>/dev/null; then
|
||||
ip=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
|
||||
fi
|
||||
echo "$ip"
|
||||
}
|
||||
|
||||
# ── Preflight ────────────────────────────────────────────────────────────────
|
||||
echo -e "\n${BLD}${CYN}Wild Dragon MAM — Cluster Node Onboarding${NC}\n"
|
||||
|
||||
[[ -z "$MAM_API_URL" ]] && die "MAM_API_URL is required.\n\n Example:\n export MAM_API_URL=http://10.0.0.25:47432\n export NODE_TOKEN=wd_xxxx\n ./deploy/onboard-node.sh"
|
||||
|
||||
if [[ -z "$NODE_IP" ]]; then
|
||||
NODE_IP="$(detect_lan_ip)"
|
||||
if [[ -n "$NODE_IP" ]]; then
|
||||
info "Auto-detected LAN IP: $NODE_IP"
|
||||
else
|
||||
warn "Could not auto-detect LAN IP — agent will fall back to interface heuristics."
|
||||
fi
|
||||
fi
|
||||
|
||||
info "Primary API : $MAM_API_URL"
|
||||
info "Role : $NODE_ROLE"
|
||||
info "Agent port : $AGENT_PORT"
|
||||
info "Install dir : $INSTALL_DIR"
|
||||
[[ -n "$NODE_IP" ]] && info "Node IP : $NODE_IP"
|
||||
[[ -n "$BMD_MODEL" ]] && info "DeckLink : $BMD_MODEL"
|
||||
[[ -n "$PROFILES" ]] && info "Profiles : $PROFILES"
|
||||
|
||||
if [[ -z "$NODE_TOKEN" ]]; then
|
||||
warn "NODE_TOKEN is not set."
|
||||
warn "If AUTH_ENABLED=true on the primary, heartbeats will be rejected."
|
||||
warn "Create a token: Z-AMPP web UI → Admin → Tokens → New Token"
|
||||
fi
|
||||
|
||||
# ── Step 1: Docker ───────────────────────────────────────────────────────────
|
||||
header "1/4 Docker"
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
warn "Docker not found — installing via get.docker.com"
|
||||
curl -fsSL https://get.docker.com | bash
|
||||
systemctl enable --now docker 2>/dev/null || true
|
||||
usermod -aG docker "${SUDO_USER:-$USER}" 2>/dev/null || true
|
||||
log "Docker installed"
|
||||
else
|
||||
log "Docker $(docker --version | grep -oP '\d+\.\d+\.\d+' | head -1) already installed"
|
||||
fi
|
||||
|
||||
if ! docker info &>/dev/null; then
|
||||
die "Docker daemon not accessible.\n Try: sudo systemctl start docker\n Or add your user to the docker group and re-login."
|
||||
fi
|
||||
|
||||
# ── Step 2: Repository ───────────────────────────────────────────────────────
|
||||
header "2/4 Repository"
|
||||
|
||||
if [[ -d "$INSTALL_DIR/.git" ]]; then
|
||||
info "Updating $INSTALL_DIR"
|
||||
git -C "$INSTALL_DIR" pull --ff-only
|
||||
log "Repository up to date ($(git -C "$INSTALL_DIR" rev-parse --short HEAD))"
|
||||
else
|
||||
info "Cloning $REPO_URL → $INSTALL_DIR"
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
log "Repository cloned"
|
||||
fi
|
||||
|
||||
# ── Step 3: Environment ──────────────────────────────────────────────────────
|
||||
header "3/4 Configuration"
|
||||
|
||||
ENV_FILE="$INSTALL_DIR/.env.worker"
|
||||
info "Writing $ENV_FILE"
|
||||
|
||||
{
|
||||
echo "# Wild Dragon worker node — generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by onboard-node.sh"
|
||||
echo "MAM_API_URL=$MAM_API_URL"
|
||||
echo "NODE_TOKEN=$NODE_TOKEN"
|
||||
echo "NODE_ROLE=$NODE_ROLE"
|
||||
echo "NODE_IP=$NODE_IP"
|
||||
echo "AGENT_PORT=$AGENT_PORT"
|
||||
echo "HEARTBEAT_MS=30000"
|
||||
[[ -n "$BMD_MODEL" ]] && echo "BMD_MODEL=$BMD_MODEL"
|
||||
for v in REDIS_URL DATABASE_URL S3_ENDPOINT S3_BUCKET S3_ACCESS_KEY S3_SECRET_KEY S3_REGION; do
|
||||
val="${!v:-}"
|
||||
[[ -n "$val" ]] && echo "$v=$val"
|
||||
done
|
||||
} > "$ENV_FILE"
|
||||
|
||||
log "Env file written"
|
||||
|
||||
# ── Step 4: Start services ───────────────────────────────────────────────────
|
||||
header "4/4 Starting services"
|
||||
|
||||
COMPOSE="docker compose -f $INSTALL_DIR/docker-compose.worker.yml --env-file $ENV_FILE --project-name $PROJECT_NAME"
|
||||
|
||||
PROFILE_FLAGS=""
|
||||
for p in $PROFILES; do
|
||||
PROFILE_FLAGS="$PROFILE_FLAGS --profile $p"
|
||||
done
|
||||
|
||||
info "Building images (this may take a minute on first run)…"
|
||||
$COMPOSE build
|
||||
|
||||
info "Starting containers…"
|
||||
# shellcheck disable=SC2086
|
||||
$COMPOSE $PROFILE_FLAGS up -d
|
||||
|
||||
# ── Verify ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
info "Waiting 6 seconds for agent to initialise…"
|
||||
sleep 6
|
||||
|
||||
HEALTH_URL="http://localhost:$AGENT_PORT/health"
|
||||
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
log "Node agent healthy at $HEALTH_URL"
|
||||
REPORTED_IP=$(curl -sf "$HEALTH_URL" | sed -nE 's/.*"ip":"([^"]+)".*/\1/p')
|
||||
[[ -n "$REPORTED_IP" ]] && log "Reporting IP: $REPORTED_IP"
|
||||
else
|
||||
warn "Could not reach $HEALTH_URL — check logs:"
|
||||
warn " $COMPOSE logs node-agent"
|
||||
fi
|
||||
|
||||
# ── Done ─────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BLD}${GRN}Onboarding complete!${NC}"
|
||||
echo ""
|
||||
echo -e " Node agent ${BLD}:$AGENT_PORT${NC} (heartbeating every 30s)"
|
||||
echo -e " Primary API ${BLD}$MAM_API_URL${NC}"
|
||||
echo -e " Role ${BLD}$NODE_ROLE${NC}"
|
||||
[[ -n "$NODE_IP" ]] && echo -e " Node IP ${BLD}$NODE_IP${NC}"
|
||||
echo ""
|
||||
echo -e " ${CYN}Useful commands:${NC}"
|
||||
echo -e " Status : $COMPOSE ps"
|
||||
echo -e " Logs : $COMPOSE logs -f"
|
||||
echo -e " Stop : $COMPOSE down"
|
||||
echo -e " Update : git -C $INSTALL_DIR pull && $COMPOSE build && $COMPOSE up -d"
|
||||
echo ""
|
||||
echo -e " Open the Z-AMPP web UI → ${BLD}Admin → Cluster${NC} to see this node."
|
||||
186
deploy/test-api.sh
Normal file
186
deploy/test-api.sh
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Wild Dragon MAM — API Smoke Test
|
||||
# =============================================================================
|
||||
# Hits every major endpoint and reports pass/fail.
|
||||
#
|
||||
# Usage:
|
||||
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-api.sh
|
||||
# MAM_API_URL=http://10.0.0.25:47432 NODE_TOKEN=wd_xxxx ./deploy/test-api.sh
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${MAM_API_URL:-http://localhost:47432}"
|
||||
TOKEN="${NODE_TOKEN:-}"
|
||||
|
||||
PASS=0; FAIL=0; SKIP=0
|
||||
|
||||
GRN='\033[0;32m'; RED='\033[0;31m'; YEL='\033[1;33m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
|
||||
|
||||
pass() { PASS=$((PASS+1)); echo -e " ${GRN}PASS${NC} $1"; }
|
||||
fail() { FAIL=$((FAIL+1)); echo -e " ${RED}FAIL${NC} $1 ${RED}← $2${NC}"; }
|
||||
skip() { SKIP=$((SKIP+1)); echo -e " ${YEL}SKIP${NC} $1 ${YEL}($2)${NC}"; }
|
||||
header() { echo -e "\n${BLD}$1${NC}"; }
|
||||
|
||||
AUTH_ARGS=()
|
||||
[[ -n "$TOKEN" ]] && AUTH_ARGS+=(-H "Authorization: Bearer $TOKEN")
|
||||
|
||||
# GET — check HTTP status code (no -f so 4xx/5xx are visible as their real code)
|
||||
check_status() {
|
||||
local label="$1" path="$2" want="$3"
|
||||
local got
|
||||
got=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null)
|
||||
[[ -z "$got" ]] && got="000"
|
||||
if [[ "$got" == "$want" ]]; then
|
||||
pass "$label [HTTP $got]"
|
||||
else
|
||||
fail "$label [HTTP $got]" "expected $want"
|
||||
fi
|
||||
}
|
||||
|
||||
# GET — check response body contains literal string (fgrep avoids regex interpretation)
|
||||
check_body() {
|
||||
local label="$1" path="$2" needle="$3"
|
||||
local body
|
||||
body=$(curl -s "${AUTH_ARGS[@]}" "$BASE$path" 2>/dev/null) || { fail "$label" "request failed"; return; }
|
||||
if echo "$body" | grep -qF "$needle"; then
|
||||
pass "$label"
|
||||
else
|
||||
fail "$label" "'$needle' not in response"
|
||||
fi
|
||||
}
|
||||
|
||||
# POST — check HTTP status code
|
||||
check_post() {
|
||||
local label="$1" path="$2" data="$3" want="$4"
|
||||
local got
|
||||
got=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
"${AUTH_ARGS[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST -d "$data" \
|
||||
"$BASE$path" 2>/dev/null)
|
||||
[[ -z "$got" ]] && got="000"
|
||||
if [[ "$got" == "$want" ]]; then
|
||||
pass "$label [HTTP $got]"
|
||||
else
|
||||
fail "$label [HTTP $got]" "expected $want"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${BLD}${CYN}Wild Dragon MAM — API Smoke Test${NC}"
|
||||
echo -e " Base URL : ${BLD}$BASE${NC}"
|
||||
[[ -n "$TOKEN" ]] && echo -e " Auth : Bearer token" || echo -e " Auth : none"
|
||||
echo ""
|
||||
|
||||
# ── Connectivity ─────────────────────────────────────────────────────────────
|
||||
header "Connectivity"
|
||||
CONNECT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/health" 2>/dev/null)
|
||||
if [[ "$CONNECT" == "200" ]]; then
|
||||
pass "API server reachable [/health → 200]"
|
||||
else
|
||||
fail "API server reachable [HTTP $CONNECT]" "cannot reach $BASE"
|
||||
echo -e "\n ${RED}Cannot reach the server — aborting.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Auth ─────────────────────────────────────────────────────────────────────
|
||||
header "Auth"
|
||||
check_status "GET /auth/me" "/api/v1/auth/me" 200
|
||||
check_body "GET /auth/me returns username" "/api/v1/auth/me" '"username"'
|
||||
check_post "POST /auth/login (missing body → 400)" "/api/v1/auth/login" '{}' 400
|
||||
|
||||
# ── Assets ───────────────────────────────────────────────────────────────────
|
||||
header "Assets"
|
||||
check_status "GET /assets" "/api/v1/assets" 200
|
||||
check_body "GET /assets returns assets key" "/api/v1/assets" '"assets"'
|
||||
check_status "GET /assets bogus id → 404" "/api/v1/assets/00000000-0000-0000-0000-000000000000" 404
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
header "Projects"
|
||||
check_status "GET /projects" "/api/v1/projects" 200
|
||||
check_body "GET /projects returns array" "/api/v1/projects" '['
|
||||
|
||||
# ── Jobs ─────────────────────────────────────────────────────────────────────
|
||||
header "Jobs"
|
||||
check_status "GET /jobs" "/api/v1/jobs" 200
|
||||
check_body "GET /jobs returns array" "/api/v1/jobs" '['
|
||||
|
||||
# ── Recorders ────────────────────────────────────────────────────────────────
|
||||
header "Recorders"
|
||||
check_status "GET /recorders" "/api/v1/recorders" 200
|
||||
|
||||
# ── Sequences (requires project_id param) ────────────────────────────────────
|
||||
header "Sequences"
|
||||
check_status "GET /sequences (no project_id → 400)" "/api/v1/sequences" 400
|
||||
check_status "GET /sequences bogus project_id → 200" "/api/v1/sequences?project_id=00000000-0000-0000-0000-000000000000" 200
|
||||
|
||||
# ── Settings ─────────────────────────────────────────────────────────────────
|
||||
header "Settings"
|
||||
check_status "GET /settings/ampp" "/api/v1/settings/ampp" 200
|
||||
|
||||
# ── Cluster ──────────────────────────────────────────────────────────────────
|
||||
header "Cluster"
|
||||
check_status "GET /cluster" "/api/v1/cluster" 200
|
||||
check_body "GET /cluster returns array" "/api/v1/cluster" '['
|
||||
|
||||
# Heartbeat: register a temporary smoke-test node, verify it appears, remove it
|
||||
TEST_HOST="smoke-test-$(date +%s)"
|
||||
check_post "POST /cluster/heartbeat" "/api/v1/cluster/heartbeat" \
|
||||
"{\"hostname\":\"$TEST_HOST\",\"role\":\"smoketest\",\"cpu_usage\":0,\"mem_used_mb\":512,\"mem_total_mb\":4096}" \
|
||||
200
|
||||
|
||||
NODE_ID=$(curl -s "${AUTH_ARGS[@]}" "$BASE/api/v1/cluster" 2>/dev/null \
|
||||
| grep -o '"id":"[^"]*"' | head -1 | grep -o '[0-9a-f-]\{36\}' || true)
|
||||
|
||||
if [[ -n "$NODE_ID" ]]; then
|
||||
pass "Cluster node visible in registry"
|
||||
DEL=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
|
||||
-X DELETE "$BASE/api/v1/cluster/$NODE_ID" 2>/dev/null)
|
||||
[[ "$DEL" == "200" ]] && pass "DELETE /cluster/:id (cleanup) [HTTP $DEL]" \
|
||||
|| fail "DELETE /cluster/:id (cleanup)" "HTTP $DEL"
|
||||
else
|
||||
skip "Cluster node visible in registry" "could not parse node id from response"
|
||||
fi
|
||||
|
||||
# ── System / Containers ───────────────────────────────────────────────────────
|
||||
header "System"
|
||||
check_status "GET /system/containers" "/api/v1/system/containers" 200
|
||||
check_body "Containers returns array" "/api/v1/system/containers" '['
|
||||
|
||||
# ── Capture (proxies to capture service) ─────────────────────────────────────
|
||||
header "Capture"
|
||||
# 200 = capture active and responding
|
||||
# 404 = capture in sidecar/idle mode (no active recorder — expected in dev)
|
||||
# 5xx = capture container unreachable
|
||||
CAPTURE_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${AUTH_ARGS[@]}" \
|
||||
"$BASE/api/v1/capture/status" 2>/dev/null)
|
||||
if [[ "$CAPTURE_CODE" == "200" ]]; then
|
||||
pass "GET /capture/status [HTTP 200 — capture active]"
|
||||
elif [[ "$CAPTURE_CODE" == "404" ]]; then
|
||||
skip "GET /capture/status [HTTP 404]" "capture in idle/sidecar mode (normal when not recording)"
|
||||
elif [[ "$CAPTURE_CODE" =~ ^5 ]]; then
|
||||
skip "GET /capture/status [HTTP $CAPTURE_CODE]" "capture container unreachable"
|
||||
else
|
||||
fail "GET /capture/status [HTTP $CAPTURE_CODE]" "unexpected status"
|
||||
fi
|
||||
|
||||
# ── Users / Tokens ───────────────────────────────────────────────────────────
|
||||
header "Users / Tokens"
|
||||
check_status "GET /users" "/api/v1/users" 200
|
||||
check_status "GET /tokens" "/api/v1/tokens" 200
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
TOTAL=$((PASS + FAIL + SKIP))
|
||||
echo ""
|
||||
echo -e "${BLD}Results:${NC} ${GRN}${PASS} passed${NC} / ${RED}${FAIL} failed${NC} / ${YEL}${SKIP} skipped${NC} / $TOTAL total"
|
||||
echo ""
|
||||
|
||||
if [[ $FAIL -gt 0 ]]; then
|
||||
echo -e "${RED}Some tests failed — check output above.${NC}"
|
||||
exit 1
|
||||
else
|
||||
echo -e "${GRN}All tests passed.${NC}"
|
||||
fi
|
||||
183
deploy/test-cluster.sh
Executable file
183
deploy/test-cluster.sh
Executable file
|
|
@ -0,0 +1,183 @@
|
|||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Wild Dragon MAM — Cluster Smoke Test
|
||||
# =============================================================================
|
||||
#
|
||||
# Validates the cluster end-to-end from any node that can reach the primary.
|
||||
# Designed to be run after `onboard-node.sh` finishes on every worker.
|
||||
#
|
||||
# MAM_API_URL=http://10.0.0.25:47432 ./deploy/test-cluster.sh
|
||||
# MAM_API_URL=... AUTH_TOKEN=wd_xxxx ./deploy/test-cluster.sh
|
||||
#
|
||||
# Checks:
|
||||
# 1. Primary API health
|
||||
# 2. Cluster registry (no duplicate hostnames, IPs are real LAN addresses)
|
||||
# 3. Each worker's /health endpoint
|
||||
# 4. GPU detection (nvidia-smi exits clean on nodes that report GPUs)
|
||||
# 5. NVENC encode probe (5s of synthetic h264_nvenc → /tmp)
|
||||
# 6. Blackmagic device enumeration
|
||||
#
|
||||
# Exit 0 = all pass, 1 = any failure. Failures are logged inline.
|
||||
# =============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MAM_API_URL="${MAM_API_URL:-}"
|
||||
AUTH_TOKEN="${AUTH_TOKEN:-}"
|
||||
|
||||
if [[ -z "$MAM_API_URL" ]]; then
|
||||
echo "✗ MAM_API_URL is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; CYN='\033[0;36m'; BLD='\033[1m'; NC='\033[0m'
|
||||
PASS=0; FAIL=0
|
||||
pass() { echo -e "${GRN} ✓${NC} $*"; PASS=$((PASS+1)); }
|
||||
fail() { echo -e "${RED} ✗${NC} $*"; FAIL=$((FAIL+1)); }
|
||||
note() { echo -e "${CYN} ▶${NC} $*"; }
|
||||
warn() { echo -e "${YEL} !${NC} $*"; }
|
||||
|
||||
api() {
|
||||
local method="${1:-GET}"; shift
|
||||
local path="$1"; shift
|
||||
local args=(-sS -X "$method" -H 'Content-Type: application/json')
|
||||
[[ -n "$AUTH_TOKEN" ]] && args+=(-H "Authorization: Bearer $AUTH_TOKEN")
|
||||
curl "${args[@]}" "$@" "${MAM_API_URL}${path}"
|
||||
}
|
||||
|
||||
echo -e "${BLD}${CYN}Wild Dragon — Cluster Smoke Test${NC}"
|
||||
echo -e "Primary: $MAM_API_URL"
|
||||
echo ""
|
||||
|
||||
# ── 1. Primary API health ───────────────────────────────────────────────
|
||||
echo -e "${BLD}1. Primary API health${NC}"
|
||||
if api GET /health | grep -q '"status":"ok"'; then
|
||||
pass "primary /health responds"
|
||||
else
|
||||
fail "primary /health did not return ok"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── 2. Cluster registry ─────────────────────────────────────────────────
|
||||
echo -e "${BLD}2. Cluster registry${NC}"
|
||||
NODES_JSON=$(api GET /api/v1/cluster || echo '[]')
|
||||
TOTAL=$(echo "$NODES_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
|
||||
note "$TOTAL nodes registered"
|
||||
|
||||
if [[ "$TOTAL" -gt 0 ]]; then
|
||||
# No duplicate hostnames
|
||||
DUP=$(echo "$NODES_JSON" | python3 -c '
|
||||
import sys, json
|
||||
nodes = json.load(sys.stdin)
|
||||
seen = {}
|
||||
dups = []
|
||||
for n in nodes:
|
||||
h = n.get('hostname')
|
||||
if h in seen: dups.append(h)
|
||||
seen[h] = True
|
||||
print(",".join(sorted(set(dups))))' 2>/dev/null)
|
||||
if [[ -z "$DUP" ]]; then
|
||||
pass "no duplicate hostnames"
|
||||
else
|
||||
fail "duplicate hostnames: $DUP — run migration 007"
|
||||
fi
|
||||
|
||||
# No private docker IPs (172.16.0.0/12 reserved for docker bridges)
|
||||
BAD_IPS=""
|
||||
while IFS=$'\t' read -r host ip; do
|
||||
[[ -z "$ip" ]] && continue
|
||||
first="${ip%%.*}"; rest="${ip#*.}"; second="${rest%%.*}"
|
||||
if [[ "$first" == "172" && "$second" == "17" ]]; then
|
||||
BAD_IPS+="${host}=${ip},"
|
||||
fi
|
||||
done < <(echo "$NODES_JSON" | jq -r '.[] | [.hostname, (.ip_address // "")] | @tsv')
|
||||
BAD_IPS="${BAD_IPS%,}"
|
||||
if [[ -z "$BAD_IPS" ]]; then
|
||||
pass "all node IPs are real LAN addresses"
|
||||
else
|
||||
fail "nodes still reporting docker bridge IPs: $BAD_IPS"
|
||||
warn " → set NODE_IP in .env.worker and restart the node-agent"
|
||||
fi
|
||||
|
||||
# All nodes recently seen
|
||||
STALE=$(echo "$NODES_JSON" | python3 -c '
|
||||
import sys, json
|
||||
nodes = json.load(sys.stdin)
|
||||
stale = [n["hostname"] for n in nodes if float(n.get("stale_seconds") or 9999) > 120]
|
||||
print(",".join(stale))' 2>/dev/null)
|
||||
if [[ -z "$STALE" ]]; then
|
||||
pass "all nodes heartbeated within 2 min"
|
||||
else
|
||||
warn "stale nodes (>2 min since heartbeat): $STALE"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── 3. Per-node /health probes ──────────────────────────────────────────
|
||||
echo -e "${BLD}3. Worker agent /health endpoints${NC}"
|
||||
echo "$NODES_JSON" | python3 -c '
|
||||
import sys, json
|
||||
for n in json.load(sys.stdin):
|
||||
if n.get("role") == "primary": continue
|
||||
print(n["id"], n["hostname"], n.get("api_url") or "")
|
||||
' 2>/dev/null | while read -r ID HOST URL; do
|
||||
[[ -z "$URL" ]] && { warn "$HOST: no api_url registered"; continue; }
|
||||
if curl -sf --max-time 4 "$URL/health" >/dev/null 2>&1; then
|
||||
pass "$HOST ($URL/health)"
|
||||
else
|
||||
fail "$HOST agent unreachable at $URL/health"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
|
||||
# ── 4. Local GPU + NVENC probe (when run on a GPU node) ─────────────────
|
||||
echo -e "${BLD}4. Local GPU + NVENC${NC}"
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
GPU_COUNT=$(nvidia-smi --query-gpu=name --format=csv,noheader 2>/dev/null | wc -l)
|
||||
if [[ "$GPU_COUNT" -gt 0 ]]; then
|
||||
pass "$GPU_COUNT NVIDIA GPU(s) visible to host"
|
||||
if command -v ffmpeg >/dev/null 2>&1; then
|
||||
if ffmpeg -hide_banner -loglevel error \
|
||||
-f lavfi -i testsrc=duration=5:size=1280x720:rate=30 \
|
||||
-c:v h264_nvenc -preset p1 -b:v 4M \
|
||||
-t 5 -f null - 2>/tmp/wd-nvenc.log; then
|
||||
pass "NVENC encode test succeeded"
|
||||
else
|
||||
fail "NVENC encode failed — see /tmp/wd-nvenc.log"
|
||||
fi
|
||||
else
|
||||
warn "ffmpeg not installed locally — skipping NVENC encode test"
|
||||
fi
|
||||
else
|
||||
warn "nvidia-smi found but reports 0 GPUs"
|
||||
fi
|
||||
else
|
||||
warn "nvidia-smi not present (not a GPU node)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── 5. Blackmagic device enumeration ────────────────────────────────────
|
||||
echo -e "${BLD}5. Blackmagic devices (cluster-wide)${NC}"
|
||||
BMD_JSON=$(api GET /api/v1/cluster/devices/blackmagic || echo '[]')
|
||||
BMD_COUNT=$(echo "$BMD_JSON" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0)
|
||||
if [[ "$BMD_COUNT" -gt 0 ]]; then
|
||||
pass "$BMD_COUNT DeckLink port(s) registered"
|
||||
echo "$BMD_JSON" | jq -r '.[] | " \(.hostname) port=\(.index) model=\(.model // "unknown") online=\(.online)"'
|
||||
else
|
||||
warn "no DeckLink devices reported by any node"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── 6. Local Blackmagic device files ────────────────────────────────────
|
||||
echo -e "${BLD}6. Local /dev/blackmagic${NC}"
|
||||
if [[ -d /dev/blackmagic ]]; then
|
||||
ls /dev/blackmagic/ | sed 's/^/ /'
|
||||
pass "$(ls /dev/blackmagic/ | wc -l) device node(s) under /dev/blackmagic"
|
||||
else
|
||||
warn "no /dev/blackmagic on this machine"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Summary ─────────────────────────────────────────────────────────────
|
||||
echo -e "${BLD}Summary:${NC} ${GRN}$PASS pass${NC} ${RED}$FAIL fail${NC}"
|
||||
[[ "$FAIL" -gt 0 ]] && exit 1 || exit 0
|
||||
33
docker-compose.gpu.yml
Normal file
33
docker-compose.gpu.yml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Wild Dragon MAM — GPU overlay
|
||||
# Apply on top of docker-compose.yml on nodes with NVIDIA GPUs.
|
||||
#
|
||||
# Prerequisites: NVIDIA Container Toolkit installed on the host.
|
||||
# Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/
|
||||
#
|
||||
# Usage (core MAM node with GPUs):
|
||||
# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
|
||||
#
|
||||
# Usage (worker node with GPUs):
|
||||
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
|
||||
#
|
||||
# This overlay:
|
||||
# - Rebuilds worker from Dockerfile.gpu (CUDA base + ffmpeg NVENC)
|
||||
# - Passes all NVIDIA GPUs into the worker container
|
||||
# - Sets NVENC_ENABLED=true so the worker prioritises h264_nvenc/hevc_nvenc
|
||||
|
||||
services:
|
||||
worker:
|
||||
build:
|
||||
context: ./services/worker
|
||||
dockerfile: Dockerfile.gpu
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
environment:
|
||||
NVENC_ENABLED: "true"
|
||||
NVIDIA_VISIBLE_DEVICES: all
|
||||
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||
130
docker-compose.worker.yml
Normal file
130
docker-compose.worker.yml
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# Wild Dragon MAM — Worker Node Stack
|
||||
# ─────────────────────────────────────
|
||||
# Deploy on any machine you want to join the cluster as a worker.
|
||||
# The primary stack (mam-api, db, redis) continues running on TrueNAS.
|
||||
#
|
||||
# Required env vars (set in .env.worker or export before running):
|
||||
# MAM_API_URL URL of the primary MAM API e.g. http://10.0.0.25:47432
|
||||
# NODE_TOKEN Bearer token from the primary's Tokens page
|
||||
# NODE_IP Host LAN IP to report (set by onboard-node.sh)
|
||||
#
|
||||
# Optional hardware overrides (if Docker can't see /dev directly):
|
||||
# GPU_COUNT Number of NVIDIA GPUs on this node (default: auto-detect from /dev/nvidia*)
|
||||
# BMD_COUNT Number of Blackmagic DeckLink cards (default: auto-detect from /dev/blackmagic/)
|
||||
# BMD_MODEL Marketed card name (e.g. "DeckLink Duo 2") — drives the port-diagram UI
|
||||
#
|
||||
# Optional env vars (needed only if starting the worker or capture profiles):
|
||||
# REDIS_URL, DATABASE_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY
|
||||
# BMD_DEVICE_0 DeckLink device path (default: /dev/blackmagic/dv0)
|
||||
# (DeckLink IO / Quad cards expose /dev/blackmagic/io* instead — set BMD_DEVICE_PREFIX=io)
|
||||
# BMD_DEVICE_1 DeckLink device path (default: /dev/blackmagic/dv1)
|
||||
# BMD_DEVICE_PREFIX Naming prefix for synthesized BMD_COUNT-based devices (default: dv). Use 'io' for IO/Quad.
|
||||
# LIVE_DIR Host path for HLS live segments (default: /mnt/NVME/MAM/wild-dragon-live)
|
||||
#
|
||||
# Profiles:
|
||||
# (default) node-agent only — cluster visibility + hardware heartbeat
|
||||
# --profile worker + CPU/GPU job worker (proxy generation, transcoding)
|
||||
# --profile capture + SDI capture service (requires Blackmagic DeckLink card)
|
||||
#
|
||||
# To enable GPU transcoding, also apply docker-compose.gpu.yml:
|
||||
# docker compose -f docker-compose.worker.yml -f docker-compose.gpu.yml --profile worker up -d
|
||||
#
|
||||
# NOTE: The node-agent mounts /var/run/docker.sock to spawn on-demand SDI
|
||||
# capture sidecars when the primary mam-api routes a recorder to this node.
|
||||
# Build the capture image before first use:
|
||||
# docker compose -f docker-compose.worker.yml build capture
|
||||
|
||||
services:
|
||||
|
||||
# node-agent runs in host network mode so it can see the real host
|
||||
# interfaces, GPU devices and DeckLink cards without bridging tricks.
|
||||
# The reported IP / hostname will be the host's, not the container's.
|
||||
node-agent:
|
||||
build: ./services/node-agent
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
MAM_API_URL: ${MAM_API_URL}
|
||||
NODE_TOKEN: ${NODE_TOKEN:-}
|
||||
NODE_ROLE: ${NODE_ROLE:-worker}
|
||||
NODE_IP: ${NODE_IP:-}
|
||||
AGENT_PORT: ${AGENT_PORT:-7436}
|
||||
HEARTBEAT_MS: ${HEARTBEAT_MS:-30000}
|
||||
GPU_COUNT: ${GPU_COUNT:--1}
|
||||
BMD_COUNT: ${BMD_COUNT:--1}
|
||||
BMD_MODEL: ${BMD_MODEL:-}
|
||||
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
|
||||
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /dev:/dev:ro
|
||||
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro
|
||||
devices:
|
||||
- /dev/blackmagic:/dev/blackmagic
|
||||
|
||||
worker:
|
||||
build: ./services/worker
|
||||
profiles: [worker]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
NVENC_ENABLED: ${NVENC_ENABLED:-false}
|
||||
networks:
|
||||
- wild-dragon-worker
|
||||
|
||||
# SDI capture service — only start on nodes with Blackmagic DeckLink cards
|
||||
# Set BMD_DEVICE_0 in .env.worker to the actual device path, e.g. /dev/blackmagic/dv0
|
||||
capture:
|
||||
build: ./services/capture
|
||||
profiles: [capture]
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
CAPTURE_PORT: 3001
|
||||
devices:
|
||||
- ${BMD_DEVICE_0:-/dev/blackmagic/dv0}:/dev/blackmagic/dv0
|
||||
- ${BMD_DEVICE_1:-/dev/blackmagic/dv1}:/dev/blackmagic/dv1
|
||||
ports:
|
||||
- "${CAPTURE_PORT:-7437}:3001"
|
||||
networks:
|
||||
- wild-dragon-worker
|
||||
|
||||
# worker-l4: HEAVY tier (proxy/conform/trim) on the L4 (NVENC). Talks to
|
||||
# zampp1's Redis/Postgres/S3 over the LAN (.200). No promotion scanner here.
|
||||
worker-l4:
|
||||
build:
|
||||
context: ./services/worker
|
||||
dockerfile: Dockerfile.gpu
|
||||
image: wild-dragon-worker-gpu:latest
|
||||
runtime: nvidia
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
WORKER_QUEUES: proxy,conform,trim
|
||||
PROXY_CONCURRENCY: "3"
|
||||
NVIDIA_VISIBLE_DEVICES: GPU-13acf439-8bf4-a5e0-7804-c1071bca547a
|
||||
WORKER_LABEL: "zampp2 / L4"
|
||||
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||
networks:
|
||||
- wild-dragon-worker
|
||||
|
||||
networks:
|
||||
wild-dragon-worker:
|
||||
driver: bridge
|
||||
|
|
@ -5,6 +5,8 @@ services:
|
|||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "${PORT_DB:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./services/mam-api/src/db/schema.sql:/docker-entrypoint-initdb.d/001-schema.sql:ro
|
||||
|
|
@ -18,6 +20,8 @@ services:
|
|||
|
||||
queue:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "${PORT_REDIS:-6379}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
|
|
@ -35,6 +39,13 @@ services:
|
|||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||
- /mnt/NVME/MAM/wild-dragon-media:/media
|
||||
- /mnt/NVME/MAM/sdk:/sdk
|
||||
- /dev/shm:/dev/shm
|
||||
- /run/dbus:/run/dbus
|
||||
- /run/systemd:/run/systemd
|
||||
- /usr/bin/nvidia-smi:/usr/bin/nvidia-smi:ro
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
|
|
@ -45,7 +56,21 @@ services:
|
|||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
SESSION_SECRET: ${SESSION_SECRET}
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-}
|
||||
DOCKER_NETWORK: wild-dragon_wild-dragon
|
||||
NODE_IP: ${NODE_IP}
|
||||
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
|
||||
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
|
||||
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
|
||||
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
networks:
|
||||
- wild-dragon
|
||||
|
||||
|
|
@ -67,11 +92,21 @@ services:
|
|||
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
|
||||
volumes:
|
||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||
- /dev/shm:/dev/shm
|
||||
- /run/dbus:/run/dbus
|
||||
- /run/systemd:/run/systemd
|
||||
networks:
|
||||
- wild-dragon
|
||||
|
||||
worker:
|
||||
build: ./services/worker
|
||||
# ── GPU worker pool (capability-routed) ──────────────────────────────
|
||||
# worker-p4: HEAVY tier (proxy/conform/trim) on the Tesla P4 (NVENC).
|
||||
# Also runs the promotion scanner (RUN_PROMOTION) — exactly one worker must.
|
||||
worker-p4:
|
||||
build:
|
||||
context: ./services/worker
|
||||
dockerfile: Dockerfile.gpu
|
||||
image: wild-dragon-worker-gpu:latest
|
||||
runtime: nvidia
|
||||
depends_on:
|
||||
- queue
|
||||
- db
|
||||
|
|
@ -83,6 +118,60 @@ services:
|
|||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
GROWING_PATH: /growing
|
||||
# Includes `import` (YouTube importer): the import queue had no consumer
|
||||
# after the capability-routing split, so import jobs sat unprocessed and
|
||||
# assets stayed `ingesting` forever. import is concurrency-1 + network-
|
||||
# bound, so one consumer (this heavy/primary worker) is sufficient.
|
||||
WORKER_QUEUES: proxy,conform,trim,import,playout-stage
|
||||
RUN_PROMOTION: "true"
|
||||
PROXY_CONCURRENCY: "2"
|
||||
PLAYOUT_MEDIA_DIR: /media
|
||||
NVIDIA_VISIBLE_DEVICES: GPU-79afca3e-2ab2-a6ea-1c44-706c1f0a26d6
|
||||
WORKER_LABEL: "zampp1 / Tesla P4"
|
||||
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||
volumes:
|
||||
- /mnt/NVME/MAM/wild-dragon-growing:/growing
|
||||
- /mnt/NVME/MAM/wild-dragon-media:/media
|
||||
networks:
|
||||
- wild-dragon
|
||||
|
||||
# worker-p400a/b: LIGHT tier (thumbnail/filmstrip) on the two Quadro P400s.
|
||||
worker-p400a:
|
||||
image: wild-dragon-worker-gpu:latest
|
||||
runtime: nvidia
|
||||
depends_on: [queue, db, worker-p4]
|
||||
environment:
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
WORKER_QUEUES: thumbnail,filmstrip
|
||||
NVIDIA_VISIBLE_DEVICES: GPU-331c53ea-2ed9-0007-e364-c1451775948f
|
||||
WORKER_LABEL: "zampp1 / P400 #1"
|
||||
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||
networks:
|
||||
- wild-dragon
|
||||
|
||||
worker-p400b:
|
||||
image: wild-dragon-worker-gpu:latest
|
||||
runtime: nvidia
|
||||
depends_on: [queue, db, worker-p4]
|
||||
environment:
|
||||
REDIS_URL: ${REDIS_URL}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
S3_ENDPOINT: ${S3_ENDPOINT}
|
||||
S3_BUCKET: ${S3_BUCKET}
|
||||
S3_ACCESS_KEY: ${S3_ACCESS_KEY}
|
||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||
S3_REGION: ${S3_REGION:-us-east-1}
|
||||
WORKER_QUEUES: thumbnail,filmstrip
|
||||
NVIDIA_VISIBLE_DEVICES: GPU-b514a592-9077-44bd-d9e8-9efa0591ef88
|
||||
WORKER_LABEL: "zampp1 / P400 #2"
|
||||
NVIDIA_DRIVER_CAPABILITIES: video,compute,utility
|
||||
networks:
|
||||
- wild-dragon
|
||||
|
||||
|
|
@ -92,18 +181,21 @@ services:
|
|||
- "${PORT_WEB_UI:-7434}:80"
|
||||
volumes:
|
||||
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||
- /mnt/NVME/MAM/wild-dragon-media:/media:ro
|
||||
- /dev/shm:/dev/shm
|
||||
- /run/dbus:/run/dbus
|
||||
- /run/systemd:/run/systemd
|
||||
networks:
|
||||
- wild-dragon
|
||||
|
||||
|
||||
editor:
|
||||
build: ./services/editor
|
||||
depends_on:
|
||||
- mam-api
|
||||
ports:
|
||||
- "${PORT_EDITOR:-47435}:80"
|
||||
networks:
|
||||
- wild-dragon
|
||||
# Build-only: the CasparCG sidecar image. mam-api spawns these on-demand per
|
||||
# channel (one container per playout channel), so this service is never up'd —
|
||||
# it exists so `docker compose build playout` produces the image the API tags
|
||||
# via PLAYOUT_IMAGE. Profile excludes it from default `up`.
|
||||
playout:
|
||||
profiles: ["build-only"]
|
||||
build: ./services/playout
|
||||
image: wild-dragon-playout:latest
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
|
|
|||
186
docs/DESCRIPTION.md
Normal file
186
docs/DESCRIPTION.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# Dragonflight · Feature Overview
|
||||
|
||||
Dragonflight is a self-hosted broadcast media-asset management system that replaces legacy tools like Grass Valley AMPP and FramelightX. It handles live ingest, growing-file editing, scheduling, transcoding, and asset management in a single operator-focused interface.
|
||||
|
||||
## Home Dashboard
|
||||
|
||||

|
||||
|
||||
The home screen provides quick access to all major features and displays system status at a glance:
|
||||
- **Library** — Browse projects, bins, and assets with hover-scrub previews
|
||||
- **Recorders** — View configured capture devices and their status
|
||||
- **Editor** — Timeline editor with cross-clip preview and render queue
|
||||
- **Jobs** — Proxy and thumbnail queue with retry controls
|
||||
- **Settings** — Configure storage, encoder, growing files, and capture SDK
|
||||
- **Dashboard** — Operations view showing recent activity, job queue, and cluster health
|
||||
|
||||
---
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Live Ingest & Capture
|
||||
**Multi-protocol source capture with per-recorder codec settings**
|
||||
|
||||
Dragonflight ingests from multiple sources simultaneously:
|
||||
- **SRT** (Secure Reliable Transport) — caller and listener modes
|
||||
- **RTMP** — standard streaming protocol
|
||||
- **SDI** — via Blackmagic DeckLink cards with FFmpeg SDK 16.x patches
|
||||
|
||||
Each recorder can be configured with independent codec settings:
|
||||
- ProRes (hi-res masters)
|
||||
- H.264 / H.265 (proxies)
|
||||
- DNxHR (Avid compatibility)
|
||||
|
||||
Audio routing and per-source configuration ensure flexibility for multi-camera productions.
|
||||
|
||||
### 2. Growing-File Editing
|
||||
**Live editing in Premiere Pro while capture is still writing**
|
||||
|
||||
Editors mount the SMB landing zone directly in Premiere Pro and edit the live master file as it's being written. The included CEP (Custom Extension Panel) provides:
|
||||
- Real-time clip detection and frame-accurate trimming
|
||||
- One-click relink to final S3 master after promotion
|
||||
- No waiting for capture to finish before editorial begins
|
||||
|
||||
### 3. Recorder Scheduler
|
||||
**Time-windowed recording automation**
|
||||
|
||||
Schedule recordings with:
|
||||
- One-shot, daily, or weekly recurrence
|
||||
- Automatic start/stop via 15-second tick loop
|
||||
- Conflict detection across recorders
|
||||
- Project and bin assignment at schedule time
|
||||
|
||||
### 4. Library & Asset Management
|
||||
**Browse, search, and organize captured footage**
|
||||
|
||||
The Library screen provides:
|
||||
- Project and bin hierarchy
|
||||
- Asset detail view with frame-anchored persistent comments
|
||||
- Right-click context menu (move-to-bin, rename, delete)
|
||||
- Global cmd/ctrl-K search across assets, projects, recorders, jobs, and users
|
||||
- Hover-scrub preview with HLS playback
|
||||
|
||||
### 5. Jobs Queue
|
||||
**BullMQ-backed proxy and thumbnail generation**
|
||||
|
||||
Automated background processing:
|
||||
- Per-job retry logic with exponential backoff
|
||||
- Bulk "retry all failed" for batch recovery
|
||||
- Inline error messages with actionable diagnostics
|
||||
- Status tracking: ingesting → processing → ready
|
||||
|
||||
Proxy encoder options:
|
||||
- CPU-based: libx264 (H.264)
|
||||
- GPU-accelerated: NVENC (NVIDIA) or VAAPI (AMD/Intel)
|
||||
|
||||
### 6. Timeline Conform & Export
|
||||
**FCP XML export with server-side FFmpeg rendering**
|
||||
|
||||
The Premiere Pro panel exports FCP XML with:
|
||||
- Server-side conform via FFmpeg
|
||||
- Multiple output formats: H.264, H.265, ProRes
|
||||
- Resolution presets: Broadcast, Web, Archive
|
||||
- Batch processing with job queue integration
|
||||
|
||||
### 7. Hi-Res Auto-Relink
|
||||
**One-click batch relink of proxy clips to frame-accurate server-trimmed masters**
|
||||
|
||||
After editing on proxies:
|
||||
- Select clips in Premiere
|
||||
- Trigger relink from the CEP panel
|
||||
- Server trims hi-res segments to exact in/out points
|
||||
- Concurrent trim worker pool for speed
|
||||
- 24-hour TTL with automatic cleanup
|
||||
|
||||
### 8. Settings & Configuration
|
||||
**Centralized control for storage, encoding, and capture**
|
||||
|
||||
Configure:
|
||||
- **S3 Storage** — endpoint, bucket, credentials (with env-var fallback)
|
||||
- **Proxy Encoder** — CPU vs GPU, bitrate, resolution
|
||||
- **Growing Files** — SMB path, retention, auto-promotion
|
||||
- **Capture SDK** — Blackmagic, AJA, or Deltacast uploader selection
|
||||
|
||||
### 9. Cluster & Distributed Capture
|
||||
**Primary + worker topology with remote DeckLink nodes**
|
||||
|
||||
- Primary node runs API, scheduler, and web UI
|
||||
- Worker nodes handle proxy/thumbnail jobs
|
||||
- Remote capture nodes run DeckLink cards off-host
|
||||
- Heartbeat health monitoring
|
||||
- Automatic failover and recovery
|
||||
|
||||
### 10. Admin & User Management
|
||||
**Role-based access, token auth, and cluster monitoring**
|
||||
|
||||
- User creation and role assignment
|
||||
- API token generation for integrations
|
||||
- Container and cluster node status
|
||||
- System health dashboard
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
SDI / SRT / RTMP ──► capture (FFmpeg)
|
||||
├─ HLS preview tee ──► /live/<assetId>/index.m3u8
|
||||
└─ master output
|
||||
├─ growing_enabled=true:
|
||||
│ /growing/<projectId>/<clip>.mov
|
||||
│ (Premiere mounts SMB, edits live)
|
||||
│ └─► promotion worker uploads to S3
|
||||
│
|
||||
└─ growing_enabled=false:
|
||||
multipart stream → S3
|
||||
|
||||
assets POST ──► proxy job ──► worker
|
||||
├─ libx264 (CPU) or NVENC/VAAPI (GPU)
|
||||
├─ thumbnail job
|
||||
└─ status: ingesting → processing → ready
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime:** Node.js 22, Docker Compose
|
||||
- **Backend:** Express, PostgreSQL 16, Redis 7 + BullMQ
|
||||
- **Frontend:** Vanilla React via in-browser Babel (no bundler), hls.js
|
||||
- **Media:** FFmpeg 7.1 with SDK 16 DeckLink patches
|
||||
- **Codecs:** ProRes, H.264, H.265, DNxHR, MOV/MP4/MXF containers
|
||||
- **Storage:** S3-compatible (RustFS) for masters, proxies, thumbnails
|
||||
|
||||
## Services
|
||||
|
||||
| Service | Port | Purpose |
|
||||
|---------|------|---------|
|
||||
| **web-ui** | 47434 | Browser SPA + capture controls |
|
||||
| **mam-api** | 47432 | REST API + recorder orchestration + scheduler |
|
||||
| **capture** | 47433 / 9000 / 1935 | DeckLink/SRT/RTMP ingest sidecar |
|
||||
| **worker** | — | BullMQ proxy + thumbnail workers |
|
||||
| **db** | 5432 | PostgreSQL 16 |
|
||||
| **queue** | 6379 | Redis 7 |
|
||||
|
||||
---
|
||||
|
||||
## Workflow Example: Live-to-Edit
|
||||
|
||||
1. **Operator** schedules a recording on Recorder A for 14:00–15:30, assigns to "News/Segment-A" project
|
||||
2. **Capture** starts at 14:00, writes ProRes master to SMB landing zone
|
||||
3. **Editor** mounts SMB in Premiere, opens the live .mov file via the CEP panel
|
||||
4. **Editor** trims and marks in/out points while capture is still writing
|
||||
5. **Capture** finishes at 15:30, promotion worker uploads master to S3
|
||||
6. **Editor** clicks "Relink to Master" in CEP panel
|
||||
7. **Server** trims hi-res segment to exact in/out, stores for 24 hours
|
||||
8. **Premiere** relinks proxy clips to trimmed master
|
||||
9. **Editor** exports final timeline via FCP XML conform
|
||||
|
||||
Total time from end of capture to relinked master: ~2 minutes.
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
- `deploy/api-smoke.sh` — verify every API endpoint after deploy
|
||||
- `deploy/onboard-node.sh` — provision a remote worker host
|
||||
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
|
||||
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
|
||||
99
docs/GROWING_FILES_QUICKSTART.md
Normal file
99
docs/GROWING_FILES_QUICKSTART.md
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
# Growing Files + Premiere Panel — Test Plan
|
||||
|
||||
A local SMB landing zone for capture so Premiere can edit the master while
|
||||
it is still recording. The promotion worker uploads the finalized file to S3
|
||||
and the panel relinks Premiere to the hi-res original.
|
||||
|
||||
## Cluster state (deployed 2026-05-22)
|
||||
|
||||
- TrueNAS dataset: `NVME/MAM-growing` (LZ4, 0777)
|
||||
- TrueNAS SMB share: `mam-growing` → `/mnt/NVME/MAM-growing`
|
||||
- Host symlink for docker compose: `/mnt/NVME/MAM/wild-dragon-growing` → the dataset
|
||||
- mam-api + worker containers mount it at `/growing`
|
||||
- Settings (live): `growing_enabled=true`, `growing_smb_url=smb://10.0.0.25/mam-growing`
|
||||
|
||||
## Capture flow (when growing_enabled=true)
|
||||
|
||||
1. Recorder starts. mam-api spawns a capture sidecar with `GROWING_ENABLED=true`
|
||||
and binds `/mnt/NVME/MAM/wild-dragon-growing:/growing`.
|
||||
2. FFmpeg writes the hi-res master directly to
|
||||
`/growing/<projectId>/<clipName>.<ext>` (no S3 stream).
|
||||
3. The HLS tee continues to publish `/live/<assetId>/index.m3u8`, so the
|
||||
Recorders + Monitors pages get a real video preview.
|
||||
4. On stop — or when the file's mtime is idle for
|
||||
`growing_promote_after_seconds` — the promotion worker:
|
||||
- uploads the local file to S3 at `projects/<projectId>/masters/<clipName>.<ext>`
|
||||
- queues a proxy job
|
||||
- flips the asset to `status=ready`
|
||||
- deletes the local copy.
|
||||
|
||||
## Premiere panel install
|
||||
|
||||
Grab the latest release artifact and run it — the installer handles the file
|
||||
copy, registry/plist debug-mode flip, and removes any legacy
|
||||
`com.wilddragon.mam.panel` install:
|
||||
|
||||
- **Windows:** `dragonflight-premiere-panel-<version>-windows-setup.exe`
|
||||
- **macOS / Win:** `dragonflight-premiere-panel-<version>.zxp` — install via
|
||||
[Anastasiy's ZXP Installer](https://install.anastasiy.com/) (free GUI)
|
||||
|
||||
Releases live at
|
||||
<https://forge.wilddragon.net/zgaetano/dragonflight/releases>.
|
||||
|
||||
Building locally (requires Windows for the `.exe`, any OS for the `.zxp`):
|
||||
|
||||
```
|
||||
cd services/premiere-plugin/build
|
||||
npm install
|
||||
powershell -File build-all.ps1 # or: node build-zxp.mjs
|
||||
```
|
||||
|
||||
The Windows installer takes care of `PlayerDebugMode`. If you installed the
|
||||
ZXP and the panel does not appear in **Window → Extensions**, enable debug
|
||||
mode manually:
|
||||
|
||||
```
|
||||
# macOS
|
||||
defaults write com.adobe.CSXS.11 PlayerDebugMode 1
|
||||
|
||||
# Windows
|
||||
reg add "HKCU\Software\Adobe\CSXS.11" /v PlayerDebugMode /t REG_SZ /d 1 /f
|
||||
```
|
||||
|
||||
Mount the SMB share once at OS level: `smb://10.0.0.25/mam-growing`.
|
||||
|
||||
In Premiere: Window → Extensions → Wild Dragon MAM.
|
||||
|
||||
## Test the live → finalized swap
|
||||
|
||||
1. Start a recorder (Ingest → Recorders → Record).
|
||||
2. The Recorder row's preview becomes a real HLS `<video>` element.
|
||||
3. In Premiere, with the growing asset selected (status=live), click
|
||||
**Mount Live**. The panel calls `GET /api/v1/assets/:id/live-path`,
|
||||
resolves the SMB UNC path, and `app.project.importFiles()` it. Premiere
|
||||
imports the still-growing MOV.
|
||||
4. Stop the recorder. After `growing_promote_after_seconds` of mtime
|
||||
inactivity, the promotion worker uploads to S3 and flips status.
|
||||
5. The panel polls every 5 s. When it sees `status=ready` it surfaces
|
||||
**Relink to Hi-Res** — clicking that downloads the finalized hi-res
|
||||
and calls `ProjectItem.changeMediaPath()` to relink in place. Timeline
|
||||
cuts are preserved.
|
||||
|
||||
## Knobs (Settings → Growing files (SMB))
|
||||
|
||||
- `growing_enabled` — master switch
|
||||
- `growing_path` — container mount path (default `/growing`)
|
||||
- `growing_smb_url` — what the Premiere panel hands to the editor
|
||||
- `growing_promote_after_seconds` — idle threshold for promotion
|
||||
|
||||
## What's NOT yet here
|
||||
|
||||
- Auth on the SMB share — currently passwordless. Add Samba auth via
|
||||
`midclt call sharing.smb.update` and put creds in the editor's keychain
|
||||
before exposing this beyond the LAN.
|
||||
- Concurrent S3 backup of the growing file. Today's MVP writes to SMB only;
|
||||
S3 happens at promotion. If you need belt-and-suspenders, add `-f tee` in
|
||||
capture-manager to fan out to both destinations.
|
||||
- Cleanup for stranded files (e.g. recorder crashes mid-capture). The idle
|
||||
threshold will eventually promote them, but a stale-file sweeper would be
|
||||
more graceful.
|
||||
116
docs/WORK_LOG_2026_05.md
Normal file
116
docs/WORK_LOG_2026_05.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Work Log — May 2026
|
||||
|
||||
## Summary
|
||||
Session focused on auth system architecture, dashboard redesign, and audio track inspector. Multiple iterations on auth approach; settled on simplified local-account model with RBAC. Dashboard rebuilt as control-room status board. Audio tab completed with full metering and fader controls.
|
||||
|
||||
## Auth System Work
|
||||
|
||||
### Commits
|
||||
- `002e5ac` — auth: top-to-bottom rework — local accounts, RBAC + client tag, audit log, env-bootstrap
|
||||
- `d1f9557` — auth: park login flow — circle back
|
||||
- `9726dbb` — Revert "auth: top-to-bottom rework..."
|
||||
- `4172b0d` — rip out entire auth/login flow
|
||||
|
||||
### What Happened
|
||||
Attempted comprehensive auth rewrite including:
|
||||
- Local user account system with bcrypt hashing
|
||||
- Role-based access control (admin/editor/viewer)
|
||||
- Client tagging for audit trails
|
||||
- Environment-based bootstrap (AUTH_ENABLED flag)
|
||||
- Session management with PostgreSQL backing
|
||||
|
||||
**Decision**: Reverted entire auth work. Reason: complexity vs. current product stage. System was over-engineered for self-hosted use case where auth is optional.
|
||||
|
||||
**Current State**: Auth disabled by default (AUTH_ENABLED=false). When enabled, system returns synthetic /auth/me endpoint. No persistent user management yet.
|
||||
|
||||
### Related Fixes
|
||||
- `cfcbec0` — fix(auth): make AUTH_ENABLED=true workable end-to-end
|
||||
- `e71c330` — fix(auth): remove manual session.save() — was suppressing Set-Cookie header
|
||||
- `65684aa` — fix(auth): ensure sessions table exists + log session.save errors
|
||||
|
||||
## Dashboard Redesign
|
||||
|
||||
### Commits
|
||||
- `a48e1d9` — dashboard: rebuild as control-room status board (on air / up next / attention / work)
|
||||
- `e5e0656` — dashboard: redesign stat cards, compress header, improve density
|
||||
- `5de1e3d` — dashboard: add dense stat cards, cluster bars, job rows, sparkline fixes
|
||||
- `48d54a3` — dashboard: add missing dash-* CSS classes; cluster: add stat-row/stat-card CSS
|
||||
|
||||
### What Changed
|
||||
Transformed dashboard from generic metrics view to broadcast control-room interface:
|
||||
- **On Air** section: live stream status, bitrate, duration
|
||||
- **Up Next** section: queued clips/segments
|
||||
- **Attention** section: warnings, errors, resource alerts
|
||||
- **Work** section: active jobs, encoding progress
|
||||
|
||||
Added visual components:
|
||||
- Dense stat cards with icon + value + trend
|
||||
- Cluster health bars (CPU, memory, disk per node)
|
||||
- Job progress rows with ETA
|
||||
- Sparkline charts for trend visualization
|
||||
|
||||
CSS infrastructure added for consistent spacing/sizing across dashboard components.
|
||||
|
||||
## Audio Tab Implementation
|
||||
|
||||
### Commit
|
||||
- `c48c7e6` — feat(audio-tab): full audio track inspector with meters, mute/solo, faders
|
||||
|
||||
### Features
|
||||
- Per-track audio meters (VU-style, real-time)
|
||||
- Mute/solo buttons per track
|
||||
- Fader controls (0-100 dB range)
|
||||
- Master output meter
|
||||
- Track naming/labeling
|
||||
- Visual feedback for clipping/peaks
|
||||
|
||||
## Other Work
|
||||
|
||||
### Storage & Admin
|
||||
- `64d739b` — feat(admin): unified Storage settings page with mount/bucket health diagnostics
|
||||
- `a44d8bd` — feat(admin): live video-presence indicators on cluster DeckLink ports
|
||||
|
||||
### Player & Streaming
|
||||
- `a86c1c7` — fix(player): stitch S3 ranges around RustFS empty-body bug (#143)
|
||||
- `d257a19` — fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
|
||||
- `37247fd` — fix(video): direct S3 signed URL for streaming + proxy bitrate 1.5Mbps
|
||||
- `e4d4c00` — feat(proxy): VBR 500k-1M encoding for proxy generation
|
||||
|
||||
### Cluster & Hardware
|
||||
- `55ff2e7` — feat(cluster): full hardware breakdown per node
|
||||
- `5ff507b` — fix(node-agent): use nsenter to run nvidia-smi in host mount namespace
|
||||
- `558c18e` — fix(node-agent): detect GPUs via docker run --gpus all ubuntu:22.04
|
||||
- `a6f045b` — fix(node-agent): probe GPU via Docker API async at startup, cache result
|
||||
|
||||
### Release & Cleanup
|
||||
- `04ce096` — chore: 1.2 ship-prep sweep — close 38 issues
|
||||
- `f0f6156` — release: add v1.1.0 ZXP artifact (Growing tab + visual system alignment)
|
||||
|
||||
## Blockers / Open Questions
|
||||
|
||||
### Auth System
|
||||
- **Decision needed**: Should auth be mandatory for production? Current design assumes optional.
|
||||
- **API endpoints missing**: `/users`, `/auth/me`, `/groups` routes not yet implemented in mam-api
|
||||
- **Frontend expects**: Users list, groups management, role-based UI filtering
|
||||
|
||||
### Dashboard
|
||||
- Real data integration needed (currently mock data)
|
||||
- Cluster stats endpoint integration
|
||||
- Job queue polling/WebSocket updates
|
||||
|
||||
### Audio
|
||||
- Backend audio processing pipeline not yet connected
|
||||
- Metering data source undefined
|
||||
- Fader changes need routing to encoder
|
||||
|
||||
## Next Steps (Recommended)
|
||||
|
||||
1. **Clarify auth requirements**: Is user management needed for v1.2? If yes, implement `/users` and `/groups` endpoints.
|
||||
2. **Connect dashboard to live data**: Wire cluster stats, job queue, stream status to real endpoints.
|
||||
3. **Audio backend integration**: Define audio processing pipeline and metering data flow.
|
||||
4. **Testing**: Add integration tests for auth flow, dashboard data binding, audio control.
|
||||
|
||||
---
|
||||
|
||||
**Session ended**: 2026-05-27 06:31 CDT
|
||||
**Status**: Work logged, auth decision documented, next steps identified
|
||||
197
docs/design/2026-05-29-all-intra-hevc-ingest.md
Normal file
197
docs/design/2026-05-29-all-intra-hevc-ingest.md
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# All-Intra HEVC (NVENC) Growing-File Ingest
|
||||
|
||||
Date: 2026-05-29 | Status: design, pending validation gate (see §8)
|
||||
Authors: Zac + Claude
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
Replace the CPU-bound ProRes capture encode with **All-Intra HEVC on NVENC**
|
||||
as the growing-file master codec, so we can:
|
||||
|
||||
- **Offload ingest encode from CPU to GPU** (the current scaling wall), and
|
||||
- **Keep edit-while-record** (all-intra => growing file stays editable), and
|
||||
- **Scale to up to 8 simultaneous signals per machine**, across Blackmagic
|
||||
today and Deltacast + AJA later.
|
||||
|
||||
This doc captures the target design AND the current working system it builds on,
|
||||
so it is self-contained for whoever implements it.
|
||||
|
||||
## 2. Why this codec
|
||||
|
||||
Growing-file editing (Premiere/Avid mounting a still-recording file over SMB)
|
||||
requires two things: **intra-frame** (every frame a keyframe, so a partial file
|
||||
is decodable to the last whole frame) and a **container whose index is not
|
||||
deferred to EOF**. ProRes/DNxHR satisfy this but are CPU-only (NVIDIA has no
|
||||
ProRes encoder). Long-GOP H.264/HEVC/AV1 do NOT work for edit-while-record.
|
||||
|
||||
**All-Intra HEVC (`-g 1 -bf 0`) via `hevc_nvenc`** is the one path that is both
|
||||
GPU-accelerated AND all-intra: it breaks the "ProRes must be CPU" constraint
|
||||
without losing edit-while-record. Trade-off: All-Intra bitrate approaches
|
||||
ProRes, so the win is **CPU offload, not storage**. AV1 is rejected (no NLE
|
||||
edit support; av1_nvenc absent from our ffmpeg builds).
|
||||
|
||||
## 3. Current working system (what we build on)
|
||||
|
||||
### Topology
|
||||
- **zampp1** (172.18.91.200): primary. Runs db (postgres), queue (redis),
|
||||
mam-api (:47432), web-ui (:47434), and the GPU worker pool. GPUs: Tesla P4 +
|
||||
2x Quadro P400. Repo at /opt/wild-dragon (its own clone).
|
||||
- **zampp2** (172.18.91.216): worker/capture node. 12-vCPU QEMU VM, NVIDIA L4,
|
||||
4x Blackmagic DeckLink (exposed as /dev/blackmagic/io0..io3). Runs node-agent
|
||||
(:7436). Repo at /opt/wild-dragon (separate clone).
|
||||
- The repo is checked out independently on BOTH nodes; node-specific files
|
||||
(node-agent, capture, worker overlay) are edited on the node that runs them.
|
||||
|
||||
### Capture (current)
|
||||
mam-api `POST /recorders/:id/start` pre-creates a `live` asset and dispatches
|
||||
`POST /sidecar/start` to the recorder's node-agent, which spawns a
|
||||
`wild-dragon-capture:latest` container (host network, privileged,
|
||||
/dev/blackmagic bound). The capture ffmpeg:
|
||||
- input: `-f decklink -i "DeckLink Duo (N)"`
|
||||
- filter: `yadif` (CPU deinterlace)
|
||||
- output 0 (master): `prores_ks` (CPU) -> S3 (pipe) or growing SMB file
|
||||
- output 1 (preview): `libx264` veryfast HLS -> /live/{assetId} (CPU)
|
||||
DeckLink does capture (cheap); BOTH encodes are CPU. ~5 vCPU per 1080i signal
|
||||
=> ~2 signals saturate the 12-vCPU VM. GPUs are idle during capture.
|
||||
|
||||
### Stop / finalize (working)
|
||||
node-agent stops the sidecar with a **180s grace** (was 10s -> SIGKILL bug).
|
||||
Capture's SIGTERM handler finalises the session and calls
|
||||
`POST /assets/:id/finalize` (the live asset id passed as ASSET_ID), which flips
|
||||
the asset out of `live`, records duration + S3 keys, and kicks the
|
||||
proxy -> thumbnail -> filmstrip chain. (Earlier 409 bug: it used to POST a new
|
||||
asset and collide with the live row.)
|
||||
|
||||
### Live monitor (working)
|
||||
SDI HLS preview is a 2nd output of the capture ffmpeg (one DeckLink read ->
|
||||
split -> ProRes + H.264 HLS), written to /live/{assetId} on the capture node.
|
||||
node-agent serves GET /live/* over HTTP; mam-api proxies
|
||||
GET /api/v1/recorders/:id/live/* to the recorder's node-agent; the web-ui
|
||||
HlsPreview loads the proxied URL. Browser auth is the session cookie
|
||||
(same-origin).
|
||||
|
||||
### GPU worker pool (working, post-capture)
|
||||
BullMQ on shared Redis; queues are type-named (proxy/thumbnail/filmstrip/
|
||||
conform/trim). Workers are capability-routed by `WORKER_QUEUES`, one GPU-pinned
|
||||
container per card (`NVIDIA_VISIBLE_DEVICES` by UUID):
|
||||
- HEAVY (proxy/conform/trim): Tesla P4 (zampp1) + L4 (zampp2), `h264_nvenc`.
|
||||
- LIGHT (thumbnail/filmstrip): 2x Quadro P400 (zampp1).
|
||||
DB setting `gpu_transcode_enabled=true` + `gpu_codec=h264_nvenc` enable NVENC.
|
||||
Each worker stamps `WORKER_LABEL` onto job data -> Jobs UI "Node" column.
|
||||
`RUN_PROMOTION=true` on exactly one worker runs the growing-files->S3 scan.
|
||||
The worker GPU image is built from services/worker/Dockerfile.gpu (CUDA base +
|
||||
Ubuntu ffmpeg with h264/hevc_nvenc; NO av1_nvenc).
|
||||
|
||||
### Deploy gotchas (learned)
|
||||
- Service source is BAKED into images; edits need rebuild + recreate (or the
|
||||
GPU image rebuild reuses cached layers so only final COPY changes -> fast).
|
||||
- The capture image can only build on zampp2 (DeckLink SDK present there).
|
||||
- Per-node `.env`: zampp2's REDIS_URL/DATABASE_URL/S3_* now point at zampp1
|
||||
(.200); secrets live only in .env, never in committed compose.
|
||||
- Clear all containers on both nodes before a full redeploy (user preference).
|
||||
|
||||
## 4. Target design
|
||||
|
||||
### 4.1 Capture ffmpeg gains NVENC
|
||||
The capture image's custom FFmpeg 7.1 is currently built WITHOUT nvenc (only
|
||||
prores_ks/dnxhd/libx264). Rebuild `services/capture/Dockerfile` ffmpeg with:
|
||||
`--enable-cuda-nvcc --enable-libnpp --enable-nvenc --enable-cuvid` plus
|
||||
nv-codec-headers (ffnvcodec) installed before configure. Keep `--enable-decklink`
|
||||
and the existing codecs (ProRes stays available as a selectable fallback).
|
||||
Verify `ffmpeg -encoders | grep nvenc` shows hevc_nvenc/h264_nvenc afterwards.
|
||||
|
||||
### 4.2 Capture sidecar gets a GPU
|
||||
node-agent `handleSidecarStart` currently spawns the capture container with no
|
||||
GPU. Add NVIDIA runtime + device pinning to the sidecar create spec:
|
||||
`HostConfig.Runtime='nvidia'` (or DeviceRequests with the node's GPU) and env
|
||||
`NVIDIA_VISIBLE_DEVICES=<uuid>` + `NVIDIA_DRIVER_CAPABILITIES=video,compute,utility`.
|
||||
The capture node's GPU is shared with its worker-l4 (see capacity, §5).
|
||||
|
||||
### 4.3 Encode parameters (master)
|
||||
All-Intra HEVC on NVENC:
|
||||
`-c:v hevc_nvenc -preset p4 -rc vbr -g 1 -bf 0 -profile:v main10 -pix_fmt p010le` (10-bit 4:2:2 is not NVENC-native; NVENC HEVC is 4:2:0 8/10-bit.
|
||||
If 4:2:2 mezzanine is required, that is a HARD blocker for NVENC and we stay on
|
||||
ProRes for those feeds — see §8). Bitrate target tuned per format (1080i59.94
|
||||
~100-160 Mbps to rival ProRes HQ). `-g 1 -bf 0` => every frame IDR (all-intra).
|
||||
|
||||
### 4.4 Container (growing-file)
|
||||
Write the master to a growing file on the SMB share (GROWING_PATH), same path
|
||||
the promotion worker already uploads on EOF. Container candidates, in order of
|
||||
preference for Premiere growing-file mounts:
|
||||
1. **MXF OP1a** (`-f mxf`) — broadcast standard, designed for growing/edit-while-
|
||||
ingest; best Avid/Premiere support. HEVC-in-MXF support in Premiere is the
|
||||
key unknown to validate (§8).
|
||||
2. **Fragmented MOV/MP4** (`-movflags +frag_keyframe+empty_moov+default_base_moof`)
|
||||
— no moov-at-EOF, readable while growing; fallback if MXF+HEVC is unsupported.
|
||||
The HLS preview path is unchanged except it can also move to h264_nvenc now that
|
||||
capture has NVENC (frees the last libx264 CPU cost).
|
||||
|
||||
## 5. Capacity & scaling (8 signals/machine)
|
||||
|
||||
After the move, per-signal CPU is just: DeckLink capture + yadif + mux + frame
|
||||
upload to the GPU. The heavy HEVC encode is on NVENC. The constraint shifts from
|
||||
CPU to **NVENC throughput + GPU memory + PCIe/host bandwidth**:
|
||||
- The **L4 is a datacenter card => unlimited NVENC sessions** (no consumer
|
||||
3-session cap). 8x 1080i HEVC-I encode sessions are well within an L4.
|
||||
- GPU memory: ~8 concurrent 1080 NVENC sessions + frame buffers fit in 24 GB.
|
||||
- The capture node's L4 is shared between capture (per-signal HEVC-I) and the
|
||||
worker-l4 proxy jobs. Under 8-signal load, give capture priority; consider
|
||||
moving worker-l4 (post-record proxies) to zampp1's P4 only, or gate worker-l4
|
||||
intake while signals are live.
|
||||
- yadif on CPU is still ~0.5-1 vCPU/signal; consider `yadif_cuda`/`bwdif_cuda`
|
||||
(GPU deinterlace) once frames are uploaded to the GPU, keeping CPU near-idle.
|
||||
|
||||
**Node sizing:** a 12-vCPU VM was the ProRes wall; with GPU encode the same VM
|
||||
should carry many more signals, but for 8x SDI + GPU + card passthrough prefer a
|
||||
larger VM or bare metal with proper PCIe passthrough. Or spread signals across
|
||||
multiple capture nodes (the node-agent model already supports N nodes; mam-api
|
||||
routes each recorder to its node).
|
||||
|
||||
## 6. Multi-vendor capture (Blackmagic / Deltacast / AJA)
|
||||
|
||||
Today capture is hard-wired to `-f decklink`. Before three vendors accrue
|
||||
special-cases, introduce a **source-backend abstraction** in capture-manager:
|
||||
each backend returns ffmpeg input args + device discovery.
|
||||
- **Blackmagic**: `-f decklink -i "<name>"` (current). Devices via
|
||||
`ffmpeg -sources decklink`.
|
||||
- **Deltacast**: VideoMaster SDK. No native ffmpeg demuxer upstream — needs an
|
||||
SDK-backed capture (their SDK -> pipe to ffmpeg, or a small grabber). Plan a
|
||||
`deltacast` backend that shells their tool into ffmpeg stdin (rawvideo).
|
||||
- **AJA**: libajantv2. Also no upstream ffmpeg input; AJA ships `ntv2` capture
|
||||
tools. Plan an `aja` backend feeding rawvideo into ffmpeg.
|
||||
All backends converge on the SAME encode/output stage (HEVC-I NVENC + HLS), so
|
||||
only the input differs. node-agent already binds the right /dev nodes per
|
||||
sourceType (decklink/deltacast); extend for AJA.
|
||||
|
||||
## 7. Risks
|
||||
|
||||
- **4:2:2 / 10-bit chroma:** NVENC HEVC is 4:2:0 (8/10-bit). ProRes HQ is 4:2:2
|
||||
10-bit. If a workflow REQUIRES 4:2:2 mezzanine, NVENC HEVC cannot match it and
|
||||
those feeds stay on ProRes (CPU). Decide per-workflow.
|
||||
- **Premiere growing HEVC support:** edit-while-record for HEVC-in-MXF (or frag
|
||||
MOV) is unproven in our stack — this is the make-or-break validation (§8).
|
||||
- **GPU contention** between live capture and post-record proxies on the same
|
||||
L4; mitigate by prioritising capture / relocating proxy load.
|
||||
- **Storage:** All-Intra HEVC bitrate ~ ProRes; expect similar disk usage.
|
||||
- **Editor performance:** HEVC-I decode in Premiere is heavier than ProRes on
|
||||
the edit workstation (decode cost moves to the editor). Validate scrubbing.
|
||||
- **NVENC quality at all-intra** vs ProRes for archival; tune bitrate/preset.
|
||||
|
||||
## 8. Validation gate (do FIRST, before building the pipeline)
|
||||
|
||||
Prove the editor story on ONE channel before wiring 8:
|
||||
1. Rebuild capture ffmpeg with NVENC; give the sidecar the L4.
|
||||
2. Capture one DeckLink feed to All-Intra HEVC, writing a GROWING file to the
|
||||
SMB share in (a) MXF OP1a, then (b) fragmented MOV.
|
||||
3. While still recording, mount it in Premiere over SMB and confirm:
|
||||
edit-while-record works, scrubbing is acceptable, audio in sync, file remains
|
||||
valid after stop. Pick the container that works; if neither does, HEVC-I is
|
||||
capture-only (no growing edit) and we keep ProRes for growing workflows.
|
||||
|
||||
## 9. Rollout
|
||||
1. Validation gate (§8) on one channel.
|
||||
2. Make capture codec/container a recorder setting; default growing feeds to
|
||||
HEVC-I NVENC, keep ProRes selectable.
|
||||
3. Move HLS preview to h264_nvenc.
|
||||
4. Source-backend abstraction (§6) — land before Deltacast/AJA hardware.
|
||||
5. GPU deinterlace + capacity test to 8 signals; finalise node sizing.
|
||||
BIN
docs/screenshots/01-home.png
Normal file
BIN
docs/screenshots/01-home.png
Normal file
Binary file not shown.
57
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md
Normal file
57
docs/superpowers/plans/2026-05-21-cluster-codec-revamp.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Cluster Hardening + Codec Settings Revamp
|
||||
|
||||
> Status: **mostly shipped 2026-05-21**. One follow-up remains: the recorders.html UI rewrite. See "Pending" below.
|
||||
|
||||
## Goal
|
||||
Four user-driven asks from 2026-05-20:
|
||||
1. Fix cluster page: workers were registering with docker bridge IPs, and three duplicate "zampp2" rows kept appearing.
|
||||
2. Expand recorder codec settings: per-recorder control over bitrate, framerate, audio channels, container format.
|
||||
3. Better DeckLink port picker: "BM1/BM2" dropdown was unusable -- diagram the card so operators pick a port visually.
|
||||
4. Validate the cluster end-to-end now that GPUs are in place.
|
||||
|
||||
## What shipped (commit list)
|
||||
| Commit | Area | Summary |
|
||||
|---|---|---|
|
||||
| `a39c983` | mam-api | Migration 007 -- dedupe `cluster_nodes` rows + unique index on `hostname`. |
|
||||
| `049beb8` | mam-api | Migration 008 -- expanded codec columns on `recorders` (video/audio bitrate, framerate, audio channels, container, plus `node_id` / `device_index` pinning). |
|
||||
| `3b4af6e` | node-agent | Prefer `NODE_IP` env override; skip docker bridge / veth / cni interfaces when auto-detecting. Version bumped to 1.1.0. |
|
||||
| `0efef0d` | mam-api | `routes/cluster.js`: `pickIp()` fallback to request source IP. New `GET /api/v1/cluster/devices/blackmagic` flattens every node's DeckLink capabilities. |
|
||||
| `40a66ba` | compose | `docker-compose.worker.yml`: `network_mode: host` for node-agent so it inherits host hostname + LAN IP. |
|
||||
| `0ebb3cf` | deploy | `onboard-node.sh`: auto-detect host LAN IP and write `NODE_IP` + `BMD_MODEL` to `.env.worker`. |
|
||||
| `f4a83ee` | capture | `capture-manager.js`: dynamic ffmpeg args. Exports `VIDEO_CODECS`, `AUDIO_CODECS`, `CONTAINER_FMT`, `CONTAINER_EXT`. |
|
||||
| `485af25` | capture | `index.js` bootstrap forwards every codec env var to `captureManager.start()`. |
|
||||
| `4c65753` | mam-api | `routes/recorders.js`: full codec field whitelist; `/start` passes settings to the capture sidecar. |
|
||||
| `d39f86d` | web-ui | `services/web-ui/public/js/bmd-card.js` -- SVG renderer for DeckLink port selection. Models: Duo 2, Quad 2, Mini Recorder 4K, Mini Monitor 4K, UltraStudio 4K Mini. |
|
||||
| `8aa3783` | deploy | `deploy/test-cluster.sh` cluster smoke test. |
|
||||
| `4a3a672` | cluster | `mam-api` self-heartbeat reads `NODE_HOSTNAME` (otherwise every restart spawns a new primary row). Smoke test rewritten with `jq` after Python f-strings were found to silently false-pass the docker-bridge check. Bridge alarm narrowed to 172.17.x since this LAN occupies 172.18.0.0/16. |
|
||||
|
||||
## Verified cluster state (post-deploy, 2026-05-21)
|
||||
```
|
||||
$ MAM_API_URL=http://localhost:47432 bash deploy/test-cluster.sh
|
||||
6 pass 0 fail
|
||||
```
|
||||
Two nodes registered, no duplicate hostnames, real LAN IPs (zampp1=172.18.91.216 primary, zampp2=172.18.91.217 worker), fresh heartbeats, 3 NVIDIA GPUs visible on zampp1, DeckLink Duo 2 reporting all 4 ports on zampp2.
|
||||
|
||||
## Deploy state
|
||||
- **zampp1**: at `4a3a672`, rebuilt `mam-api`/`web-ui`/`worker`/`capture`, migrations 007+008 applied at startup. `.env` has `NODE_HOSTNAME=zampp1`, `NODE_IP=172.18.91.216`.
|
||||
- **zampp2**: at `4a3a672`, rebuilt `node-agent` + `worker`. `.env` has `NODE_IP=172.18.91.217`, `BMD_COUNT=4`, `BMD_MODEL="DeckLink Duo 2"`, `BMD_DEVICE_0..3` populated.
|
||||
- **Forgejo PAT** is at `/root/.git-credentials` on zampp1 (mode 600). Pushes from zampp1 need `HOME=/root`.
|
||||
|
||||
## LAN topology gotcha
|
||||
The user's LAN is **172.18.91.0/24** -- inside Docker's reserved 172.16.0.0/12 range. Any heuristic that flags all of 172.16-172.31 as "docker bridge" will produce false positives. The smoke test now alarms only on 172.17.x (default docker0). The server-side `pickIp()` in `routes/cluster.js` has the same vulnerability but the node-agent's `NODE_IP` env-var override masks it in practice.
|
||||
|
||||
## Pending
|
||||
- [ ] **`services/web-ui/public/recorders.html` rewrite.** The supporting pieces are in `main` but the HTML wiring was lost to a context-compaction event mid-session. Required UI:
|
||||
- Tabbed codec settings (Video / Audio / Container) for both master and proxy.
|
||||
- SDI source picker: node dropdown + inline `BMDCards.render(...)` SVG with click-to-select.
|
||||
- Load BMD card data from `GET /api/v1/cluster/devices/blackmagic`.
|
||||
- `<script src="js/bmd-card.js?v=1"></script>` in the head.
|
||||
- SVG styles (`.bmd-card-svg`, `.bmd-port-ring`, `.bmd-port-group.is-selected`, ...) inlined or split into a CSS file.
|
||||
- [ ] **Visual polish pass** with flyonui MCP -- the user noted the current UI "still looks AI-designed". Should happen AFTER the recorders.html rewrite.
|
||||
|
||||
## How to pick this up
|
||||
1. `cd /opt/wild-dragon && git pull` on zampp1 (or zampp2).
|
||||
2. Read this file end-to-end. Then `services/web-ui/public/js/bmd-card.js` (top JSDoc explains the API) and `services/capture/src/capture-manager.js` (codec catalogs).
|
||||
3. Inspect `recorders.html` -- it still has the pre-revamp "BM1/BM2" dropdown and flat codec fields. Compare against the `recorders` table columns in `008-codec-settings.sql` for the full field set the UI should drive.
|
||||
4. Iterate against a live deployment: `bash deploy/test-cluster.sh` for regression check, plus the actual `/recorders.html` page in a browser (web-ui on port 8080, mam-api on 47432).
|
||||
5. Commit through Forgejo MCP if the diff is small; otherwise push from zampp1 (see Deploy state above for creds location). **Cloudflare WAF blocks large MCP uploads** (the blocked domain is `anthropic.com`, not Forgejo) -- pushing from a host with creds is faster for anything over ~3 KB.
|
||||
2086
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md
Normal file
2086
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md
Normal file
File diff suppressed because it is too large
Load diff
502
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-2-plan.md
Normal file
502
docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-2-plan.md
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
# UI Shell Rework — Wave 2 (Low-risk page migrations) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or executing-plans. Steps use `- [ ]` checkboxes.
|
||||
|
||||
**Goal:** Migrate the 6 lowest-risk shell pages (login, home, settings, tokens, users, containers) from `css/common.css` + bespoke per-page CSS to the new `/dist/app.css` primitive bundle from wave 1. Each page goes from "old look" to "new look" with the same functionality. Also fold in the deferred token cleanups from the wave-1 code review.
|
||||
|
||||
**Architecture:** Each page migration is a self-contained markup rewrite. Pattern: swap the `<link>` to `/dist/app.css`, replace the sidebar + topbar markup with `wd-*` primitives, restyle page-specific content with `wd-card-asset` / `wd-card-op` / `wd-list-row` / `wd-form-*` / `wd-btn` / etc. Preserve every existing `<script>` and `id` so JS keeps working. Deploy after each page; check.
|
||||
|
||||
**Tech Stack:** Tailwind+flyon-ui bundle from wave 1 (already live), nginx static, no JS changes expected.
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md`
|
||||
**Wave 1 plan:** `docs/superpowers/plans/2026-05-21-ui-shell-rework-wave-1-plan.md`
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Files this wave modifies:**
|
||||
|
||||
```
|
||||
services/web-ui/
|
||||
├── public/
|
||||
│ ├── login.html (REWRITE: small, no sidebar, just form)
|
||||
│ ├── home.html (REWRITE: hero + stat tiles, has sidebar)
|
||||
│ ├── settings.html (REWRITE: tabbed settings forms, has sidebar)
|
||||
│ ├── tokens.html (REWRITE: list of tokens + create panel, has sidebar)
|
||||
│ ├── users.html (REWRITE: user list + edit slide-panel, has sidebar)
|
||||
│ └── containers.html (REWRITE: docker container list + logs, has sidebar)
|
||||
└── src/css/components/
|
||||
└── tokens.css (MODIFY: add deferred token cleanups)
|
||||
```
|
||||
|
||||
**Files this wave does NOT touch:** the other 9 pages (index, projects, upload, jobs, api-tokens, recorders, cluster, capture, edit, editor, player). They're wave 3 / 4 / excluded.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 0: Fold deferred token cleanups into tokens.css
|
||||
|
||||
Address items 1, 2, 4, 6 from the wave-1 code review BEFORE the page migrations multiply duplication of raw oklch values.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/web-ui/src/css/components/tokens.css`
|
||||
- Modify: `services/web-ui/src/css/components/button.css` (use new tokens)
|
||||
- Modify: `services/web-ui/src/css/components/card-operational.css` (use new tokens)
|
||||
- Modify: `services/web-ui/src/css/components/sidebar.css`, `topbar.css`, `slide-panel.css`, `card-asset.css`, `form-controls.css`, `field-group.css`, `list-row.css`, `toast.css` (use shared --ease / --dur tokens)
|
||||
|
||||
- [ ] **Step 1: Extend tokens.css with the missing tokens**
|
||||
|
||||
Append to `services/web-ui/src/css/components/tokens.css` inside `:root`:
|
||||
|
||||
```css
|
||||
/* Hover-darker variants of accent + signals — promoted from
|
||||
* inline oklch() arithmetic that was duplicated across button.css
|
||||
* and card-operational.css */
|
||||
--accent-hover: oklch(52% 0.20 266);
|
||||
--accent-bright: oklch(70% 0.18 266);
|
||||
--signal-bad-hover: oklch(68% 0.22 25);
|
||||
--signal-good-hover: oklch(74% 0.18 148);
|
||||
--signal-warn-hover: oklch(84% 0.16 90);
|
||||
/* Pure-black-ish tinted toward brand hue for thumbnails & overlays.
|
||||
* Numerically still ~black but the hue channel is set so future
|
||||
* derivations stay on-brand. */
|
||||
--thumb-black: oklch(0% 0 266);
|
||||
--overlay: oklch(8% 0.010 266 / 0.65);
|
||||
--shadow: oklch(0% 0 266 / 0.5);
|
||||
|
||||
/* Motion + ease tokens — promoted from raw cubic-bezier strings
|
||||
* that appeared in 8 of 12 primitive files */
|
||||
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--dur-fast: 120ms;
|
||||
--dur-normal: 180ms;
|
||||
--dur-slide: 240ms;
|
||||
|
||||
/* Z layers — promoted from sidebar/topbar where 30 was hard-coded */
|
||||
--z-topbar: 30;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Find-and-replace raw oklch hover values across primitives**
|
||||
|
||||
For each of these files, replace the literal oklch string with the new token. Use `sed -i` for the substitutions, but verify each file afterward.
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/src/css/components
|
||||
|
||||
# button.css
|
||||
sed -i 's|background: oklch(52% 0.20 266);|background: var(--accent-hover);|' button.css
|
||||
sed -i 's|background: oklch(68% 0.22 25);|background: var(--signal-bad-hover);|' button.css
|
||||
|
||||
# card-operational.css — gradient stop in signal-strip-fill
|
||||
sed -i 's|oklch(70% 0.18 266)|var(--accent-bright)|' card-operational.css
|
||||
|
||||
# card-asset.css — pure-black thumb background
|
||||
sed -i 's|background: oklch(0% 0 0);|background: var(--thumb-black);|' card-asset.css
|
||||
|
||||
# slide-panel.css — overlay color
|
||||
sed -i 's|oklch(8% 0.010 266 / 0.65)|var(--overlay)|' slide-panel.css
|
||||
|
||||
# toast.css — shadow
|
||||
sed -i 's|oklch(0% 0 0 / 0.7)|var(--shadow)|' toast.css
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace raw cubic-bezier strings with --ease + --dur tokens**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/src/css/components
|
||||
# Replace exact "120ms cubic-bezier(0.25, 1, 0.5, 1)" with the tokens
|
||||
for f in sidebar.css topbar.css slide-panel.css card-asset.css card-operational.css form-controls.css field-group.css list-row.css button.css; do
|
||||
sed -i 's|120ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-fast) var(--ease-out-quart)|g' "$f"
|
||||
done
|
||||
# slide-panel slide-in (240ms ease-out-expo)
|
||||
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' slide-panel.css
|
||||
# Tab indicator
|
||||
sed -i 's|240ms cubic-bezier(0\.25, 1, 0\.5, 1)|var(--dur-slide) var(--ease-out-quart)|' slide-panel.css
|
||||
sed -i 's|200ms cubic-bezier(0\.25, 1, 0\.5, 1)|200ms var(--ease-out-quart)|g' form-controls.css
|
||||
sed -i 's|240ms cubic-bezier(0\.16, 1, 0\.3, 1)|var(--dur-slide) var(--ease-out-expo)|' card-operational.css
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace hard-coded z-index 30 with --z-topbar**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/src/css/components
|
||||
sed -i 's|z-index: 30;|z-index: var(--z-topbar);|' topbar.css
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Rebuild + verify primitives still ship correctly**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon
|
||||
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||
sleep 4
|
||||
docker exec wild-dragon-web-ui-1 grep -c '.wd-' /usr/share/nginx/html/dist/app.css
|
||||
# Expect: same large number (~116+) — no rules dropped
|
||||
docker exec wild-dragon-web-ui-1 grep -c '\-\-accent-hover\|\-\-ease-out-quart\|\-\-z-topbar' /usr/share/nginx/html/dist/app.css
|
||||
# Expect: at least 3 hits (tokens now defined + referenced)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Visual regression check on smoke page**
|
||||
|
||||
```bash
|
||||
curl -sk -o /dev/null -w 'smoke=%{http_code}/%{size_download}\n' http://localhost:47434/_primitives-smoke.html
|
||||
# Expect: HTTP 200, ~12 KB (unchanged from wave 1)
|
||||
```
|
||||
|
||||
Manually load the smoke page in a browser; everything should look identical to wave 1. If anything changed visually, the sed substitutions introduced a regression.
|
||||
|
||||
- [ ] **Step 7: Commit + push**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon
|
||||
HOME=/root git add services/web-ui/src/css/components/
|
||||
HOME=/root git diff --cached --stat
|
||||
HOME=/root git -c user.email=zgaetano@wilddragon.net -c user.name='Zac Gaetano' commit -m 'web-ui: token cleanups from wave-1 code review
|
||||
|
||||
- Promote --accent-hover, --signal-bad-hover, --signal-good-hover,
|
||||
--signal-warn-hover, --accent-bright tokens (were duplicated raw
|
||||
oklch arithmetic in button.css / card-operational.css)
|
||||
- Promote --thumb-black, --overlay, --shadow tokens (tinted toward
|
||||
brand hue 266 so future derivations stay on-brand)
|
||||
- Promote --ease-out-quart, --ease-out-expo, --dur-fast/normal/slide
|
||||
tokens (cubic-bezier strings appeared in 8 of 12 primitive files)
|
||||
- Promote --z-topbar (was hard-coded 30 in topbar.css while every
|
||||
other layer was tokenized)
|
||||
- Replace all usages across the 12 primitive files via sed.
|
||||
|
||||
Bundle byte count unchanged (~138 KB); visual regression on smoke
|
||||
page = zero. Code-review concerns from wave 1 now resolved before
|
||||
wave 2 page migrations begin.'
|
||||
HOME=/root git push 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Migrate login.html
|
||||
|
||||
Smallest page. No sidebar, no topbar — just a centered card with email/password. Migrating first because if it breaks nothing else does.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/web-ui/public/login.html`
|
||||
|
||||
- [ ] **Step 1: Read the current page to see what's there**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/public
|
||||
cat login.html | head -80
|
||||
```
|
||||
|
||||
Note: form ids, input names, any inline JS handlers. Preserve all of them.
|
||||
|
||||
- [ ] **Step 2: Write the new login.html**
|
||||
|
||||
The new structure:
|
||||
- `<link rel="stylesheet" href="/dist/app.css">` instead of the old `common.css`
|
||||
- Centered `<main>` with a single `.wd-card-op`-shaped panel (operational card primitive, sized small)
|
||||
- Inside: brand logo + "Z-AMPP" wordmark at top, then `<form>` with two `.wd-form-group` (email + password), then `.wd-btn.wd-btn--primary.wd-btn--md` submit
|
||||
- Keep every existing `id`, `name`, `type`, and `<script>` tag from the old file
|
||||
- If there's an "error message" div, replace its class with `.wd-toast.wd-toast--error` (inline, not floating)
|
||||
|
||||
Replace the entire `<head>` and `<body>` with the new shell. JS at the bottom stays as-is.
|
||||
|
||||
- [ ] **Step 3: Deploy on zampp1 (no Docker rebuild needed — HTML is static)**
|
||||
|
||||
```bash
|
||||
# Actually nginx serves from the image's filesystem, not the host's
|
||||
# /opt/wild-dragon/services/web-ui/public/. So we DO need a rebuild.
|
||||
cd /opt/wild-dragon
|
||||
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||
sleep 4
|
||||
curl -sk -o /dev/null -w 'login=%{http_code}/%{size_download}\n' http://localhost:47434/login.html
|
||||
# Confirm new bundle is referenced
|
||||
curl -sk http://localhost:47434/login.html | grep -E 'dist/app.css|common.css'
|
||||
# Expect: dist/app.css present, common.css absent
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Visual + functional check**
|
||||
|
||||
Open `http://172.18.91.216:47434/login.html` in a browser. Verify:
|
||||
- Page renders with new brand styling
|
||||
- Email + password fields look like the wd-input primitive
|
||||
- Submit button looks like wd-btn--primary
|
||||
- Logging in still actually works (POST to /api/v1/auth/login)
|
||||
|
||||
- [ ] **Step 5: Commit + push**
|
||||
|
||||
```bash
|
||||
HOME=/root git add services/web-ui/public/login.html
|
||||
HOME=/root git commit -m 'web-ui(wave 2): migrate login.html to new primitives'
|
||||
HOME=/root git push 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Migrate home.html
|
||||
|
||||
Has sidebar + topbar + dashboard stat tiles. The first page that exercises the full shell.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/web-ui/public/home.html`
|
||||
|
||||
- [ ] **Step 1: Read the current page**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/public
|
||||
wc -l home.html
|
||||
head -80 home.html
|
||||
```
|
||||
|
||||
Identify: page title, what's in the topbar right side, the stat tile structure, any chart libraries, and the bottom `<script>` blocks. Preserve all script references and JS state.
|
||||
|
||||
- [ ] **Step 2: Migrate the markup**
|
||||
|
||||
The migration recipe for every shell page:
|
||||
|
||||
1. **`<head>`**: replace `<link rel=stylesheet href=css/common.css>` with `<link rel=stylesheet href=/dist/app.css>`. Keep favicon, viewport meta.
|
||||
2. **`<body>` root**: wrap in `<div class="wd-shell">` (style inline: `display:flex;min-height:100vh`).
|
||||
3. **Sidebar**: copy verbatim from the smoke page's `<nav class="wd-sidebar">` block. Mark the active nav item with `is-active` on Home.
|
||||
4. **Right column**: `<div style="flex:1;display:flex;flex-direction:column;">`
|
||||
5. **Topbar**: `<header class="wd-topbar">` with breadcrumb in `.wd-topbar-left` containing just "Home", any existing right-side button as `.wd-btn.wd-btn--primary.wd-btn--sm`.
|
||||
6. **Main content**: `<main style="padding:20px 20px 32px;">`
|
||||
7. **Stat tiles**: replace with `.wd-card-op-grid` containing `.wd-card-op` (small, content-only — no footer needed if there's no action).
|
||||
8. **Auth-guard script** at the bottom — stays exactly as-is.
|
||||
|
||||
- [ ] **Step 3: Deploy + check**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon
|
||||
docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||
sleep 4
|
||||
curl -sk -o /dev/null -w 'home=%{http_code}/%{size_download}\n' http://localhost:47434/home.html
|
||||
curl -sk http://localhost:47434/home.html | grep -c 'wd-sidebar\|wd-topbar\|wd-card'
|
||||
# Expect: 8+ matches
|
||||
```
|
||||
|
||||
Load `http://172.18.91.216:47434/home.html` in a browser. Sidebar should be the new one, breadcrumb shows "Home", stat tiles render as operational cards.
|
||||
|
||||
- [ ] **Step 4: Commit + push**
|
||||
|
||||
```bash
|
||||
HOME=/root git add services/web-ui/public/home.html
|
||||
HOME=/root git commit -m 'web-ui(wave 2): migrate home.html to new primitives'
|
||||
HOME=/root git push 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Migrate settings.html
|
||||
|
||||
System settings form. Lots of form-groups, possibly tabbed.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/web-ui/public/settings.html`
|
||||
|
||||
- [ ] **Step 1: Read current page + identify all form sections**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/public
|
||||
grep -E '<h[12]|form-group|form-section-label' settings.html | head -30
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Migrate using the standard recipe**
|
||||
|
||||
Same recipe as task 2, except for the form content:
|
||||
- Replace each form section with a `.wd-field-group` (header + body, no tabs unless the section is genuinely tabbed)
|
||||
- Replace every `<input>` with `class="wd-input"`, every `<select>` with `class="wd-select"`, every `<label>` with `class="wd-label"`
|
||||
- Replace every `<button>` with `class="wd-btn wd-btn--primary wd-btn--md"` (or `--secondary` / `--ghost` / `--danger` as appropriate)
|
||||
- Wrap rows of inputs in `.wd-form-row`
|
||||
- Preserve every `id`, `name`, `type`, and JS handler
|
||||
|
||||
Set `.wd-nav-item.is-active` on Settings.
|
||||
|
||||
- [ ] **Step 3: Deploy + check**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||
sleep 4
|
||||
curl -sk -o /dev/null -w 'settings=%{http_code}/%{size_download}\n' http://localhost:47434/settings.html
|
||||
```
|
||||
|
||||
Load in browser. Verify the form actually saves (test by changing one value and clicking save).
|
||||
|
||||
- [ ] **Step 4: Commit + push**
|
||||
|
||||
```bash
|
||||
HOME=/root git add services/web-ui/public/settings.html
|
||||
HOME=/root git commit -m 'web-ui(wave 2): migrate settings.html to new primitives'
|
||||
HOME=/root git push 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Migrate tokens.html
|
||||
|
||||
Lists API tokens, allows creation of new ones with a slide-panel. First page that exercises the slide-panel primitive in the migrated context.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/web-ui/public/tokens.html`
|
||||
|
||||
- [ ] **Step 1: Read + identify the slide-panel structure**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/public
|
||||
grep -E 'slide-panel|slide-overlay|wd-list-row' tokens.html | head -20
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Migrate**
|
||||
|
||||
- Standard shell recipe (sidebar with `is-active` on Tokens, topbar with "Tokens" breadcrumb and "New token" primary button)
|
||||
- Token list → `.wd-list` containing `.wd-list-row` for each token: name (cell--name), created date (cell--meta), badge for scope, action buttons in cell--actions
|
||||
- Create-token form moves into `.wd-slide-panel` (with overlay, header, body, footer pattern exactly as in the smoke page's field-group)
|
||||
- Preserve every JS handler — especially the copy-to-clipboard one for the newly-generated token
|
||||
|
||||
- [ ] **Step 3: Deploy + check**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||
sleep 4
|
||||
curl -sk -o /dev/null -w 'tokens=%{http_code}/%{size_download}\n' http://localhost:47434/tokens.html
|
||||
```
|
||||
|
||||
Load + verify: clicking "New token" opens the slide-panel (codec-clipping bug fix from wave 1 applies — body should scroll if it overflows), creating a token shows the copy-once display.
|
||||
|
||||
- [ ] **Step 4: Commit + push**
|
||||
|
||||
```bash
|
||||
HOME=/root git add services/web-ui/public/tokens.html
|
||||
HOME=/root git commit -m 'web-ui(wave 2): migrate tokens.html to new primitives'
|
||||
HOME=/root git push 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Migrate users.html
|
||||
|
||||
User management. List + edit slide-panel. Pattern matches tokens.html closely.
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/web-ui/public/users.html`
|
||||
|
||||
- [ ] **Step 1: Read + identify**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/public
|
||||
grep -E '<h[12]|wd-list|slide-panel' users.html | head
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Migrate using the tokens.html recipe**
|
||||
|
||||
Identical pattern to task 4: shell + list + slide-panel for create/edit. Mark Users active. Preserve every JS handler (role dropdown, password reset, etc.).
|
||||
|
||||
- [ ] **Step 3: Deploy + check**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||
sleep 4
|
||||
curl -sk -o /dev/null -w 'users=%{http_code}/%{size_download}\n' http://localhost:47434/users.html
|
||||
```
|
||||
|
||||
Verify: list renders, edit panel opens, save works.
|
||||
|
||||
- [ ] **Step 4: Commit + push**
|
||||
|
||||
```bash
|
||||
HOME=/root git add services/web-ui/public/users.html
|
||||
HOME=/root git commit -m 'web-ui(wave 2): migrate users.html to new primitives'
|
||||
HOME=/root git push 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Migrate containers.html
|
||||
|
||||
Docker container list. List rows with status badges + action buttons (logs / restart). No slide-panel (logs typically opens in a separate tab or inline).
|
||||
|
||||
**Files:**
|
||||
- Modify: `services/web-ui/public/containers.html`
|
||||
|
||||
- [ ] **Step 1: Read + identify**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon/services/web-ui/public
|
||||
head -80 containers.html
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Migrate**
|
||||
|
||||
- Standard shell recipe (Containers active)
|
||||
- Container list → `.wd-list` with `.wd-list-row` per container:
|
||||
- cell--name: container name
|
||||
- cell with image: cell--meta
|
||||
- cell with status: `.wd-badge.wd-badge--good` (Up) / `.wd-badge--bad` (Down) / `.wd-badge--warn` (Restarting)
|
||||
- cell--actions: ghost buttons for Logs / Restart / Stop
|
||||
- Auto-refresh polling JS stays unchanged
|
||||
|
||||
- [ ] **Step 3: Deploy + check**
|
||||
|
||||
```bash
|
||||
cd /opt/wild-dragon && docker compose up -d --build web-ui 2>&1 | grep -E 'Built|Started' | tail -3
|
||||
sleep 4
|
||||
curl -sk -o /dev/null -w 'containers=%{http_code}/%{size_download}\n' http://localhost:47434/containers.html
|
||||
```
|
||||
|
||||
Load + verify: all containers visible, status badges color-coded, Logs button still opens logs.
|
||||
|
||||
- [ ] **Step 4: Commit + push**
|
||||
|
||||
```bash
|
||||
HOME=/root git add services/web-ui/public/containers.html
|
||||
HOME=/root git commit -m 'web-ui(wave 2): migrate containers.html to new primitives'
|
||||
HOME=/root git push 2>&1 | tail -3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Wave-2 user QA gate
|
||||
|
||||
- [ ] **Step 1: Verify all 6 migrated pages serve correctly**
|
||||
|
||||
```bash
|
||||
for p in login home settings tokens users containers; do
|
||||
printf ' %s.html: HTTP=%s\n' "$p" "$(curl -sk -o /dev/null -w '%{http_code}' http://localhost:47434/$p.html)"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: all 200.
|
||||
|
||||
- [ ] **Step 2: Verify all 6 pages reference /dist/app.css and NOT common.css**
|
||||
|
||||
```bash
|
||||
for p in login home settings tokens users containers; do
|
||||
CNT_NEW=$(curl -sk http://localhost:47434/$p.html | grep -c dist/app.css)
|
||||
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
|
||||
printf ' %s.html: new=%s old=%s\n' "$p" "$CNT_NEW" "$CNT_OLD"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: new=1 old=0 for every page.
|
||||
|
||||
- [ ] **Step 3: Verify wave-3 / wave-4 pages are STILL on the old CSS (no accidental change)**
|
||||
|
||||
```bash
|
||||
for p in index projects upload jobs api-tokens recorders cluster capture editor; do
|
||||
CNT_OLD=$(curl -sk http://localhost:47434/$p.html | grep -c common.css)
|
||||
printf ' %s.html: still-on-old=%s\n' "$p" "$CNT_OLD"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: still-on-old=1 for every page (none of them migrated yet).
|
||||
|
||||
- [ ] **Step 4: User visual QA**
|
||||
|
||||
Stop. Ask user to load each of the 6 migrated pages and confirm the new look is correct, navigation still works, forms still save, lists still poll. If anything looks wrong, fix it before wave 3 starts.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage**: Every page in the wave-2 list from the design spec is in the plan. Token cleanups from wave-1 review are folded in as task 0.
|
||||
- **Placeholders**: none. Every step has the actual command / file change.
|
||||
- **Type consistency**: every migrated page uses `.wd-shell` / `.wd-sidebar` / `.wd-topbar` / `.wd-nav-item.is-active` / `.wd-card-op` / `.wd-list` / `.wd-list-row` / `.wd-btn` / `.wd-input` / `.wd-select` / `.wd-label` / `.wd-form-row` / `.wd-field-group` — exact class names from the wave-1 bundle.
|
||||
- **Open risk**: each page migration is a manual markup rewrite. The implementer subagent needs to actually read each existing page before rewriting, not work from the description alone, because each page has page-specific JS handlers that must be preserved verbatim.
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# NLE Editor: React SPA Polish — Phases 1–3 Implementation Plan
|
||||
|
||||
> **Date:** 2026-05-24
|
||||
> **Status:** Phase 1 IN PROGRESS
|
||||
> **Progress:** Tasks 1.1–1.6 code-complete, pending test/deploy
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core Editor (IN PROGRESS)
|
||||
|
||||
### Task 1.1: Sequence API helpers in data.jsx ✅
|
||||
- Added `getSequences`, `createSequence`, `getSequence`, `updateSequence`, `deleteSequence`, `syncSequenceClips`, `exportSequenceEDL` to `data.jsx`
|
||||
- All exported on `window.ZAMPP_API`
|
||||
|
||||
### Task 1.2: Timecode.js wired into SPA ✅
|
||||
- Added `<script src="js/timecode.js">` and `<script src="js/timeline.js">` to `index.html` before `screens-editor.jsx`
|
||||
- `window.TC` available globally for 59.94 DF timecode math
|
||||
|
||||
### Task 1.3: TimelinePanel React component ✅
|
||||
- `tlRef` container div in editor layout
|
||||
- `useEffect` mounts `window.Timeline.init()` on first render
|
||||
- `useEffect` pushes scale changes via `window.Timeline.setScale()`
|
||||
- `onClipsChanged` / `onPlayheadMoved` callbacks connect timeline engine to React state
|
||||
|
||||
### Task 1.4: screens-editor.jsx rewrite ✅ (455 lines, was 162)
|
||||
Full rewrite with:
|
||||
- **App state**: `projectId`, `sequences`, `currentSeq`, `assets`, `sourceAsset`, `srcIn`/`srcOut`, `playheadFrames`, `history` (undo stack), `scale`, `tool`, `saveStatus`, `isDirty`
|
||||
- **Source monitor**: `<video>` + `apiFetch('/assets/:id/stream')` + Mark In/Out + Insert button
|
||||
- **Program monitor**: Virtual clip-by-clip playback — loads V1 clips sorted by `timeline_in_frames`, advances on `timeUpdate`/`ended` events
|
||||
- **Media panel**: Asset list from `ZAMPP_DATA.ASSETS`, filter by bin, `AssetThumb` thumbnails, double-click loads source
|
||||
- **Sequence management**: Picker `<select>`, "New sequence" modal, `openSequence()` loads via API
|
||||
- **Auto-save**: `markDirty()` → debounce 2s → `syncSequenceClips()` → status updates
|
||||
- **Undo/redo**: 50-step history stack, Ctrl+Z / Ctrl+Shift+Z
|
||||
- **EDL export**: Button triggers `window.ZAMPP_API.exportSequenceEDL()`
|
||||
- **Tool toolbar**: V/C/H buttons synced with `Timeline.setTool()`
|
||||
- **Zoom slider**: Range input driving `window.Timeline.setScale()`
|
||||
- **Keyboard handler**: I/O, V/C/H, Ctrl+Z/Shift+Z, Ctrl+S
|
||||
|
||||
### Task 1.5: "In Development" overlay removed ✅
|
||||
- Deleted the `position: absolute; inset: 0; backdropFilter: blur(6px)` overlay div (was lines 98-117)
|
||||
- Removed `FauxFrame` component reference
|
||||
- All buttons are now functional
|
||||
|
||||
### Task 1.6: Editor nav badge removed ✅
|
||||
- `shell.jsx`: `{ id: "editor", label: "Editor", icon: "editor" }` — no more `badge: { kind: "dev", text: "DEV" }`
|
||||
|
||||
### NEXT: Task 1.7 — Test & Deploy
|
||||
1. `docker compose up -d --build web-ui`
|
||||
2. Navigate to Editor route in browser
|
||||
3. Verify: source monitor loads video, timeline renders with 4 track rows, Insert places clip, auto-save fires, EDL export downloads
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — UX Polish & Growing File (PENDING)
|
||||
|
||||
- [ ] 2.1 Multi-track refinements (ripple delete, snap, locking, overlap prevention)
|
||||
- [ ] 2.2 Zoom slider + adaptive ruler
|
||||
- [ ] 2.3 JKL transport + frame stepping
|
||||
- [ ] 2.4 Waveform display on audio tracks
|
||||
- [ ] 2.5 Inspector panel wiring
|
||||
- [ ] 2.6 Style migration to Tailwind primitives
|
||||
- [ ] 2.7 HLS live preview during capture
|
||||
|
||||
## Phase 3 — Export, Conform & Features (PENDING)
|
||||
|
||||
- [ ] 3.1 FCP XML export + conform queue
|
||||
- [ ] 3.2 Hi-Res Auto-Relink
|
||||
- [ ] 3.3 Timecoded Comments
|
||||
- [ ] 3.4 Player Rebuild (P1)
|
||||
- [ ] 3.5 Subclips (P2)
|
||||
- [ ] 3.6 Multi-select & Bulk Ops (P3)
|
||||
- [ ] 3.7 Smart Bins (P6)
|
||||
- [ ] 3.8 Metadata Templates (P7)
|
||||
2888
docs/superpowers/plans/2026-05-27-auth-system.md
Normal file
2888
docs/superpowers/plans/2026-05-27-auth-system.md
Normal file
File diff suppressed because it is too large
Load diff
198
docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md
Normal file
198
docs/superpowers/specs/2026-05-21-ui-shell-rework-design.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# UI Shell Rework — Design Spec
|
||||
|
||||
> Status: **design approved 2026-05-21**, awaiting user review of the spec before the implementation plan is written.
|
||||
|
||||
## Context
|
||||
|
||||
The Wild Dragon MAM web-ui currently ships 15 static HTML pages served by nginx, sharing a single hand-written `common.css`. The token system is already strong (oklch palette, brand hue 266, 5-step depth surfaces, semantic signal tokens, 4pt spacing). What does not work is the gap between tokens and execution: cards across pages have undifferentiated spacing, generic chrome, weak hierarchy, and identical shape regardless of role. The user feedback that prompted this work was "the UI still looks AI-designed."
|
||||
|
||||
The rework adopts flyon-ui (Tailwind plugin) as the component primitive layer, ports the oklch palette into a custom flyon-ui theme so brand identity is preserved, and rebuilds every page that uses the standard shell against the new primitives. The personality target is *quiet pro tool* — closer to Sony Media Cloud and DaVinci Resolve than to a consumer SaaS dashboard.
|
||||
|
||||
## Goals & non-goals
|
||||
|
||||
**Goals**
|
||||
- A coherent visual system across every shell page (15 pages minus 3 excluded).
|
||||
- Higher information density at every screen — closer to Sony / DaVinci than to today's spacing.
|
||||
- Four distinct card families so the eye reads role from shape.
|
||||
- A theme port that preserves brand hue 266 and the existing oklch palette under flyon-ui.
|
||||
- Accessibility floor: WCAG AA contrast, full keyboard nav, reduced-motion honored.
|
||||
|
||||
**Non-goals**
|
||||
- Mobile UX. Phones get an explicit "desktop only" splash. Tablet gets a collapsed icon-rail sidebar but no further accommodation.
|
||||
- Replacing the brand color, the font stack, or the dark theme.
|
||||
- Animations beyond functional state transitions (no celebrations, no page-fade, no sound design).
|
||||
- Adding new pages or features. This is purely visual / structural.
|
||||
- Rebuilding `edit.html`, `editor.html`, or `player.html` (deliberately excluded — see Rollout).
|
||||
|
||||
## Personality, scene & color strategy
|
||||
|
||||
- **Register:** product (app UI, design serves the product), not brand.
|
||||
- **Theme scene sentence:** "MAM operator at a 27-inch monitor in a dim control room, scanning a grid of 100+ video assets at 2am while a live recording timer runs." Forces dark, low-chroma, tabular-numeric trust signals.
|
||||
- **Color strategy:** restrained. Tinted neutrals (chroma 0.010–0.015, hue 266) plus a single amber accent used in ≤10% of surfaces — for active states, recording indicators, primary CTAs, focus rings.
|
||||
- **Anti-patterns explicitly banned:** glassmorphism as default, gradient text, side-stripe borders (>1px on the side of cards/rows/callouts), hero-metric SaaS template, identical card grids across roles, em dashes in copy. Cliché category-reflex check passes: this design lands as DAW / NLE / NLE-adjacent operational tool, not "dark blue observability dashboard."
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Build system & theme port
|
||||
|
||||
The `services/web-ui` Docker image gains a Node build stage. During `docker build`, `tailwindcss --minify` runs once, scans `public/**/*.html` for class usage, and emits `public/dist/app.[hash].css`. The runtime stage stays nginx-static — no runtime Node, longer startup, or extra moving parts.
|
||||
|
||||
A `tailwind.config.js` at `services/web-ui/` defines a custom flyon-ui theme that maps the existing oklch palette into flyon-ui's color slots. Brand hue 266 is preserved; the 5-step depth surfaces become Tailwind's `bg-deep` / `bg-base` / `bg-panel` / `bg-surface` / `bg-raised` / `bg-hover` utility chain. Signal tokens (`signal-good` / `signal-bad` / `signal-warn` / `signal-idle`) map directly. Spacing scale uses Tailwind's default 4pt scale, which already matches the existing `--sp-*` tokens — utilities like `p-3` and `gap-4` replace `var(--sp-3)` and `gap: var(--sp-4)`.
|
||||
|
||||
Fonts (Inter + JetBrains Mono) move from Google CDN to self-hosted woff2 in `public/fonts/`. The four "legacy alias" entries in the current `:root` (`--status-amber`, `--status-amber-bg`, etc.) get cleaned up during the port.
|
||||
|
||||
The custom theme also disables flyon-ui utility classes for the banned patterns: no `glass-*`, no `gradient-text`, no card-shadow defaults.
|
||||
|
||||
### 2. Sidebar
|
||||
|
||||
- **Dimensions:** 200px wide (down from 220px). Items 28px tall (down from ~36px), 8px horizontal padding, 4px vertical.
|
||||
- **Type:** Inter 13px / 500 for items. Section labels 10px / 600 / 0.14em tracked / uppercase / `text-tertiary`.
|
||||
- **Header:** 18px dragon logo + Inter 13px / 600 / -0.01em "Z-AMPP" wordmark. Total header height 48px to align with topbar.
|
||||
- **Active state:** `bg-surface` background + `text-primary` text + 4px leading accent dot (8px tall, vertically centered). No side-stripe border (banned). No accent background fill.
|
||||
- **Hover:** `bg-hover` fade-in over 120ms ease-out. No transform.
|
||||
- **IN DEV badge** (injected by `auth-guard.js`): retained, restyled as 9px / 700 / 0.12em tracked amber pill.
|
||||
- **Footer user widget:** 28px round avatar, name + role stacked, logout button reveals on row hover.
|
||||
|
||||
### 3. Topbar
|
||||
|
||||
- **Dimensions:** 48px tall. Padding 16px left / 12px right. Bottom border `border-faint`.
|
||||
- **Left:** breadcrumb pattern, not flat title. Inter 13px / 500 / `text-secondary` for ancestor crumbs, 13px / 600 / `text-primary` for current. 10px chevron separator in `text-tertiary` with 8px gutters.
|
||||
- **Center:** page-scoped search input on pages that have searchable content (Library, Recorders, Projects, Jobs, Cluster). 360px wide, 28px tall, leading magnifier, monospace placeholder.
|
||||
- **Right:** primary CTA rightmost (28px button with 12px leading icon), 1px vertical divider, then 28px-square icon-only ghost buttons for secondary actions (filter, sort, view-toggle).
|
||||
- **Sticky:** `position: sticky; top: 0; z-index: 30` inside `.main`. Sidebar does not scroll separately; topbar stays visible while content scrolls.
|
||||
|
||||
### 4. Card families
|
||||
|
||||
Four distinct card shapes, each with one clear job. Same-shape repetition is banned.
|
||||
|
||||
**Asset card** — Library, Projects asset grid, Recorders recording cards
|
||||
- 16:9 thumbnail, full-bleed. Duration chip bottom-right (JetBrains Mono 10px, `bg-deep` 70% opacity). Comment-count chip bottom-left (when >0). Selection checkbox top-left (only on hover or when any are selected). Version badge top-right when applicable.
|
||||
- Metadata: filename (Inter 13px / 500), then `{author} · {date}` row (11px / `text-tertiary`, mono numerics).
|
||||
- Role pill at bottom: full-width, light tint of role color, 10px / 600 / 0.08em tracked / uppercase. Dotted-border placeholder when unset.
|
||||
- 1px `border-faint`, 6px radius, `bg-panel`. Hover: thumbnail +4% brightness, border lifts to `border`. No scale, no shadow.
|
||||
|
||||
**Operational card** — Recorders cards, Cluster nodes, Jobs queue
|
||||
- Header / content / footer rows. Wider than tall (min 8:3 ratio). 14px padding, 10px row gap.
|
||||
- Header: 16px name + status pill (semantic signal tokens).
|
||||
- Content: role-specific. Recorder gets live preview + signal strip + timer; cluster node gets CPU/mem mini-bars; job gets progress strip.
|
||||
- Footer: 1px top hairline, metadata-left + actions-right.
|
||||
- Border 1px `border-faint`, becomes 1px `accent-border` when active (recording / online / running). Color change only — no glow, no shadow, no animation.
|
||||
|
||||
**Inline list row** — Containers, Users, Tokens, API tokens
|
||||
- Not a card. Table row with extra breathing room. 44px tall, hairline divider (`border-faint`).
|
||||
- Hover: `bg-hover` row tint. Selected: `bg-surface` tint + 4px leading accent dot (same indicator language as sidebar active state).
|
||||
|
||||
**Empty state**
|
||||
- Centered 28px line icon (`text-tertiary`), 14px / 600 title, 13px body, primary action button. No card chrome — the empty state IS the page.
|
||||
- Declarative copy. No exclamation points, no emojis.
|
||||
|
||||
### 5. Grids
|
||||
|
||||
- Asset grid: `repeat(auto-fill, minmax(220px, 1fr))` with 12px gap.
|
||||
- Operational grid: `repeat(auto-fill, minmax(380px, 1fr))` with 14px gap.
|
||||
- Page content padding: 20px sides, 16px top, 32px bottom.
|
||||
|
||||
### 6. Forms, slide-panels & inputs
|
||||
|
||||
**Slide-panel structure (the codec-clipping bug fix codified as a primitive):**
|
||||
- 460px wide. `height: 100vh; display: flex; flex-direction: column; overflow: hidden`. Header `flex-shrink: 0`. Body `flex: 1; min-height: 0; overflow-y: auto`. Footer `flex-shrink: 0; bg-deep`.
|
||||
- Header 52px, 18px padding, title + close button. Bottom border `border-faint`.
|
||||
- Body 18px padding, `display: flex; flex-direction: column; gap: 16px`.
|
||||
|
||||
**Form primitives:**
|
||||
- Label: 11px / 600 / 0.08em tracked / uppercase / `text-tertiary`.
|
||||
- Input / select / textarea: 32px tall, 10px horizontal padding, 13px text, 1px `border` outline, 4px radius. Focus: `accent-border` outline + 2px `accent-subtle` ring.
|
||||
- Form hint: 11px / `text-tertiary` / 1.5 line-height. JetBrains Mono code spans.
|
||||
- Form row: `grid-template-columns: 1fr 1fr; gap: 14px`.
|
||||
|
||||
**Field-group (tabbed sections, generalized from the codec-block pattern):**
|
||||
- Titled header strip (36px, `bg-surface`) + tab row (32px, `bg-deep`) + tab panels (14px padding).
|
||||
- Active tab: 2px `accent` bottom border, text shifts from `text-tertiary` to `accent`. Tab switches are instant — no animation.
|
||||
|
||||
**Buttons:**
|
||||
- Sizes: `sm` 28px / `md` 32px / `lg` 36px.
|
||||
- Variants: `primary` (accent bg), `secondary` (`bg-surface` + border), `ghost` (transparent + secondary text, `bg-hover` on hover), `danger` (status-red bg).
|
||||
- Leading icon: 12px svg, 6px gap. Disabled: 40% opacity. Active press: 60ms `opacity: 0.85`. No gradient, no shadow, no scale.
|
||||
|
||||
**Toggle:** 34×18, track `bg-hover` → `accent`, 200ms ease-out on dot only.
|
||||
|
||||
**Date / time inputs:** native `<input type="date">` styled to match the input primitive. No third-party picker library.
|
||||
|
||||
### 7. States, motion & feedback
|
||||
|
||||
**Loading:** skeleton blocks matched to content shape. Asset grid → 12 placeholder cards with 1.8s gradient shimmer (not opacity pulse). In-card actions get inline 12px ring spinner. In-button: label replaced by spinner, width preserved.
|
||||
|
||||
**Empty states:** fade in 240ms on first load; instant when user-initiated.
|
||||
|
||||
**Errors:**
|
||||
- *Toast* (bottom-right, 320px): `bg-panel` + 1px `status-red` border + 4px `status-red` top strip. Auto-dismiss 4s success / 8s warning / manual error. Stack up to 3.
|
||||
- *Inline*: red 11px text below offending field. No icon, no shake.
|
||||
- *Page-level*: full-page card with icon + plain-English title + Retry + Get-help buttons. Never blocks the sidebar.
|
||||
|
||||
**Success:** "Recorder saved" toast. Affected card briefly tints (200ms `accent-subtle` background, fades back over 1.2s). One-time. No checkmark celebrations.
|
||||
|
||||
**Live / realtime (recording-in-progress):**
|
||||
- Signal strip shimmer 1.8s ease-in-out (down from 2.4s linear).
|
||||
- "LIVE" preview-stamp dot stutter pattern: bright 0.9s / dim 0.3s / bright 0.9s (broadcast tally light).
|
||||
- Timer: 600 weight, `status-red` while recording. Already correct.
|
||||
|
||||
**Hover / focus:**
|
||||
- All transitions 120ms ease-out on `border-color` and `background-color` only. Never on `width`, `height`, `transform`.
|
||||
- Focus ring: 2px `accent-subtle` outline, 1px offset, `:focus-visible` only.
|
||||
|
||||
**Page transitions:** none. Click nav → page renders. Slide-panel keeps 240ms slide-in from right.
|
||||
|
||||
**Notifications:** no bell, no global status banner. Failures surface inline on affected pages.
|
||||
|
||||
### 8. Accessibility & responsive
|
||||
|
||||
**A11y floor:**
|
||||
- WCAG AA contrast on every text/background pair. `text-tertiary` lightness bumped from 52% to 56% to clear AA cleanly.
|
||||
- `:focus-visible` ring on every interactive element.
|
||||
- Full keyboard nav. Slide-panel traps focus while open; Esc closes overlays.
|
||||
- Every icon-only button gets `aria-label`. Toasts use `role="status" aria-live="polite"`.
|
||||
- `prefers-reduced-motion: reduce` honored: kills shimmer / pulse / slide-in, state changes become instant.
|
||||
|
||||
**Responsive:**
|
||||
- Desktop-first tool. *Fully supported* minimum viewport: 1280×800. Anything narrower is best-effort only.
|
||||
- ≥1600px: standard layout, content max-width 1440px, centered.
|
||||
- 1280–1599px: standard layout, no max-width cap.
|
||||
- 768–1279px (tablet, best-effort): sidebar collapses to a 56px icon-rail, breadcrumb truncates to last crumb. Functional but not polished.
|
||||
- <768px (phone): explicit "Z-AMPP is desktop-only" splash. No fake mobile experience.
|
||||
|
||||
**Browser support:** Chromium latest 2 versions + Safari latest 2. Firefox best-effort. No IE / legacy Edge.
|
||||
|
||||
## Rollout
|
||||
|
||||
Four waves, ordered by blast radius. Each wave is its own commit / deploy / verify cycle on zampp1.
|
||||
|
||||
**Wave 1 — Foundation (zero user-visible change).** Build pipeline, Tailwind + flyon-ui config, theme port, primitive CSS components, shell markup migration. New primitives exist but no page uses them yet. *Validates the build system works end-to-end.*
|
||||
|
||||
**Wave 2 — Shell + low-risk pages.** `login.html`, `home.html`, `settings.html`, `tokens.html`, `users.html`, `containers.html`. New shell, new card / list patterns, new forms. Low risk: no live data, simple flows.
|
||||
|
||||
**Wave 3 — Content-heavy pages.** `index.html` (Library), `projects.html`, `upload.html`, `jobs.html`, `api-tokens.html`. Asset grid, project tree, upload queue, job queue.
|
||||
|
||||
**Wave 4 — Operational pages.** `recorders.html`, `cluster.html`, `capture.html`. Live data, HLS preview, signal polling, BMD picker, codec slide-panel. Done last so the primitives are battle-tested before they meet the most fragile pages.
|
||||
|
||||
**Excluded from rework:** `edit.html`, `editor.html` (in-development construction screen is the right treatment), `player.html` (standalone embed, no shell).
|
||||
|
||||
**Definition of done per page:** new shell + new primitives + AA contrast verified + keyboard-nav check + responsive at 1280/1440/1920 widths + no JS regressions (live-recording flow on recorders.html is the canary).
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Tailwind build pipeline introduction breaks docker build | Wave 1 ships the build *without* migrating any page. If build fails, we revert without losing functionality. |
|
||||
| Theme port loses brand hue 266 character | Custom flyon-ui theme explicitly maps existing oklch tokens; QA on wave 1 includes side-by-side color comparison vs. current. |
|
||||
| Recorders rewrite (just stabilized) gets re-touched in wave 4 | Wave 4 is last on purpose — primitives are battle-tested by then. The codec-tab pattern from the recent recorders rewrite is generalized into the `.field-group` primitive in wave 1, so wave 4's recorders rewrite is mostly markup migration, not pattern reinvention. |
|
||||
| Density target is too aggressive — 13px text / 28px rows feel cramped on smaller monitors | Wave 1 ships density + AA verified at 1280×800. If feedback says cramped, bump base text to 14px in a single token change. |
|
||||
| Page-level skeleton loaders are extra implementation work | Acceptable cost. Spinners-only would feel cheaper than the rest of the design. |
|
||||
| Native `<input type="date">` looks inconsistent across Chromium / Safari | Acceptable. Inconsistency is small; bundle weight savings of avoiding a date-picker library is real. |
|
||||
|
||||
## Implementation plan handoff
|
||||
|
||||
Once this spec is approved by the user, the next step is invoking the `superpowers:writing-plans` skill to produce a wave-by-wave implementation plan with concrete commit / deploy steps. The plan will live at `docs/superpowers/plans/2026-05-21-ui-shell-rework-plan.md` and reference this spec.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. All seven design sections were approved by the user (Zac) during brainstorming on 2026-05-21. No placeholder values remain in this spec.
|
||||
272
docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Normal file
272
docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# YouTube Importer — Design Spec
|
||||
|
||||
> Status: **design approved 2026-05-23**, awaiting user review of the spec before the implementation plan is written.
|
||||
|
||||
## Context
|
||||
|
||||
The Ingest group in Dragonflight today covers file Upload, Recorders (SRT/RTMP/SDI), Capture (DeckLink), Monitors, and Schedule. There is no path to bring in media that already lives on the public web. The frequent ask is: "I want to grab a YouTube link and have it become an asset in my project, with the same proxy/thumbnail pipeline as anything else." This spec adds a YouTube importer that mirrors the existing upload flow: paste a URL, pick a project, click Import, and the asset shows up in the Library once the worker is done.
|
||||
|
||||
The importer rides on the existing job pipeline. After the download lands in S3, the asset re-enters the same proxy → thumbnail → ready path as a regular upload, so there is no parallel "imported asset" lifecycle to maintain.
|
||||
|
||||
## Goals & non-goals
|
||||
|
||||
**Goals**
|
||||
- Paste a public YouTube URL, end up with a `ready` asset in the chosen project.
|
||||
- Reuse the existing `assets` table, S3 layout, BullMQ pipeline, and Jobs screen — no parallel state machine.
|
||||
- Progress visible from both the import screen (queue rows) and the Jobs screen.
|
||||
- Clear, actionable errors for the obvious failure modes (private, age-gated, removed, geo-blocked, network).
|
||||
|
||||
**Non-goals**
|
||||
- Playlists, channels, or batch-paste of multiple URLs. Single URL per submission. (Easy to add later.)
|
||||
- Cookies / login. Private, members-only, and age-gated videos are out of scope v1.
|
||||
- Quality picker. Always grabs best MP4 (with M4A audio merge fallback).
|
||||
- Non-YouTube sources (Vimeo, Twitch VODs, Dropbox links, etc.). The route is `/imports/youtube` precisely to leave room for siblings later.
|
||||
- Auto-update of yt-dlp inside the running container. Updates land via image rebuild.
|
||||
- Copyright enforcement. We surface a one-line "only import videos you have rights to use" note and stop there.
|
||||
|
||||
## Architecture
|
||||
|
||||
The importer threads through four existing layers:
|
||||
|
||||
```
|
||||
[web-ui] YouTube screen ──POST /imports/youtube──▶ [mam-api]
|
||||
│
|
||||
assets row (status='ingesting')
|
||||
jobs row (type='youtube_import')
|
||||
│
|
||||
BullMQ "import" queue
|
||||
▼
|
||||
[worker]
|
||||
yt-dlp download → S3 originals/
|
||||
ffprobe metadata → assets row
|
||||
status='processing'
|
||||
│
|
||||
BullMQ "proxy" queue ◀── existing path
|
||||
▼
|
||||
proxy → thumbnail → ready
|
||||
```
|
||||
|
||||
Once the worker hands off to the `proxy` queue, the asset is indistinguishable from one that came through Upload — same proxy worker, same thumbnail worker, same Library list.
|
||||
|
||||
## 1. UX
|
||||
|
||||
### Nav
|
||||
|
||||
A 6th child is added to the **Ingest** group in `shell.jsx`, between Upload and Recorders:
|
||||
|
||||
```js
|
||||
{ id: "youtube", label: "YouTube", icon: "download" },
|
||||
```
|
||||
|
||||
The `download` glyph already exists in `icons.jsx`. The matching `ingestChildren` array in `shell.jsx` and the crumbs map in `app.jsx` both gain `"youtube"`.
|
||||
|
||||
### Screen
|
||||
|
||||
A new `YouTubeImport` component lives in `screens-ingest.jsx` and is exported on `window` alongside `Upload`, `Recorders`, etc. It is registered as a route in `app.jsx`.
|
||||
|
||||
Layout — visually a sibling of the Upload screen:
|
||||
|
||||
- **Header**: title "YouTube", subtitle "Paste a link — we download and import the best available MP4."
|
||||
- **Project selector**: same `select` element as Upload's, pre-selected to the first project.
|
||||
- **URL input**: a single-line `field-input` with placeholder "Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)…" and an inline **Import** button. Enter submits. The button is disabled until a URL pattern matches.
|
||||
- **Subtitle line under the input**: "Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported."
|
||||
- **Queue panel**: identical structure to Upload's queue — one row per submitted URL, showing:
|
||||
- Source icon (use `link` glyph) and the URL (truncated middle, full URL in `title` tooltip).
|
||||
- Title once known (filled in by a poll on the asset row).
|
||||
- Progress bar tied to job `progress` (0–100). The worker drives this between 5 and 60 % for download and 60 to 100 % for upload + DB writes.
|
||||
- Status pill: queued → downloading → processing → done / failed.
|
||||
- Error text if the job fails (red, one line).
|
||||
- A "Clear done" button at the top of the queue.
|
||||
|
||||
The queue persists for the session in component state only — no separate UI table. Jobs screen remains the canonical history.
|
||||
|
||||
### URL validation (client-side, before POST)
|
||||
|
||||
Accept (case-insensitive) any of these patterns:
|
||||
- `https?://(www\.|m\.)?youtube\.com/watch\?[^ ]*v=[A-Za-z0-9_-]{11}`
|
||||
- `https?://youtu\.be/[A-Za-z0-9_-]{11}`
|
||||
- `https?://(www\.)?youtube\.com/shorts/[A-Za-z0-9_-]{11}`
|
||||
|
||||
Anything else is rejected inline ("That doesn't look like a YouTube URL") without an API call. The server re-validates as a defense-in-depth check.
|
||||
|
||||
### Out-of-scope v1 (called out, not built)
|
||||
|
||||
- Pasting a playlist URL. Server returns 400 "Playlists aren't supported yet."
|
||||
- Multi-line paste. Single URL only.
|
||||
- Quality picker. yt-dlp format string is hard-coded.
|
||||
- Cookies upload. Private videos fail with a clear message.
|
||||
|
||||
## 2. API
|
||||
|
||||
### Route
|
||||
|
||||
New file `services/mam-api/src/routes/imports.js`, mounted at `/api/v1/imports` in `services/mam-api/src/index.js`.
|
||||
|
||||
**`POST /api/v1/imports/youtube`**
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{ "url": "https://youtu.be/dQw4w9WgXcQ", "projectId": "uuid", "binId": "uuid?" }
|
||||
```
|
||||
|
||||
Behavior:
|
||||
1. Validate `url` against the same three regexes as the client. 400 on miss.
|
||||
2. Reject playlist URLs (URL contains `list=`) with 400 "Playlists aren't supported yet."
|
||||
3. Generate `assetId = uuidv4()`.
|
||||
4. Insert into `assets` with:
|
||||
- `status='ingesting'`
|
||||
- `media_type='video'`
|
||||
- `filename = url` (placeholder; worker overwrites with the sanitized title once yt-dlp prints metadata — keeps the row queryable in the meantime)
|
||||
- `display_name = url` (same; worker overwrites)
|
||||
- `original_s3_key = NULL` (worker fills in)
|
||||
- `source_url = url` (new column — see Schema)
|
||||
- `project_id`, `bin_id`, timestamps.
|
||||
5. Insert into `jobs` with `type='youtube_import'`, `asset_id`, `payload={ url }`, `status='queued'`, `progress=0`.
|
||||
6. Enqueue BullMQ job on the `import` queue:
|
||||
```js
|
||||
await importQueue.add('youtube', { assetId, url });
|
||||
```
|
||||
7. Respond `200 { assetId, jobId }`.
|
||||
|
||||
Errors:
|
||||
- Missing fields → 400.
|
||||
- Bad URL → 400 with `error: 'Invalid YouTube URL'`.
|
||||
- Playlist URL → 400 with `error: 'Playlists aren't supported yet'`.
|
||||
- Project not found → 404.
|
||||
- DB / queue failure → 500 (next(err)).
|
||||
|
||||
### Jobs screen integration
|
||||
|
||||
`services/web-ui/public/screens-jobs.jsx` already normalizes job types via a `kindMap`. Add one entry:
|
||||
```js
|
||||
const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', youtube_import: 'YouTube' };
|
||||
```
|
||||
Retry, delete, and the SSE event stream all work for the new type with no further changes because they key off `job.id`, not `job.type`.
|
||||
|
||||
## 3. Worker
|
||||
|
||||
### Container changes
|
||||
|
||||
`services/worker/Dockerfile` gains two packages:
|
||||
```dockerfile
|
||||
RUN apk add --no-cache ffmpeg yt-dlp python3
|
||||
```
|
||||
`yt-dlp` is in the Alpine `community` repo and pulls `python3` as a runtime dep — we list it explicitly for clarity. Image grows by ~25 MB.
|
||||
|
||||
### New worker
|
||||
|
||||
`services/worker/src/workers/youtube-import.js`, registered in `services/worker/src/index.js`:
|
||||
```js
|
||||
const workers = [
|
||||
createWorker('proxy', proxyWorker),
|
||||
createWorker('thumbnail', thumbnailWorker),
|
||||
createWorker('conform', conformWorker),
|
||||
createWorker('import', youtubeImportWorker),
|
||||
];
|
||||
```
|
||||
|
||||
### Job handler
|
||||
|
||||
For a job with `{ assetId, url }`:
|
||||
|
||||
1. `job.updateProgress(2)` — accepted.
|
||||
2. Build a temp directory `tmpdir()/yt-${jobId}`.
|
||||
3. Run yt-dlp:
|
||||
```sh
|
||||
yt-dlp \
|
||||
--no-playlist \
|
||||
--no-warnings \
|
||||
--restrict-filenames \
|
||||
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b" \
|
||||
--merge-output-format mp4 \
|
||||
--print-json \
|
||||
--newline \
|
||||
-o "<tmpdir>/<assetId>.%(ext)s" \
|
||||
"<url>"
|
||||
```
|
||||
- `--print-json` writes one JSON line at the end with title, duration, width, height, uploader, etc.
|
||||
- `--newline` makes progress lines newline-terminated so we can parse them.
|
||||
- `--restrict-filenames` prevents shell-special characters in temp paths.
|
||||
4. Stream stdout line-by-line. Lines matching `r'\[download\]\s+(\d+(\.\d+)?)%'` map to `job.updateProgress(5 + Math.floor(pct * 0.55))` so download takes us from 5 to 60 %.
|
||||
5. On yt-dlp non-zero exit: parse stderr for the first line containing `ERROR:` and use it as the job's error message. Mark the asset `status='error'`, mark the job failed, throw so BullMQ records it. Surface a friendly substitution for the common cases:
|
||||
- "Private video" → "Private video — not supported."
|
||||
- "Sign in to confirm your age" → "Age-restricted video — not supported."
|
||||
- "Video unavailable" → "Video unavailable or removed."
|
||||
- "This video is not available in your country" → "Video is geo-blocked from this region."
|
||||
- HTTP 429 → "YouTube rate-limited the importer — try again later."
|
||||
- Anything else → use yt-dlp's stderr line verbatim, truncated to 300 chars.
|
||||
6. Parse the last stdout line as JSON to read metadata. The resulting file is `<tmpdir>/<assetId>.mp4`.
|
||||
7. `getMediaInfo` (existing helper in `services/worker/src/ffmpeg/executor.js`) on that path. Use ffprobe's values for codec/fps/duration when yt-dlp's are missing or wrong.
|
||||
8. Sanitize the title for the S3 filename: keep `[A-Za-z0-9 ._-]`, collapse runs of whitespace, trim, cap at 120 chars, append `.mp4`. If the sanitized title is empty, fall back to `youtube-<videoId>.mp4`.
|
||||
9. Upload to `originals/{assetId}/{sanitized-title}.mp4` via the existing `uploadToS3` helper. Progress 60 → 90 %.
|
||||
10. UPDATE the assets row with:
|
||||
- `filename = <sanitized title>.mp4`
|
||||
- `display_name = <yt-dlp title untouched>`
|
||||
- `original_s3_key = originals/<assetId>/<sanitized-title>.mp4`
|
||||
- `codec`, `resolution`, `fps`, `duration_ms`, `file_size` from ffprobe.
|
||||
- `status = 'processing'`
|
||||
- `updated_at = NOW()`
|
||||
11. Enqueue a `proxy` job on the existing `proxy` queue with the same payload shape `upload.js` uses:
|
||||
```js
|
||||
await proxyQueue.add('generate', {
|
||||
assetId,
|
||||
inputKey: asset.original_s3_key,
|
||||
outputKey: `proxies/${assetId}.mp4`,
|
||||
});
|
||||
```
|
||||
12. `job.updateProgress(100)`. Return — BullMQ marks the import job done. The proxy job picks up the rest exactly like a regular upload.
|
||||
13. Always `rm -rf` the temp directory in a `finally`.
|
||||
|
||||
### Concurrency & retries
|
||||
|
||||
- Default BullMQ concurrency for this queue: **1** per worker process. Two simultaneous yt-dlp invocations risk YouTube rate-limiting more than they help throughput. Configurable later via env if needed.
|
||||
- No automatic BullMQ retry — yt-dlp failures are almost always permanent (private, geo, removed) and a silent retry storm would chew through quota. The Jobs screen's manual Retry button is the right knob for "this should be transient" cases.
|
||||
|
||||
## 4. Schema migration
|
||||
|
||||
New file `services/mam-api/src/db/migrations/011-youtube-import.sql`:
|
||||
|
||||
```sql
|
||||
-- 1. Add the new job type to the enum.
|
||||
-- Postgres requires ALTER TYPE ... ADD VALUE for enum changes.
|
||||
ALTER TYPE job_type ADD VALUE IF NOT EXISTS 'youtube_import';
|
||||
|
||||
-- 2. Remember where an asset came from. NULL for everything that
|
||||
-- pre-dates the importer; populated for any imported asset.
|
||||
ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT;
|
||||
```
|
||||
|
||||
`source_url` is exposed on the asset drawer as a "Source" line ("imported from youtu.be/…") in a follow-up PR — out of scope for this spec, but worth noting that the column exists for it.
|
||||
|
||||
## 5. Files touched
|
||||
|
||||
**New**
|
||||
- `services/mam-api/src/routes/imports.js`
|
||||
- `services/mam-api/src/db/migrations/011-youtube-import.sql`
|
||||
- `services/worker/src/workers/youtube-import.js`
|
||||
|
||||
**Edited**
|
||||
- `services/mam-api/src/index.js` — mount the new route.
|
||||
- `services/web-ui/public/screens-ingest.jsx` — add `YouTubeImport`, export on `window`.
|
||||
- `services/web-ui/public/shell.jsx` — add the nav child, extend `ingestChildren`.
|
||||
- `services/web-ui/public/app.jsx` — register the route and the crumb.
|
||||
- `services/web-ui/public/screens-jobs.jsx` — extend `kindMap` with `youtube_import: 'YouTube'`.
|
||||
- `services/worker/src/index.js` — register the `import` queue worker.
|
||||
- `services/worker/Dockerfile` — add `yt-dlp` and `python3` to the apk install line.
|
||||
|
||||
## 6. Risks & trade-offs
|
||||
|
||||
- **Worker egress**. The worker container needs outbound HTTPS to YouTube. Fine in the current homelab; will fail in a locked-down cluster. Documented in the implementation plan.
|
||||
- **yt-dlp drift**. YouTube changes break old yt-dlp versions every few weeks. The Alpine package lags upstream by days. Fix is to rebuild the worker image. We do not auto-update inside the running container — too risky for an offline / locked-down deploy. If imports start failing en masse, the runbook is `docker compose build worker && docker compose up -d worker`.
|
||||
- **Single-URL UX feels light**. That is deliberate for v1. Adding multi-URL paste and playlist expansion are both small follow-ups once the single-URL path is stable.
|
||||
- **No copyright enforcement**. We rely on the one-line notice in the UI. If misuse becomes a real concern, the next step would be an admin allowlist of domains or a per-user import quota — not in this spec.
|
||||
- **`filename = url` placeholder**. Briefly, the asset row in the Library shows the URL as the name. The worker overwrites it within seconds for a successful import. Acceptable; the Library already handles "ingesting" assets with placeholder names from the upload path.
|
||||
|
||||
## 7. Acceptance
|
||||
|
||||
The feature is done when:
|
||||
- A user can navigate to **Ingest → YouTube**, paste a public YouTube URL, pick a project, click Import, and within a minute or two see the asset appear in the Library with proxy and thumbnail.
|
||||
- A failed import (private video, removed video, bogus URL) shows a clear error message on both the YouTube screen's queue row and the Jobs screen.
|
||||
- The Jobs screen lists "YouTube" jobs alongside Proxy / Thumbnail / Conform, with Retry working.
|
||||
- `source_url` is populated on the imported asset row.
|
||||
- Image rebuild + `docker compose up -d worker` is the documented recovery path if YouTube changes break yt-dlp.
|
||||
269
docs/superpowers/specs/2026-05-27-auth-system-design.md
Normal file
269
docs/superpowers/specs/2026-05-27-auth-system-design.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# Dragonflight User Authentication — Design
|
||||
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Date:** 2026-05-27
|
||||
**Brainstormed with:** Zac
|
||||
|
||||
## Problem
|
||||
|
||||
Dragonflight has the skeleton of an auth system spread across the codebase:
|
||||
|
||||
- `users` table (`id`, `username`, `password_hash`, `display_name`, `role`)
|
||||
- `sessions` table (`sid`, `sess`, `expire`) for `connect-pg-simple`
|
||||
- `groups`, `user_groups`, `api_tokens` tables
|
||||
- `SESSION_SECRET` env var
|
||||
- `AUTH_ENABLED` env flag with boot-log toggle
|
||||
- PR #26 frontend handler that bounces to `/login.html` on 401
|
||||
- Issue #94 "session security fixes" deployed 2026-05-26 (commit `3ebe5d6`)
|
||||
|
||||
But the actual `express-session` middleware was never mounted in `services/mam-api/src/index.js`. There is no `/api/v1/auth/*` router. There is no `requireAuth` middleware. As a result, when `AUTH_ENABLED=true` was tried:
|
||||
|
||||
1. User submits login, server returns 200 OK from a stub endpoint.
|
||||
2. No `Set-Cookie` is ever sent (no session middleware mounted).
|
||||
3. The next request to a protected route returns 401.
|
||||
4. Frontend bounces to `/login.html`.
|
||||
5. **Infinite redirect loop.**
|
||||
|
||||
The prior attempts failed because auth was being built reactively in pieces, with no single source of truth for what "logged in" means.
|
||||
|
||||
## Goals
|
||||
|
||||
- One coherent, readable auth code path.
|
||||
- Web UI logins survive page reloads and container restarts.
|
||||
- Premiere panel can authenticate via long-lived bearer tokens.
|
||||
- First-run setup works on a fresh install with no env var or CLI gymnastics.
|
||||
- The whole auth flow can be exercised by automated tests, including a regression test for the redirect-loop failure mode.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- MFA / TOTP.
|
||||
- OAuth / OIDC delegation (Forgejo, Google, etc.).
|
||||
- Per-project or per-recorder permissions. Flat access: logged in = full access.
|
||||
- Email-based "forgot password" (no SMTP assumed; admin-reset only).
|
||||
- Audit log of who-did-what (the `last_login_at` column is the minimum).
|
||||
- Service-to-service auth for `node-agent` — keeps existing `019-node-token-binding` mechanism.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Decision | Choice | Reasoning |
|
||||
|---|---|---|
|
||||
| Client surface | Web UI + Premiere panel | Two transports (cookies + bearer), one identity backend |
|
||||
| Permission model | Flat (logged in = full access) | Small homogeneous operator population. `groups` / `user_groups` schemas stay inert. |
|
||||
| Identity provider | Local username/password | On-prem broadcast operators won't tolerate OIDC roundtrips. Matches existing schema. |
|
||||
| First-user bootstrap | First-run setup page | Hardest to mis-configure. No env vars to leak. No CLI to remember. |
|
||||
| Session lifetime | 8h absolute + 1h sliding idle | Operator security posture, tighter than typical SaaS. |
|
||||
| Auth library | Hand-rolled (`express-session` + `connect-pg-simple`) | Explicit, debuggable. Rejected JWT and Passport for this codebase. |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Single source of truth
|
||||
|
||||
"Logged in" means exactly one of two things:
|
||||
|
||||
1. The request carries a valid `dragonflight.sid` cookie whose row in `sessions` hasn't expired and isn't past its 1h-idle or 8h-absolute window, OR
|
||||
2. The request carries `Authorization: Bearer <token>` whose SHA-256 matches an `api_tokens` row that hasn't been revoked or expired.
|
||||
|
||||
Nothing else counts. No `localStorage` flags, no JWT, no client-side "I think I'm logged in" hints.
|
||||
|
||||
### One middleware, one check
|
||||
|
||||
`services/mam-api/src/middleware/auth.js` exposes a single `requireAuth` function:
|
||||
|
||||
```js
|
||||
export async function requireAuth(req, res, next) {
|
||||
// Dev mode preserved. The 'dev' user is a real row in `users` seeded at
|
||||
// boot when AUTH_ENABLED !== 'true', so FK-bearing routes (api_tokens,
|
||||
// future comments, audit fields) keep working without conditional logic.
|
||||
if (process.env.AUTH_ENABLED !== 'true') {
|
||||
req.user = DEV_USER; // { id: <UUID of seeded 'dev' user>, username: 'dev' }
|
||||
return next();
|
||||
}
|
||||
|
||||
// 1. Session check
|
||||
if (req.session?.user_id) {
|
||||
const now = Date.now();
|
||||
if (now - req.session.first_seen_at > 8 * 3600 * 1000) return destroyAnd401(req, res);
|
||||
if (now - req.session.last_seen_at > 1 * 3600 * 1000) return destroyAnd401(req, res);
|
||||
req.session.last_seen_at = now;
|
||||
req.user = await loadUser(req.session.user_id);
|
||||
if (!req.user) return destroyAnd401(req, res);
|
||||
return next();
|
||||
}
|
||||
|
||||
// 2. Bearer check
|
||||
const bearer = parseBearer(req.headers.authorization);
|
||||
if (bearer) {
|
||||
const hash = sha256hex(bearer);
|
||||
const row = await pool.query(
|
||||
`SELECT t.id, t.user_id, t.expires_at, u.username
|
||||
FROM api_tokens t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = $1`, [hash]);
|
||||
if (row.rows.length && (!row.rows[0].expires_at || row.rows[0].expires_at > new Date())) {
|
||||
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.rows[0].id]).catch(() => {});
|
||||
req.user = { id: row.rows[0].user_id, username: row.rows[0].username };
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Otherwise
|
||||
return res.status(401).json({ error: 'unauthorized' });
|
||||
}
|
||||
```
|
||||
|
||||
Mounted at the `/api/v1` level in `services/mam-api/src/index.js`, **before** the individual route mounts, with an allowlist for the three pre-login auth paths:
|
||||
|
||||
```js
|
||||
app.use('/api/v1', (req, res, next) => {
|
||||
const unauth = ['/auth/login', '/auth/setup', '/auth/setup-required'];
|
||||
if (unauth.some(p => req.path === p)) return next();
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
// then: app.use('/api/v1/assets', assetsRouter), etc.
|
||||
```
|
||||
|
||||
`/health` lives at the root, outside the `/api/v1` mount, so it's naturally unaffected. `/api/v1/cluster/*` keeps its existing `019-node-token-binding` service-auth path: requireAuth runs first, fails with 401 for an unauthenticated request, **but** the cluster routes themselves do their own token check on request bodies, so node-agent traffic must include a valid user session OR an api_token (which is the change — node-agent will need to be issued an api_token at install time). Alternative: carve `/api/v1/cluster/*` out of the requireAuth gate too, and keep node-agent on its existing binding token alone. Implementer should pick — flagged in the implementation order.
|
||||
|
||||
### Session middleware (actually wired this time)
|
||||
|
||||
In `services/mam-api/src/index.js`, **before any route**:
|
||||
|
||||
```js
|
||||
import session from 'express-session';
|
||||
import connectPgSimple from 'connect-pg-simple';
|
||||
const PgStore = connectPgSimple(session);
|
||||
|
||||
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
||||
|
||||
app.use(session({
|
||||
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
|
||||
secret: process.env.SESSION_SECRET,
|
||||
name: 'dragonflight.sid',
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.TRUST_PROXY === 'true',
|
||||
path: '/',
|
||||
maxAge: 8 * 3600 * 1000,
|
||||
},
|
||||
rolling: false, // sliding renewal handled in requireAuth so we can enforce idle + absolute separately
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
}));
|
||||
```
|
||||
|
||||
### Auth router
|
||||
|
||||
`services/mam-api/src/routes/auth.js`:
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/v1/auth/setup-required` | none | `{ required: bool }`. Cheap, no auth. |
|
||||
| `POST` | `/api/v1/auth/setup` | none | Only succeeds if `users` is empty. Creates first user, logs them in. |
|
||||
| `POST` | `/api/v1/auth/login` | none | `{ username, password }` -> 200 + cookie or 401 |
|
||||
| `POST` | `/api/v1/auth/logout` | required | Destroys session row, clears cookie |
|
||||
| `GET` | `/api/v1/auth/me` | required | `{ id, username, display_name }` |
|
||||
| `POST` | `/api/v1/auth/password` | required | Change own password (requires current) |
|
||||
| `GET/POST/DELETE` | `/api/v1/auth/users[/:id]` | required | User CRUD |
|
||||
| `GET/POST/DELETE` | `/api/v1/auth/tokens[/:id]` | required | Current user's API tokens |
|
||||
|
||||
### Data model
|
||||
|
||||
Existing schema is almost right. One small migration:
|
||||
|
||||
```sql
|
||||
-- services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||||
-- idle / absolute timestamps live inside session.sess JSONB; no schema change needed
|
||||
```
|
||||
|
||||
`groups` and `user_groups` stay as-is, unused for v1. `api_tokens` is already correctly shaped.
|
||||
|
||||
## Flows
|
||||
|
||||
### Browser login (the one that broke last time)
|
||||
|
||||
1. SPA boots, `<AuthGate>` calls `GET /api/v1/auth/me`.
|
||||
2. `requireAuth` returns 401.
|
||||
3. AuthGate calls `GET /api/v1/auth/setup-required`. If `true`, render Setup screen. Otherwise, render Login screen.
|
||||
4. User submits `POST /api/v1/auth/login`. Server `bcrypt.compare`s, sets `req.session.user_id`, `first_seen_at`, `last_seen_at`. **Critical:** `await new Promise(r => req.session.save(r))` before responding, so the cookie is persisted to Postgres before the next request can arrive.
|
||||
5. AuthGate re-calls `/api/v1/auth/me`, gets 200, renders the app.
|
||||
|
||||
**Why this doesn't loop:** the explicit `req.session.save()` callback before response guarantees the cookie row exists before the SPA can fire its next request. `requireAuth` returns a clean 401 (not a redirect) so the SPA decides what to render. The static `/login.html` is deleted; there is no HTML bounce.
|
||||
|
||||
### Premiere panel bearer
|
||||
|
||||
1. Web UI -> Settings -> API Tokens -> "New token" named "Premiere panel".
|
||||
2. `POST /api/v1/auth/tokens` returns `{ token: 'dfl_<32 hex>', prefix: 'dfl_a3f2', id }` **exactly once**.
|
||||
3. Premiere panel sends `Authorization: Bearer dfl_<...>` on every request. `requireAuth` SHA-256s it, looks up `api_tokens.token_hash`, updates `last_used_at`.
|
||||
|
||||
### Idle + absolute timeout (inside `requireAuth`)
|
||||
|
||||
```
|
||||
if session present:
|
||||
if now - session.first_seen_at > 8h -> destroy session, 401
|
||||
if now - session.last_seen_at > 1h -> destroy session, 401
|
||||
session.last_seen_at = now
|
||||
req.user = lookup(session.user_id)
|
||||
next()
|
||||
```
|
||||
|
||||
Bearer tokens have their own optional `expires_at` (`NULL` = never expires); checked the same way.
|
||||
|
||||
## Frontend
|
||||
|
||||
- **`services/web-ui/src/auth-gate.jsx`** — new component that wraps the SPA. On mount: `GET /me`. On 401: check `setup-required`, render either Setup or Login. On 200: render the app shell.
|
||||
- **Login screen** — layout B from brainstorm: 22px wordmark over "WILD DRAGON BROADCAST" tagline above a `--bg-1` card containing username, password, "Sign in" button. Matches DESIGN.md tokens.
|
||||
- **Setup screen** — same chrome; fields = username, password, confirm password; button = "Create admin".
|
||||
- **Settings -> Account section** — change password.
|
||||
- **Settings -> API Tokens section** — list / create / revoke. New token shown exactly once with a copy affordance.
|
||||
- **Fetch wrapper** — the central `ZAMPP_API.fetch` (already exists) gains a 401 handler that re-mounts AuthGate's Login state with the current path saved as `last_path`, restored after re-auth.
|
||||
|
||||
### Removed
|
||||
|
||||
- The static `/login.html` page (PR #26's bounce target) is deleted. SPA handles login internally; no full-page reload.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| Wrong username or password | `401 { error: 'invalid credentials' }`. Same message either way, no user enumeration. |
|
||||
| Login rate limiting | Per-IP exponential backoff (1s, 2s, 4s, 8s, max 30s). In-memory `Map`. Single-instance limitation documented. |
|
||||
| Idle / absolute expiry | 401 -> AuthGate Login. Last path saved, restored on re-auth. |
|
||||
| Setup after first user exists | `409 { error: 'setup already complete' }`. Permanently disabled. |
|
||||
| Token revoke | `DELETE /api/v1/auth/tokens/:id` — only owner can revoke. Subsequent bearer requests 401. |
|
||||
| Delete-self when only user | `409 { error: 'cannot delete last user' }`. |
|
||||
| Forgot password | No self-serve. Any logged-in user can reset another via `POST /api/v1/auth/users/:id/password`. Documented as the recovery path. |
|
||||
| Password rules | Min 12 chars, no max, no character class requirements (NIST SP 800-63B). `bcrypt` cost 12. |
|
||||
| CSRF | `SameSite=Lax` + same origin + required `X-Requested-With: dragonflight-ui` header on mutating requests (belt-and-suspenders). |
|
||||
| Session table growth | `connect-pg-simple` `pruneSessionInterval: 60 * 15` (every 15 min). |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit — `services/mam-api/test/middleware/auth.test.js`**: requireAuth with (a) no creds, (b) valid session, (c) idle-expired session, (d) absolute-expired session, (e) valid bearer, (f) invalid bearer, (g) bearer matching a deleted user.
|
||||
- **Integration — `services/mam-api/test/auth.integration.test.js`**: spin up Express + test Postgres. Walks: setup -> login -> /me -> mutating call -> logout -> /me 401. Second pass: idle timeout simulated by mutating `last_seen_at` in DB. Third pass: bearer issue -> use -> revoke -> 401.
|
||||
- **Regression test for the redirect-loop bug:** explicit test that after `POST /auth/login` returns 200, a subsequent `GET /auth/me` with the returned cookie returns 200 in the same test client. This is the test that would have caught the original failure.
|
||||
- **Manual smoke (documented in PR):** fresh install -> setup -> create admin -> land on dashboard -> reload (stays logged in) -> wait 1h idle -> reload -> bounce to login.
|
||||
|
||||
## Implementation order
|
||||
|
||||
Suggested sequencing for the implementation plan (writing-plans will refine):
|
||||
|
||||
1. Migration `023-auth-session-timestamps.sql`. Add idempotent seed of the dev user (`INSERT ... ON CONFLICT DO NOTHING` with a fixed UUID) so dev mode FK-bearing routes work out of the box.
|
||||
2. `express-session` + `connect-pg-simple` wiring in `index.js`.
|
||||
3. `requireAuth` middleware (with `DEV_USER` constant resolved from the seeded row).
|
||||
4. Auth router (setup, login, logout, me, password).
|
||||
5. Apply `requireAuth` to API router with allowlist. Decide cluster carve-out (see Architecture).
|
||||
6. Auth tests (unit + integration + regression).
|
||||
7. Frontend `<AuthGate>` + Login screen + Setup screen.
|
||||
8. Frontend Settings -> Account + API Tokens.
|
||||
9. Delete `/login.html`.
|
||||
10. User CRUD + token CRUD routes.
|
||||
11. Rate limiting + CSRF header.
|
||||
12. Documentation: README updates, `AUTH_ENABLED` transition notes.
|
||||
|
||||
## Out-of-band notes for the implementer
|
||||
|
||||
- The current `cors({ origin: true, credentials: true })` in `index.js` is too permissive once cookies start carrying authority. Tighten to a specific origin list (driven by an `ALLOWED_ORIGINS` env var) at the same time as wiring the session middleware — otherwise we're undoing the `SameSite=Lax` protection from the other side.
|
||||
- node-agent -> mam-api traffic on `/api/v1/cluster/*` must keep working. Add a route-level carve-out comment that this path uses the existing `019-node-token-binding` token, not the user-auth path.
|
||||
- The boot log currently says `Authentication: ENABLED` / `DISABLED (set AUTH_ENABLED=true for production)`. Once this lands, the recommended default flips: `AUTH_ENABLED=true` becomes the documented default in `.env.example` and the README, and `AUTH_ENABLED=false` is documented as a dev-only escape hatch.
|
||||
84
docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md
Normal file
84
docs/superpowers/specs/2026-05-29-hls-vod-playback-design.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# HLS VOD Playback for Browser
|
||||
|
||||
Date: 2026-05-29 | Status: design → implementation
|
||||
Authors: Zac + Claude
|
||||
|
||||
## Purpose
|
||||
|
||||
Replace the browser playback path for **recorded (VOD) assets** with HLS, retiring
|
||||
the MP4 range-stitching workaround. The MP4 proxy is **kept** (supplements, not
|
||||
replaces) because the Premiere UXP panel and conform pipeline consume it.
|
||||
|
||||
## Background — current state
|
||||
|
||||
- `GET /assets/:id/stream` returns `{ url: /api/v1/assets/:id/video, type: 'mp4' }`
|
||||
for ready assets.
|
||||
- `GET /assets/:id/video` streams `proxies/<id>.mp4` through Node with the
|
||||
**RustFS range-stitching hack** (`stitchedS3Stream`): RustFS mis-serves ranged
|
||||
GETs whose start offset is past ~5.8 MB, so the endpoint streams from byte 0 and
|
||||
drops bytes. Works, but wastes bandwidth/CPU per seek and is fragile.
|
||||
- **Live** assets already use HLS (`type: 'hls'`, `/live/<id>/index.m3u8`), and
|
||||
`hls.js` is already loaded and wired in `screens-asset.jsx` for `type === 'hls'`.
|
||||
- The proxy worker (`services/worker/src/workers/proxy.js`) produces a single
|
||||
H.264/AAC/yuv420p MP4 — already HLS-compatible.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Supplement, not replace.** Keep `proxies/<id>.mp4`; add an HLS rendition.
|
||||
- **Generate in the proxy worker** via fast remux (`-c copy`) — no re-encode.
|
||||
- **Serve segments through mam-api** as whole-file GETs (no Range) — sidesteps the
|
||||
RustFS range bug entirely and reuses session auth.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Generation (worker/proxy.js)
|
||||
After uploading `proxies/<id>.mp4`, remux it to HLS into a temp dir:
|
||||
```
|
||||
ffmpeg -i <proxy.mp4> -c copy -f hls \
|
||||
-hls_time 6 -hls_playlist_type vod -hls_flags independent_segments \
|
||||
-hls_segment_filename <tmp>/seg_%03d.ts <tmp>/index.m3u8
|
||||
```
|
||||
Upload every file in the temp dir to `hls/<assetId>/` (playlist + `.ts`). Set
|
||||
`assets.hls_s3_key = 'hls/<assetId>/index.m3u8'`. Remux is seconds; failure is
|
||||
non-fatal (MP4 path still works as fallback).
|
||||
|
||||
### 2. Storage / schema
|
||||
Migration adds `assets.hls_s3_key TEXT` (nullable). Presence = HLS available.
|
||||
Segment objects live under `hls/<assetId>/seg_NNN.ts`; playlist references
|
||||
**relative** segment names so the serving endpoint is path-agnostic.
|
||||
|
||||
### 3. Serving (mam-api)
|
||||
New `GET /assets/:id/hls/:file` (file = `index.m3u8` or `seg_NNN.ts`):
|
||||
- Validate `:file` against `^(index\.m3u8|seg_\d+\.ts)$` (no traversal).
|
||||
- Whole-object GET of `hls/<id>/<file>` from S3 — **no Range handling**.
|
||||
- Content-Type: `application/vnd.apple.mpegurl` (m3u8) / `video/mp2t` (ts).
|
||||
- `Cache-Control: private, max-age=3600` for segments; `no-cache` for the playlist.
|
||||
- Covered by the existing `requireAuth` gate; `hls.js` carries the same-origin
|
||||
session cookie (same mechanism the live HLS path already relies on).
|
||||
|
||||
### 4. Stream selection (mam-api `/stream`)
|
||||
For non-live assets: if `hls_s3_key` is set →
|
||||
`{ url: '/api/v1/assets/:id/hls/index.m3u8', type: 'hls' }`. Else fall back to the
|
||||
existing MP4 `/video` response. Live unchanged.
|
||||
|
||||
### 5. Backfill (existing assets)
|
||||
Add an `hls` BullMQ job + `POST /assets/:id/reprocess?type=hls`: downloads the
|
||||
existing `proxy_s3_key`, remuxes to HLS, uploads, sets `hls_s3_key`. No re-encode.
|
||||
|
||||
### 6. Frontend
|
||||
No change required — `screens-asset.jsx` already plays `type: 'hls'` via `hls.js`.
|
||||
Verify `hls.js` xhr carries credentials (same-origin cookie) for the proxied
|
||||
segments; add `xhrSetup` withCredentials only if needed.
|
||||
|
||||
## Out of scope
|
||||
- Multi-bitrate/ABR ladders (single rendition for now).
|
||||
- Replacing the MP4 proxy or the `/video` endpoint (kept as fallback + for panel).
|
||||
- Live-asset playback changes (already HLS).
|
||||
|
||||
## Test plan
|
||||
1. Upload/capture an asset → proxy job produces MP4 **and** `hls/<id>/index.m3u8`.
|
||||
2. `/stream` returns `type: 'hls'`; `/assets/:id/hls/index.m3u8` → 200 m3u8;
|
||||
`/assets/:id/hls/seg_000.ts` → 200 `video/mp2t`, whole-file (no 206/Range).
|
||||
3. Browser: asset plays + seeks via hls.js (no range-stitching path hit).
|
||||
4. `reprocess?type=hls` backfills an older asset; it then plays via HLS.
|
||||
5. MP4 proxy + `/hires` download still work (panel workflow intact).
|
||||
235
docs/superpowers/specs/2026-05-30-playout-mcr-design.md
Normal file
235
docs/superpowers/specs/2026-05-30-playout-mcr-design.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# Wild Dragon MAM — Playout / Master Control (MCR)
|
||||
|
||||
**Date:** 2026-05-30 (revised 2026-05-30 — §7 closed)
|
||||
**Status:** APPROVED — implementation in progress (code drafted but uncommitted; see WORK_LOG_PLAYOUT.md)
|
||||
**Author:** Zac + Claude
|
||||
|
||||
---
|
||||
|
||||
## Resolved Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|----------|----------|
|
||||
| Playout engine | **CasparCG Server** (orchestrated via AMCP), not ffmpeg-native |
|
||||
| Channel count | **Multi-channel from start** — N independent channels, placed across cluster nodes by capability (mirrors recorders) |
|
||||
| Scheduling model | **Phased** — Phase A: on-demand playlist player; Phase B: 24/7 continuous channel |
|
||||
| Output targets | SDI (DeckLink), NDI, SRT, RTMP — all via CasparCG consumers |
|
||||
| Media source | Assets live in **S3**; must be staged to a CasparCG-local media volume before play (see §4) |
|
||||
| CasparCG packaging | **Build our own image** (like `capture/build-with-decklink.sh`) — GL context via GPU passthrough or Xvfb; NDI + DeckLink SDKs fetched at build time (not redistributable) |
|
||||
| Master codec playability | Zac confirms current masters **play fine in CasparCG** — no transcode-for-playout step; staging is a plain S3→/media copy. Validate on hardware but do not gate on it |
|
||||
| Management UI | **Single Dragonflight `playout.html` GUI** drives everything via AMCP; operator never touches CasparCG directly |
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Playout adds **master-control-room** capability to Dragonflight: take library assets, arrange them on a timeline / playlist, and play them out continuously to a broadcast output — SDI via DeckLink, or stream via SRT / RTMP / NDI. Drag-and-drop scheduling, a program/preview monitor, and as-run logging.
|
||||
|
||||
This is the **mirror image** of the existing capture path. Capture is `input → ffmpeg encode → S3`. Playout is `S3 asset → engine → output`. We reuse three things wholesale:
|
||||
|
||||
1. **Cluster node + capability model** — nodes already advertise DeckLink/Deltacast/GPU in `cluster_nodes.capabilities`; channels are placed on nodes that have a free output port, exactly as recorders claim input ports.
|
||||
2. **Sidecar orchestration** — mam-api spawns containers via the local Docker socket or the remote `node-agent /sidecar/start`. A CasparCG channel is just a different sidecar image.
|
||||
3. **Scheduler tick + PG advisory lock** — `src/scheduler.js` already runs a single-leader tick over a schedule table. Phase B's wall-clock channel reuses this pattern.
|
||||
|
||||
### Why CasparCG over ffmpeg-native
|
||||
|
||||
The capture stack proves we can drive ffmpeg + DeckLink. But playout's hard part is **gapless, frame-accurate, clean transitions between clips** — every clip boundary in an ffmpeg-per-clip model is a black flash unless we engineer a concat-feeder. CasparCG solves this natively: a channel is a persistent output with a playlist, hard/mix/wipe transitions, layered graphics/logo (CG), and DeckLink/NDI/SRT/RTMP consumers built in. We orchestrate it over **AMCP** (its TCP control protocol) instead of reinventing a feeder. Trade: a new dependency + container image, and media must be on a CasparCG-visible disk (§4).
|
||||
|
||||
---
|
||||
|
||||
## 1. Data Model
|
||||
|
||||
New migration `029-playout.sql`. Five tables.
|
||||
|
||||
### 1.1 `playout_channels`
|
||||
A logical output. One channel → one engine instance → one output target.
|
||||
|
||||
```
|
||||
id uuid pk
|
||||
name text -- "Channel 1", "Pop-up SDI"
|
||||
node_id uuid -> cluster_nodes(id) -- where the engine runs (null = primary)
|
||||
output_type text -- 'decklink' | 'ndi' | 'srt' | 'rtmp'
|
||||
output_config jsonb -- { device_index } | { ndi_name } | { url, latency } | { url, key }
|
||||
video_format text -- '1080i5994' | '1080p5994' | '720p5994' ...
|
||||
status text -- 'stopped' | 'starting' | 'running' | 'error'
|
||||
container_id text -- running CasparCG sidecar
|
||||
project_id uuid -> projects(id) -- RBAC scoping (nullable = admin-only)
|
||||
created_at / updated_at
|
||||
```
|
||||
|
||||
`output_type` + `output_config` map straight to a CasparCG consumer:
|
||||
- `decklink` → `ADD <ch> DECKLINK <device> ...`
|
||||
- `ndi` → `ADD <ch> NDI ...`
|
||||
- `srt`/`rtmp` → `ADD <ch> FFMPEG <url> -f mpegts ...` (CasparCG 2.3+ ffmpeg consumer)
|
||||
|
||||
### 1.2 `playout_playlists`
|
||||
An ordered list of items bound to a channel. Phase A's primary object.
|
||||
|
||||
```
|
||||
id, channel_id -> playout_channels(id)
|
||||
name, loop boolean, created_at / updated_at
|
||||
```
|
||||
|
||||
### 1.3 `playout_items`
|
||||
One entry on a playlist OR one entry on the 24/7 timeline.
|
||||
|
||||
```
|
||||
id
|
||||
playlist_id uuid -> playout_playlists(id) -- Phase A
|
||||
asset_id uuid -> assets(id)
|
||||
sort_order int -- position in playlist (Phase A)
|
||||
scheduled_at timestamptz -- wall-clock start (Phase B, null in A)
|
||||
in_point numeric -- seconds, trim head (reuse subclip in/out from editor)
|
||||
out_point numeric -- seconds, trim tail
|
||||
transition text -- 'cut' | 'mix' | 'wipe'
|
||||
transition_ms int
|
||||
graphics jsonb -- optional CG/template overlay (Phase B+)
|
||||
media_status text -- 'pending' | 'staging' | 'ready' | 'error' (see §4)
|
||||
media_path text -- resolved path inside the CasparCG media volume
|
||||
```
|
||||
|
||||
### 1.4 `playout_schedule` (Phase B)
|
||||
Day-ahead, wall-clock-bound timeline rows. Same shape as `playout_items` but `scheduled_at` is authoritative and the scheduler tick (§5) drives transitions. Phase A can ignore this table.
|
||||
|
||||
### 1.5 `playout_as_run`
|
||||
Append-only log: what actually played, when, for how long. Compliance / billing.
|
||||
|
||||
```
|
||||
id, channel_id, asset_id, item_id
|
||||
started_at, ended_at, duration_s, result -- 'played' | 'skipped' | 'error'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Services & Components
|
||||
|
||||
### 2.1 New sidecar: `services/playout/` (CasparCG wrapper)
|
||||
A thin container: **CasparCG Server** + a small Node control shim exposing HTTP, the same way `capture` wraps ffmpeg.
|
||||
|
||||
- Base image: official/community `casparcg/server` (Linux build with DeckLink + NDI + FFmpeg producers/consumers).
|
||||
- Node shim (`src/index.js`): opens an AMCP TCP socket to local CasparCG, exposes:
|
||||
- `POST /channel/start` → `ADD <ch> <consumer>` for the channel's output target
|
||||
- `POST /play` → `PLAY <ch>-<layer> <media> [transition]`
|
||||
- `POST /loadbg` + `/play` → preview/cue then take (preview monitor)
|
||||
- `POST /stop`, `GET /status` → `INFO <ch>` (current clip, position, fps)
|
||||
- playlist load → translate `playout_items` rows into a sequence of AMCP `LOADBG`/`PLAY` calls, advancing on `OnTransition` / end-of-clip events.
|
||||
- Mirrors capture's status-polling contract so the UI monitor reuses existing plumbing.
|
||||
|
||||
### 2.2 mam-api: `src/routes/playout.js`
|
||||
CRUD + control, RBAC-scoped via the existing `assertProjectAccess` helper (channels carry `project_id`).
|
||||
|
||||
```
|
||||
GET /playout/channels list (project-filtered)
|
||||
POST /playout/channels create (edit on project)
|
||||
POST /playout/channels/:id/start|stop spawn/kill CasparCG sidecar
|
||||
GET /playout/channels/:id/status proxy engine INFO
|
||||
POST /playout/channels/:id/play|pause|skip transport control
|
||||
GET/POST/PUT/DELETE /playout/playlists... playlist + item CRUD, reorder
|
||||
POST /playout/items/:id/stage kick S3→media-volume staging (§4)
|
||||
GET /playout/channels/:id/asrun as-run log
|
||||
```
|
||||
|
||||
Channel start/stop reuses `resolveNodeTarget()` + the Docker-socket / `node-agent /sidecar/start` split already in `recorders.js`. **Refactor opportunity:** lift that sidecar-spawn logic out of `recorders.js` into `src/orchestration/sidecar.js` so both recorders and playout share it (keep this small — only what both need).
|
||||
|
||||
### 2.3 web-ui: `playout.html` + `public/playout.jsx`
|
||||
New MCR page. Layout:
|
||||
|
||||
```
|
||||
┌─ PREVIEW ───────────┬─ PROGRAM (on air) ──────┐
|
||||
│ [cued clip] │ [live output] ● ON AIR │
|
||||
│ TC / duration │ TC / remaining │
|
||||
│ [CUE] [TAKE] │ [PLAY][PAUSE][SKIP][STOP]│
|
||||
├─ MEDIA BIN ─────────┴──────────────────────────┤
|
||||
│ (draggable asset list, reuse asset browser) │
|
||||
├─ PLAYLIST / TIMELINE ──────────────────────────┤
|
||||
│ ▸ clip A ──▸ clip B ──▸ clip C (drag-drop) │ Phase A: ordered list
|
||||
│ └ 24h time grid w/ now-bar │ Phase B: time-of-day grid
|
||||
└────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Drag-drop: reuse whatever the NLE editor timeline uses (check `editor.jsx`); assets drag from the bin into the playlist/grid.
|
||||
- API via existing `ZAMPP_API.fetch` wrapper.
|
||||
- Program monitor: HLS preview of the output — CasparCG can emit a second low-bitrate FFmpeg consumer to HLS, reusing the `/live/<id>` HLS plumbing capture already uses.
|
||||
|
||||
---
|
||||
|
||||
## 3. Channel placement & ports
|
||||
|
||||
A DeckLink port is exclusive — same constraint capture already handles. A node's DeckLink port can be an **input (recorder)** or an **output (playout channel)**, never both at once. So:
|
||||
|
||||
- Extend the capability/port-claim check: when starting a channel on `output_type=decklink`, verify the target node has that device index free (no active recorder, no active channel).
|
||||
- NDI / SRT / RTMP outputs have no hardware contention → can stack many per node (GPU/CPU-bound only).
|
||||
- Surface a unified "device map" (extend the existing cluster DeckLink-status endpoint) showing each port's role: idle / recording / playing-out.
|
||||
|
||||
---
|
||||
|
||||
## 4. Media staging (the S3 ⇄ CasparCG gap)
|
||||
|
||||
**The crux.** Assets live in S3 (`original_s3_key` / `proxy_s3_key`). CasparCG plays from a **local media folder**. Options:
|
||||
|
||||
- **A — Pre-stage to a shared media volume (recommended).** Before a clip can go on air, download/symlink it from S3 to a CasparCG-visible volume (`/media`), set `playout_items.media_status='ready'` + `media_path`. A new BullMQ `playout-stage` job (reuses the worker pattern) does the pull. UI shows per-item readiness; TAKE is blocked until `ready`. Mirrors the growing-file SMB share already mounted for capture.
|
||||
- **B — Stream from S3 via presigned URL.** CasparCG FFmpeg producer plays an HTTPS presigned URL directly. Zero staging, but seeking/trim and gapless transitions over network are fragile for broadcast. Acceptable as a fallback for SRT/RTMP, risky for SDI.
|
||||
|
||||
**Decision:** Phase A uses **A** (stage proxies for preview, masters for air) with **B** available as a per-channel "low-latency / no-stage" toggle. Zac confirms the current masters play fine in CasparCG, so staging is a **plain S3→/media copy — no transcode-for-playout step**. (Validate on hardware during implementation, but the model does not assume a transcode stage.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Scheduling
|
||||
|
||||
### Phase A — playlist player
|
||||
No wall clock. Operator builds a `playout_playlists` row, drags items in, hits PLAY. The playout sidecar walks `playout_items` by `sort_order`, cueing the next clip during the current one (`LOADBG`) and taking it at end-of-clip with the configured transition. `loop` repeats. As-run logged per item.
|
||||
|
||||
### Phase B — 24/7 continuous channel
|
||||
Wall-clock timeline in `playout_schedule`. Reuse `src/scheduler.js`:
|
||||
- Add a second tick (or extend the existing one) under the **same PG advisory lock pattern** — exactly-one-leader, so a multi-replica deploy doesn't double-fire.
|
||||
- Tick responsibilities: stage upcoming items (look-ahead window), verify the on-air item matches the schedule, **fill gaps** (loop a filler/slate asset when the timeline has a hole — a channel must never go black), roll the day forward.
|
||||
- As-run becomes the compliance record.
|
||||
|
||||
---
|
||||
|
||||
## 6. Phasing / Milestones
|
||||
|
||||
**Phase A — Playlist playout MVP**
|
||||
1. Migration `029-playout.sql` (channels, playlists, items, as-run).
|
||||
2. `services/playout/` sidecar: CasparCG image + AMCP control shim, single output target (start with **SRT or NDI** — no hardware needed for dev; DeckLink behind hardware check).
|
||||
3. mam-api `routes/playout.js` — channel + playlist CRUD, start/stop, transport, RBAC.
|
||||
4. `playout-stage` BullMQ job (S3 → /media).
|
||||
5. web-ui `playout.html` — bin + drag-drop ordered playlist + program/preview monitors + transport.
|
||||
6. DeckLink output on real hardware; port-contention check vs recorders.
|
||||
|
||||
**Phase B — 24/7 continuous channel**
|
||||
7. `playout_schedule` + time-of-day grid UI.
|
||||
8. Scheduler tick (advisory-locked) — look-ahead staging, gap-fill/slate, day-roll.
|
||||
9. As-run reporting view.
|
||||
10. Graphics/CG overlay (logo bug, lower-thirds) via CasparCG templates.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions (for review)
|
||||
|
||||
**Resolved (2026-05-30):**
|
||||
- ~~CasparCG packaging~~ → **build our own image.** Fetch DeckLink + NDI SDKs at build time (not redistributable — same as capture's DeckLink build). GL context for the mixer comes from GPU passthrough on a real node, or **Xvfb** (virtual framebuffer) where there's no display — community images run `--privileged` + X11 socket. Pin the NDI SDK version to what the server expects (`.so` version mismatch is the common docker failure).
|
||||
- ~~Master codec playability~~ → Zac confirms masters **play fine**; no transcode-for-playout. Staging = plain S3→/media copy.
|
||||
- ~~Management GUI~~ → **single Dragonflight `playout.html`** drives everything via AMCP; operator never touches CasparCG.
|
||||
- ~~Audio loudness~~ → **pre-normalize at stage time** (Zac, 2026-05-30). `playout-stage` job runs ffmpeg `loudnorm` (EBU R128, target −23 LUFS, true-peak −1 dBTP) once, on the S3→/media copy. Output is the cached version CasparCG plays. Staging is no longer a pure copy — staging cost ≈ realtime CPU per clip on first stage; results are reusable across channels. Override (`media_status='ready'` + raw copy) available for clips already mastered to spec.
|
||||
- ~~Frame rate~~ → **`1080p5994`** default for new channels (Zac, 2026-05-30). Progressive 1080 @ 59.94 fps. Per-channel override allowed (`video_format` column). Streaming-friendly (SRT/RTMP/NDI) and current SDI gear accepts it; matches capture's 59.94 cadence.
|
||||
- ~~Preview latency~~ → **HLS v1** (Zac, 2026-05-30). Reuse capture's `/live/<id>` HLS plumbing. CasparCG emits a second low-bitrate FFmpeg consumer to HLS. ~4–6s lag, fine for confidence monitor. Operator desk gets a real downstream monitor off the SDI/NDI output anyway. Revisit WebRTC if MCR operators complain.
|
||||
- ~~Failover~~ → **auto-restart on healthy node** (Zac, 2026-05-30). Scheduler tick (§5) monitors `playout_sidecars` health (AMCP ping + container alive); on N missed checks marks the channel `error`, re-places it on another capability-matching node with a free output port, resumes the playlist from the next item after the as-run-logged on-air item. Gap = black/slate for ~5–30 s during respawn (operator sees a flag in the UI). **DeckLink channels are not auto-failed-over in v1** — device-index pinning makes the destination port non-trivial; v1 alerts and lets the operator move the channel. NDI/SRT/RTMP channels (no hardware contention) failover automatically. Tracked via `restart_count` + `last_restart_at` on `playout_channels`.
|
||||
|
||||
**Still open:**
|
||||
- (none — all §7 questions resolved 2026-05-30)
|
||||
|
||||
---
|
||||
|
||||
## 8. Reused building blocks (already in the repo)
|
||||
|
||||
| Need | Existing piece |
|
||||
|------|----------------|
|
||||
| Spawn engine container local/remote | `recorders.js` Docker-socket + `node-agent /sidecar/start` |
|
||||
| Node capability / port model | `cluster_nodes.capabilities`, cluster DeckLink-status endpoint |
|
||||
| Single-leader scheduled transitions | `src/scheduler.js` + PG advisory lock |
|
||||
| Background media jobs | BullMQ worker (`services/worker`) |
|
||||
| RBAC scoping | `src/auth/authz.js` `assertProjectAccess` (channel/project_id) |
|
||||
| HLS preview plumbing | capture's `/live/<id>` HLS output |
|
||||
| Subclip in/out points | NLE editor in/out marking |
|
||||
| API wrapper / SPA shell | `ZAMPP_API.fetch`, esbuild JSX pages |
|
||||
|
|
@ -1,8 +1,97 @@
|
|||
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
|
||||
# All-Intra HEVC NVENC is the master codec for growing-file ingest (see
|
||||
# docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the
|
||||
# nv-codec-headers (header-only, no driver / no full CUDA toolkit needed)
|
||||
# so ffmpeg's configure can light up hevc_nvenc / h264_nvenc / cuvid.
|
||||
# At runtime, /dev/nvidia* + the host driver libs (via the NVIDIA Container
|
||||
# Toolkit) supply the actual encoder.
|
||||
FROM debian:bookworm AS ffmpeg-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential nasm yasm pkg-config git ca-certificates python3 \
|
||||
libssl-dev libx264-dev libx265-dev libvpx-dev libopus-dev \
|
||||
libmp3lame-dev libsrt-openssl-dev \
|
||||
libzmq3-dev zlib1g-dev libstdc++-12-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy in BMD DeckLink SDK headers and patch script
|
||||
COPY sdk/ /decklink-sdk/
|
||||
COPY patch_decklink.py /patch_decklink.py
|
||||
COPY decklink-sdk16.patch /decklink-sdk16.patch
|
||||
|
||||
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
|
||||
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
|
||||
RUN git clone --depth=1 --branch n12.1.14.0 https://github.com/FFmpeg/nv-codec-headers.git /nv-codec-headers \
|
||||
&& make -C /nv-codec-headers PREFIX=/usr/local install
|
||||
|
||||
# Pull FFmpeg 7.1 source
|
||||
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
||||
|
||||
# Patch FFmpeg DeckLink code for SDK 16.x API changes
|
||||
RUN python3 /patch_decklink.py
|
||||
|
||||
WORKDIR /ffmpeg
|
||||
# NVENC adds: --enable-nvenc (encoder), --enable-cuvid (decoder), --enable-ffnvcodec.
|
||||
# We deliberately do NOT enable --enable-cuda-nvcc / --enable-libnpp here — those
|
||||
# require the full ~3GB CUDA toolkit and are only needed for GPU filters like
|
||||
# yadif_cuda / scale_cuda. If §5's GPU deinterlace stretch goal goes ahead,
|
||||
# rebuild this image off nvidia/cuda:12.x-devel and flip those flags on.
|
||||
RUN ./configure \
|
||||
--prefix=/usr/local \
|
||||
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
|
||||
--extra-ldflags="-L/usr/local/lib" \
|
||||
--enable-gpl \
|
||||
--enable-nonfree \
|
||||
--enable-libx264 \
|
||||
--enable-libx265 \
|
||||
--enable-libvpx \
|
||||
--enable-libopus \
|
||||
--enable-libmp3lame \
|
||||
--enable-libsrt \
|
||||
--enable-libzmq \
|
||||
--enable-decklink \
|
||||
--enable-ffnvcodec \
|
||||
--enable-nvenc \
|
||||
--enable-cuvid \
|
||||
--disable-doc \
|
||||
--disable-debug \
|
||||
--disable-ffplay \
|
||||
&& make -j$(nproc) \
|
||||
&& make install
|
||||
|
||||
# Sanity-check: hevc_nvenc and h264_nvenc must be present in the encoder list,
|
||||
# otherwise the resulting image is useless for the All-Intra HEVC pipeline.
|
||||
RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|
||||
|| (echo 'FATAL: nvenc encoders missing from ffmpeg build' && exit 1)
|
||||
|
||||
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||
FROM node:20-bookworm
|
||||
RUN apt-get update && apt-get install -y ffmpeg && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Runtime deps for compiled ffmpeg libs
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libx264-164 libx265-199 libvpx7 libopus0 libmp3lame0 \
|
||||
libsrt1.5-openssl libzmq5 libstdc++6 libc++1 libc++abi1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy compiled ffmpeg/ffprobe
|
||||
COPY --from=ffmpeg-builder /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||
COPY --from=ffmpeg-builder /usr/local/lib/ /usr/local/lib/
|
||||
|
||||
# DeckLink runtime .so
|
||||
COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
|
||||
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
||||
RUN ldconfig
|
||||
|
||||
# Mount points the recorder lifecycle expects to exist.
|
||||
# /live — HLS preview output (bound from host LIVE_DIR by node-agent)
|
||||
# /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing)
|
||||
RUN mkdir -p /live /growing
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "src/index.js"]
|
||||
|
|
|
|||
30
services/capture/build-with-decklink.sh
Executable file
30
services/capture/build-with-decklink.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "=== Checking prerequisites ==="
|
||||
|
||||
if [ ! -f sdk/DeckLinkAPI.h ]; then
|
||||
echo "ERROR: sdk/DeckLinkAPI.h not found."
|
||||
echo ""
|
||||
echo "Please download the Blackmagic DeckLink SDK 16.x from:"
|
||||
echo " https://www.blackmagicdesign.com/developer/product/capture"
|
||||
echo ""
|
||||
echo "Then extract the Linux/include/ folder contents into:"
|
||||
echo " $(pwd)/sdk/"
|
||||
echo ""
|
||||
echo "Required files: DeckLinkAPI.h DeckLinkAPIVersion.h DeckLinkAPIDispatch.cpp"
|
||||
echo " LinuxCOM.h DeckLinkAPIModes.h DeckLinkAPITypes.h"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "SDK headers found:"
|
||||
ls sdk/*.h sdk/*.cpp 2>/dev/null
|
||||
|
||||
echo ""
|
||||
echo "=== Building capture container with DeckLink FFmpeg ==="
|
||||
docker compose -f ../../docker-compose.yml build capture
|
||||
|
||||
echo ""
|
||||
echo "=== Verifying DeckLink support in built image ==="
|
||||
docker run --rm wild-dragon-capture ffmpeg -f decklink -list_devices true -i dummy 2>&1 | head -20
|
||||
346
services/capture/decklink-sdk16.patch
Normal file
346
services/capture/decklink-sdk16.patch
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
diff --git a/libavdevice/decklink_common.cpp b/libavdevice/decklink_common.cpp
|
||||
index fe187cd..47de7ef 100644
|
||||
--- a/libavdevice/decklink_common.cpp
|
||||
+++ b/libavdevice/decklink_common.cpp
|
||||
@@ -25,12 +25,7 @@ extern "C" {
|
||||
#include "libavformat/internal.h"
|
||||
}
|
||||
|
||||
-#include <DeckLinkAPIVersion.h>
|
||||
#include <DeckLinkAPI.h>
|
||||
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
|
||||
-#include <DeckLinkAPI_v14_2_1.h>
|
||||
-#endif
|
||||
-
|
||||
#ifdef _WIN32
|
||||
#include <DeckLinkAPI_i.c>
|
||||
#else
|
||||
@@ -517,8 +512,8 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
|
||||
return AVERROR(EIO);
|
||||
|
||||
while (ret == 0 && iter->Next(&dl) == S_OK) {
|
||||
- IDeckLinkOutput_v14_2_1 *output_config;
|
||||
- IDeckLinkInput_v14_2_1 *input_config;
|
||||
+ IDeckLinkOutput *output_config;
|
||||
+ IDeckLinkInput *input_config;
|
||||
const char *display_name = NULL;
|
||||
const char *unique_name = NULL;
|
||||
AVDeviceInfo *new_device = NULL;
|
||||
@@ -532,14 +527,14 @@ int ff_decklink_list_devices(AVFormatContext *avctx,
|
||||
goto next;
|
||||
|
||||
if (show_outputs) {
|
||||
- if (dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **)&output_config) == S_OK) {
|
||||
+ if (dl->QueryInterface(IID_IDeckLinkOutput, (void **)&output_config) == S_OK) {
|
||||
output_config->Release();
|
||||
add = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (show_inputs) {
|
||||
- if (dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **)&input_config) == S_OK) {
|
||||
+ if (dl->QueryInterface(IID_IDeckLinkInput, (void **)&input_config) == S_OK) {
|
||||
input_config->Release();
|
||||
add = 1;
|
||||
}
|
||||
diff --git a/libavdevice/decklink_common.h b/libavdevice/decklink_common.h
|
||||
index 095b438..6b32dc2 100644
|
||||
--- a/libavdevice/decklink_common.h
|
||||
+++ b/libavdevice/decklink_common.h
|
||||
@@ -29,23 +29,6 @@
|
||||
#define IDeckLinkProfileAttributes IDeckLinkAttributes
|
||||
#endif
|
||||
|
||||
-#if BLACKMAGIC_DECKLINK_API_VERSION < 0x0e030000
|
||||
-#define IDeckLinkInput_v14_2_1 IDeckLinkInput
|
||||
-#define IDeckLinkInputCallback_v14_2_1 IDeckLinkInputCallback
|
||||
-#define IDeckLinkMemoryAllocator_v14_2_1 IDeckLinkMemoryAllocator
|
||||
-#define IDeckLinkOutput_v14_2_1 IDeckLinkOutput
|
||||
-#define IDeckLinkVideoFrame_v14_2_1 IDeckLinkVideoFrame
|
||||
-#define IDeckLinkVideoInputFrame_v14_2_1 IDeckLinkVideoInputFrame
|
||||
-#define IDeckLinkVideoOutputCallback_v14_2_1 IDeckLinkVideoOutputCallback
|
||||
-#define IID_IDeckLinkInput_v14_2_1 IID_IDeckLinkInput
|
||||
-#define IID_IDeckLinkInputCallback_v14_2_1 IID_IDeckLinkInputCallback
|
||||
-#define IID_IDeckLinkMemoryAllocator_v14_2_1 IID_IDeckLinkMemoryAllocator
|
||||
-#define IID_IDeckLinkOutput_v14_2_1 IID_IDeckLinkOutput
|
||||
-#define IID_IDeckLinkVideoFrame_v14_2_1 IID_IDeckLinkVideoFrame
|
||||
-#define IID_IDeckLinkVideoInputFrame_v14_2_1 IID_IDeckLinkVideoInputFrame
|
||||
-#define IID_IDeckLinkVideoOutputCallback_v14_2_1 IID_IDeckLinkVideoOutputCallback
|
||||
-#endif
|
||||
-
|
||||
extern "C" {
|
||||
#include "libavutil/mem.h"
|
||||
#include "libavcodec/packet_internal.h"
|
||||
@@ -93,16 +76,6 @@ static char *dup_cfstring_to_utf8(CFStringRef w)
|
||||
#define DECKLINK_FREE(s) free((void *) s)
|
||||
#endif
|
||||
|
||||
-#ifdef _WIN32
|
||||
-#include <guiddef.h> // REFIID, IsEqualIID()
|
||||
-#define DECKLINK_IsEqualIID IsEqualIID
|
||||
-#else
|
||||
-static inline bool DECKLINK_IsEqualIID(const REFIID& riid1, const REFIID& riid2)
|
||||
-{
|
||||
- return memcmp(&riid1, &riid2, sizeof(REFIID)) == 0;
|
||||
-}
|
||||
-#endif
|
||||
-
|
||||
class decklink_output_callback;
|
||||
class decklink_input_callback;
|
||||
|
||||
@@ -120,8 +93,8 @@ typedef struct DecklinkPacketQueue {
|
||||
struct decklink_ctx {
|
||||
/* DeckLink SDK interfaces */
|
||||
IDeckLink *dl;
|
||||
- IDeckLinkOutput_v14_2_1 *dlo;
|
||||
- IDeckLinkInput_v14_2_1 *dli;
|
||||
+ IDeckLinkOutput *dlo;
|
||||
+ IDeckLinkInput *dli;
|
||||
IDeckLinkConfiguration *cfg;
|
||||
IDeckLinkProfileAttributes *attr;
|
||||
decklink_output_callback *output_callback;
|
||||
diff --git a/libavdevice/decklink_dec.cpp b/libavdevice/decklink_dec.cpp
|
||||
index 8830779..418701e 100644
|
||||
--- a/libavdevice/decklink_dec.cpp
|
||||
+++ b/libavdevice/decklink_dec.cpp
|
||||
@@ -31,11 +31,7 @@ extern "C" {
|
||||
#include "libavformat/internal.h"
|
||||
}
|
||||
|
||||
-#include <DeckLinkAPIVersion.h>
|
||||
#include <DeckLinkAPI.h>
|
||||
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
|
||||
-#include <DeckLinkAPI_v14_2_1.h>
|
||||
-#endif
|
||||
|
||||
extern "C" {
|
||||
#include "config.h"
|
||||
@@ -109,7 +105,7 @@ static VANCLineNumber vanc_line_numbers[] = {
|
||||
{bmdModeUnknown, 0, -1, -1, -1}
|
||||
};
|
||||
|
||||
-class decklink_allocator : public IDeckLinkMemoryAllocator_v14_2_1
|
||||
+class decklink_allocator : public IDeckLinkMemoryAllocator
|
||||
{
|
||||
public:
|
||||
decklink_allocator(): _refs(1) { }
|
||||
@@ -133,21 +129,7 @@ public:
|
||||
virtual HRESULT STDMETHODCALLTYPE Decommit() { return S_OK; }
|
||||
|
||||
// IUnknown methods
|
||||
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||
- {
|
||||
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||
- *ppv = static_cast<IUnknown*>(this);
|
||||
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkMemoryAllocator_v14_2_1)) {
|
||||
- *ppv = static_cast<IDeckLinkMemoryAllocator_v14_2_1*>(this);
|
||||
- } else {
|
||||
- *ppv = NULL;
|
||||
- return E_NOINTERFACE;
|
||||
- }
|
||||
-
|
||||
- AddRef();
|
||||
- return S_OK;
|
||||
- }
|
||||
-
|
||||
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
|
||||
virtual ULONG STDMETHODCALLTYPE Release(void)
|
||||
{
|
||||
@@ -490,7 +472,7 @@ skip_packet:
|
||||
}
|
||||
|
||||
|
||||
-static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, int64_t pts)
|
||||
+static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideoInputFrame *videoFrame, int64_t pts)
|
||||
{
|
||||
const uint8_t KLV_DID = 0x44;
|
||||
const uint8_t KLV_IN_VANC_SDID = 0x04;
|
||||
@@ -592,30 +574,17 @@ static void handle_klv(AVFormatContext *avctx, decklink_ctx *ctx, IDeckLinkVideo
|
||||
}
|
||||
}
|
||||
|
||||
-class decklink_input_callback : public IDeckLinkInputCallback_v14_2_1
|
||||
+class decklink_input_callback : public IDeckLinkInputCallback
|
||||
{
|
||||
public:
|
||||
explicit decklink_input_callback(AVFormatContext *_avctx);
|
||||
~decklink_input_callback();
|
||||
|
||||
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||
- {
|
||||
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||
- *ppv = static_cast<IUnknown*>(this);
|
||||
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkInputCallback_v14_2_1)) {
|
||||
- *ppv = static_cast<IDeckLinkInputCallback_v14_2_1*>(this);
|
||||
- } else {
|
||||
- *ppv = NULL;
|
||||
- return E_NOINTERFACE;
|
||||
- }
|
||||
-
|
||||
- AddRef();
|
||||
- return S_OK;
|
||||
- }
|
||||
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||
virtual ULONG STDMETHODCALLTYPE AddRef(void);
|
||||
virtual ULONG STDMETHODCALLTYPE Release(void);
|
||||
virtual HRESULT STDMETHODCALLTYPE VideoInputFormatChanged(BMDVideoInputFormatChangedEvents, IDeckLinkDisplayMode*, BMDDetectedVideoInputFormatFlags);
|
||||
- virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame_v14_2_1*, IDeckLinkAudioInputPacket*);
|
||||
+ virtual HRESULT STDMETHODCALLTYPE VideoInputFrameArrived(IDeckLinkVideoInputFrame*, IDeckLinkAudioInputPacket*);
|
||||
|
||||
private:
|
||||
std::atomic<int> _refs;
|
||||
@@ -624,7 +593,7 @@ private:
|
||||
int no_video;
|
||||
int64_t initial_video_pts;
|
||||
int64_t initial_audio_pts;
|
||||
- IDeckLinkVideoInputFrame_v14_2_1* last_video_frame;
|
||||
+ IDeckLinkVideoInputFrame* last_video_frame;
|
||||
};
|
||||
|
||||
decklink_input_callback::decklink_input_callback(AVFormatContext *_avctx) : _refs(1)
|
||||
@@ -656,7 +625,7 @@ ULONG decklink_input_callback::Release(void)
|
||||
return ret;
|
||||
}
|
||||
|
||||
-static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
|
||||
+static int64_t get_pkt_pts(IDeckLinkVideoInputFrame *videoFrame,
|
||||
IDeckLinkAudioInputPacket *audioFrame,
|
||||
int64_t wallclock,
|
||||
int64_t abs_wallclock,
|
||||
@@ -710,7 +679,7 @@ static int64_t get_pkt_pts(IDeckLinkVideoInputFrame_v14_2_1 *videoFrame,
|
||||
return pts;
|
||||
}
|
||||
|
||||
-static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
|
||||
+static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational frame_rate, BMDTimecodeFormat tc_format, IDeckLinkVideoInputFrame *videoFrame)
|
||||
{
|
||||
IDeckLinkTimecode *timecode;
|
||||
int ret = AVERROR(ENOENT);
|
||||
@@ -732,7 +701,7 @@ static int get_bmd_timecode(AVFormatContext *avctx, AVTimecode *tc, AVRational f
|
||||
return ret;
|
||||
}
|
||||
|
||||
-static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame_v14_2_1 *videoFrame)
|
||||
+static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimecode *tc, IDeckLinkVideoInputFrame *videoFrame)
|
||||
{
|
||||
AVRational frame_rate = ctx->video_st->r_frame_rate;
|
||||
int ret;
|
||||
@@ -757,7 +726,7 @@ static int get_frame_timecode(AVFormatContext *avctx, decklink_ctx *ctx, AVTimec
|
||||
}
|
||||
|
||||
HRESULT decklink_input_callback::VideoInputFrameArrived(
|
||||
- IDeckLinkVideoInputFrame_v14_2_1 *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
|
||||
+ IDeckLinkVideoInputFrame *videoFrame, IDeckLinkAudioInputPacket *audioFrame)
|
||||
{
|
||||
void *frameBytes;
|
||||
void *audioFrameBytes;
|
||||
@@ -1172,7 +1141,7 @@ av_cold int ff_decklink_read_header(AVFormatContext *avctx)
|
||||
goto error;
|
||||
|
||||
/* Get input device. */
|
||||
- if (ctx->dl->QueryInterface(IID_IDeckLinkInput_v14_2_1, (void **) &ctx->dli) != S_OK) {
|
||||
+ if (ctx->dl->QueryInterface(IID_IDeckLinkInput, (void **) &ctx->dli) != S_OK) {
|
||||
av_log(avctx, AV_LOG_ERROR, "Could not open input device from '%s'\n",
|
||||
avctx->url);
|
||||
ret = AVERROR(EIO);
|
||||
diff --git a/libavdevice/decklink_enc.cpp b/libavdevice/decklink_enc.cpp
|
||||
index d2e246c..cb8f917 100644
|
||||
--- a/libavdevice/decklink_enc.cpp
|
||||
+++ b/libavdevice/decklink_enc.cpp
|
||||
@@ -28,11 +28,7 @@ extern "C" {
|
||||
#include "libavformat/internal.h"
|
||||
}
|
||||
|
||||
-#include <DeckLinkAPIVersion.h>
|
||||
#include <DeckLinkAPI.h>
|
||||
-#if BLACKMAGIC_DECKLINK_API_VERSION >= 0x0e030000
|
||||
-#include <DeckLinkAPI_v14_2_1.h>
|
||||
-#endif
|
||||
|
||||
extern "C" {
|
||||
#include "libavformat/avformat.h"
|
||||
@@ -52,7 +48,7 @@ extern "C" {
|
||||
#endif
|
||||
|
||||
/* DeckLink callback class declaration */
|
||||
-class decklink_frame : public IDeckLinkVideoFrame_v14_2_1
|
||||
+class decklink_frame : public IDeckLinkVideoFrame
|
||||
{
|
||||
public:
|
||||
decklink_frame(struct decklink_ctx *ctx, AVFrame *avframe, AVCodecID codec_id, int height, int width) :
|
||||
@@ -115,20 +111,7 @@ public:
|
||||
_ancillary->AddRef();
|
||||
return S_OK;
|
||||
}
|
||||
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||
- {
|
||||
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||
- *ppv = static_cast<IUnknown*>(this);
|
||||
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoFrame_v14_2_1)) {
|
||||
- *ppv = static_cast<IDeckLinkVideoFrame_v14_2_1*>(this);
|
||||
- } else {
|
||||
- *ppv = NULL;
|
||||
- return E_NOINTERFACE;
|
||||
- }
|
||||
-
|
||||
- AddRef();
|
||||
- return S_OK;
|
||||
- }
|
||||
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return ++_refs; }
|
||||
virtual ULONG STDMETHODCALLTYPE Release(void)
|
||||
{
|
||||
@@ -155,10 +138,10 @@ private:
|
||||
std::atomic<int> _refs;
|
||||
};
|
||||
|
||||
-class decklink_output_callback : public IDeckLinkVideoOutputCallback_v14_2_1
|
||||
+class decklink_output_callback : public IDeckLinkVideoOutputCallback
|
||||
{
|
||||
public:
|
||||
- virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame_v14_2_1 *_frame, BMDOutputFrameCompletionResult result)
|
||||
+ virtual HRESULT STDMETHODCALLTYPE ScheduledFrameCompleted(IDeckLinkVideoFrame *_frame, BMDOutputFrameCompletionResult result)
|
||||
{
|
||||
decklink_frame *frame = static_cast<decklink_frame *>(_frame);
|
||||
struct decklink_ctx *ctx = frame->_ctx;
|
||||
@@ -176,20 +159,7 @@ public:
|
||||
return S_OK;
|
||||
}
|
||||
virtual HRESULT STDMETHODCALLTYPE ScheduledPlaybackHasStopped(void) { return S_OK; }
|
||||
- virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, LPVOID *ppv)
|
||||
- {
|
||||
- if (DECKLINK_IsEqualIID(riid, IID_IUnknown)) {
|
||||
- *ppv = static_cast<IUnknown*>(this);
|
||||
- } else if (DECKLINK_IsEqualIID(riid, IID_IDeckLinkVideoOutputCallback_v14_2_1)) {
|
||||
- *ppv = static_cast<IDeckLinkVideoOutputCallback_v14_2_1*>(this);
|
||||
- } else {
|
||||
- *ppv = NULL;
|
||||
- return E_NOINTERFACE;
|
||||
- }
|
||||
-
|
||||
- AddRef();
|
||||
- return S_OK;
|
||||
- }
|
||||
+ virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID iid, LPVOID *ppv) { return E_NOINTERFACE; }
|
||||
virtual ULONG STDMETHODCALLTYPE AddRef(void) { return 1; }
|
||||
virtual ULONG STDMETHODCALLTYPE Release(void) { return 1; }
|
||||
};
|
||||
@@ -769,7 +739,7 @@ static int decklink_write_video_packet(AVFormatContext *avctx, AVPacket *pkt)
|
||||
ctx->first_pts = pkt->pts;
|
||||
|
||||
/* Schedule frame for playback. */
|
||||
- hr = ctx->dlo->ScheduleVideoFrame(frame,
|
||||
+ hr = ctx->dlo->ScheduleVideoFrame((class IDeckLinkVideoFrame *) frame,
|
||||
pkt->pts * ctx->bmd_tb_num,
|
||||
ctx->bmd_tb_num, ctx->bmd_tb_den);
|
||||
/* Pass ownership to DeckLink, or release on failure */
|
||||
@@ -904,7 +874,7 @@ av_cold int ff_decklink_write_header(AVFormatContext *avctx)
|
||||
return ret;
|
||||
|
||||
/* Get output device. */
|
||||
- if (ctx->dl->QueryInterface(IID_IDeckLinkOutput_v14_2_1, (void **) &ctx->dlo) != S_OK) {
|
||||
+ if (ctx->dl->QueryInterface(IID_IDeckLinkOutput, (void **) &ctx->dlo) != S_OK) {
|
||||
av_log(avctx, AV_LOG_ERROR, "Could not open output device from '%s'\n",
|
||||
avctx->url);
|
||||
ret = AVERROR(EIO);
|
||||
21
services/capture/patch_decklink.py
Normal file
21
services/capture/patch_decklink.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env python3
|
||||
# Apply the upstream FFmpeg master decklink SDK-16 compatibility patch on top
|
||||
# of the release/7.1 source. The patch renames every IDeckLink* interface and
|
||||
# helper to its _v14_2_1 versioned form so the call sites keep working against
|
||||
# SDK 16's headers (which only retain the versioned aliases). Cherry-picking
|
||||
# individual replacements like the previous regex patch produced inconsistent
|
||||
# code that compiled but silently dropped every video frame.
|
||||
import subprocess, sys, pathlib
|
||||
patch = pathlib.Path('/decklink-sdk16.patch')
|
||||
if not patch.exists():
|
||||
print('FATAL: /decklink-sdk16.patch not found in build context', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
# Patch was produced as `git diff HEAD FETCH_HEAD` where HEAD=release/7.1 and
|
||||
# FETCH_HEAD=master, so we apply it in REVERSE to move 7.1 → master.
|
||||
result = subprocess.run(
|
||||
['git', 'apply', '-R', '--verbose', str(patch)],
|
||||
cwd='/ffmpeg', capture_output=True, text=True,
|
||||
)
|
||||
print(result.stdout)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
|
|
@ -1,9 +1,112 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createUploadStream } from './s3/client.js';
|
||||
|
||||
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
||||
|
||||
// Growing-files mode: writes the master to a local SMB-backed share that the
|
||||
// editor can mount, instead of streaming to S3 in real time. The promotion
|
||||
// worker uploads the finalized file to S3 after the recording stops.
|
||||
// Toggled per-process by `GROWING_ENABLED=true` on the capture container
|
||||
// (see routes/recorders.js where the env is composed).
|
||||
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
|
||||
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
|
||||
|
||||
// ── Codec catalogue ──────────────────────────────────────────────────────
|
||||
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
|
||||
// / pix_fmt are layered on top from the per-recorder configuration.
|
||||
const VIDEO_CODECS = {
|
||||
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
||||
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
|
||||
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
||||
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
||||
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||
// All-Intra HEVC on NVENC — the growing-file master codec.
|
||||
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
|
||||
// to its last complete frame — the prerequisite for edit-while-record.
|
||||
//
|
||||
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
|
||||
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
|
||||
// is rejected with EINVAL (validated on the L4, driver 595). The working
|
||||
// recipe for true all-intra is therefore:
|
||||
// -bf 0 no B-frames
|
||||
// -g 600 large GOP just to satisfy the init check
|
||||
// -forced-idr 1 forced keyframes are emitted as IDR
|
||||
// -force_key_frames expr:1 force a keyframe on EVERY frame
|
||||
// → ffprobe confirms pict_type = I for all frames.
|
||||
//
|
||||
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
|
||||
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
|
||||
// The frag-MOV index is not deferred to EOF, so the file stays readable while
|
||||
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
|
||||
//
|
||||
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
|
||||
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
|
||||
hevc_nvenc: {
|
||||
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
|
||||
bitrateControl: true,
|
||||
pixFmt: 'p010le',
|
||||
},
|
||||
};
|
||||
|
||||
const AUDIO_CODECS = {
|
||||
pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false },
|
||||
pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false },
|
||||
pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false },
|
||||
aac: { args: ['-c:a', 'aac'], bitrateControl: true },
|
||||
ac3: { args: ['-c:a', 'ac3'], bitrateControl: true },
|
||||
opus: { args: ['-c:a', 'libopus'], bitrateControl: true },
|
||||
flac: { args: ['-c:a', 'flac'], bitrateControl: false },
|
||||
};
|
||||
|
||||
const CONTAINER_FMT = {
|
||||
mov: 'mov',
|
||||
mp4: 'mp4',
|
||||
mkv: 'matroska',
|
||||
mxf: 'mxf',
|
||||
ts: 'mpegts',
|
||||
};
|
||||
|
||||
const CONTAINER_EXT = {
|
||||
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
|
||||
};
|
||||
|
||||
function buildEncodeArgs({
|
||||
codec, videoBitrate, framerate,
|
||||
audioCodec, audioBitrate, audioChannels,
|
||||
container, isNetwork, isProxy = false,
|
||||
}) {
|
||||
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
|
||||
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
|
||||
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
|
||||
|
||||
const args = [];
|
||||
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
|
||||
|
||||
args.push(...v.args);
|
||||
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
|
||||
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
|
||||
if (framerate && framerate !== 'native') args.push('-r', framerate);
|
||||
|
||||
args.push(...a.args);
|
||||
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
|
||||
if (audioChannels) args.push('-ac', String(audioChannels));
|
||||
|
||||
if (fmt === 'mov' || fmt === 'mp4') {
|
||||
args.push('-movflags', '+frag_keyframe+empty_moov');
|
||||
}
|
||||
args.push('-f', fmt);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
class CaptureManager {
|
||||
constructor() {
|
||||
this.state = {
|
||||
|
|
@ -11,7 +114,6 @@ class CaptureManager {
|
|||
sessionId: null,
|
||||
processes: {},
|
||||
currentSession: {},
|
||||
// Live signal metrics derived from ffmpeg stderr
|
||||
framesReceived: 0,
|
||||
currentFps: 0,
|
||||
lastFrameAt: null,
|
||||
|
|
@ -24,14 +126,13 @@ class CaptureManager {
|
|||
* Returns { inputArgs, isNetwork }
|
||||
* @private
|
||||
*/
|
||||
_buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
||||
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
||||
if (sourceType === 'srt') {
|
||||
let url;
|
||||
if (listen) {
|
||||
const port = listenPort || 9000;
|
||||
url = `srt://0.0.0.0:${port}?mode=listener`;
|
||||
} else {
|
||||
// Caller mode — ensure mode=caller is appended if not already present
|
||||
url = sourceUrl;
|
||||
if (!url.includes('mode=')) {
|
||||
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
||||
|
|
@ -52,24 +153,94 @@ class CaptureManager {
|
|||
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
|
||||
}
|
||||
|
||||
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
|
||||
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
|
||||
// When the physical device is absent (/dev/deltacast<N> missing), fall back
|
||||
// to a lavfi test card so development and integration testing work without hardware.
|
||||
if (sourceType === 'deltacast') {
|
||||
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
|
||||
? parseInt(device, 10)
|
||||
: 0;
|
||||
const { existsSync } = await import('node:fs');
|
||||
const deviceNode = `/dev/deltacast${idx}`;
|
||||
if (existsSync(deviceNode)) {
|
||||
console.log(`[capture] Deltacast index ${idx} → ${deviceNode} (hardware)`);
|
||||
return {
|
||||
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
|
||||
isNetwork: false,
|
||||
};
|
||||
} else {
|
||||
// No hardware — lavfi test card with port label + timecode burn-in.
|
||||
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
|
||||
// recorded files look right in the MAM library during dev.
|
||||
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
|
||||
const testSrc = [
|
||||
`testsrc2=size=1920x1080:rate=30`,
|
||||
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
|
||||
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
|
||||
].join(',');
|
||||
return {
|
||||
inputArgs: [
|
||||
'-f', 'lavfi', '-i', testSrc,
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
|
||||
'-map', '0:v:0', '-map', '1:a:0',
|
||||
],
|
||||
isNetwork: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default: SDI via DeckLink
|
||||
// device may be an integer index (0-based) or a full device name string.
|
||||
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
|
||||
// Map integer index -> name using ffmpeg -sources decklink at runtime.
|
||||
//
|
||||
// ffmpeg -sources decklink output format:
|
||||
// Auto-detected sources for decklink:
|
||||
// DeckLink Duo 2
|
||||
// DeckLink Duo 2 (2)
|
||||
// Lines containing device names start with whitespace; the header line
|
||||
// starts with a non-space character. Previous code used a v4l2-style
|
||||
// hex-address regex that never matched DeckLink output → index 1+ always
|
||||
// fell through to a wrong fallback, producing black output from port 2+.
|
||||
let deckLinkName = String(device);
|
||||
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
||||
const idx = parseInt(device, 10);
|
||||
try {
|
||||
const { execSync } = await import('child_process');
|
||||
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||
const names = [];
|
||||
for (const line of out.split('\n')) {
|
||||
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
|
||||
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||
if (m) names.push(m[1]);
|
||||
}
|
||||
if (names[idx]) {
|
||||
deckLinkName = names[idx];
|
||||
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
|
||||
} else {
|
||||
// Fallback: cannot determine model name without enumeration.
|
||||
// Log a warning — operator should check the detected device list.
|
||||
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
|
||||
deckLinkName = `DeckLink (${idx})`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
|
||||
// Pass the numeric index directly; some ffmpeg builds accept it.
|
||||
deckLinkName = String(device);
|
||||
}
|
||||
}
|
||||
return {
|
||||
inputArgs: ['-f', 'decklink', '-i', String(device)],
|
||||
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
|
||||
isNetwork: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new capture session
|
||||
* @param {Object} params
|
||||
* - projectId, binId, clipName — always required
|
||||
* - device — DeckLink device index (SDI only)
|
||||
* - sourceType — 'sdi' | 'srt' | 'rtmp' (default: 'sdi')
|
||||
* - sourceUrl — URL for caller mode (SRT/RTMP caller)
|
||||
* - listen — true for listener/server mode
|
||||
* - listenPort — port to bind in listener mode
|
||||
* - streamKey — RTMP stream key for listener mode
|
||||
* @returns {Object} Session info
|
||||
* Start a new capture session.
|
||||
*
|
||||
* Codec parameters all have sensible defaults so legacy callers (no codec
|
||||
* args) still produce ProRes HQ master + H.264 proxy.
|
||||
*/
|
||||
async start({
|
||||
assetId,
|
||||
|
|
@ -82,6 +253,23 @@ class CaptureManager {
|
|||
listen = false,
|
||||
listenPort,
|
||||
streamKey,
|
||||
// ── Recording codec ─────────────────────────────────────────────
|
||||
videoCodec = 'prores_hq',
|
||||
videoBitrate = null,
|
||||
framerate = null,
|
||||
audioCodec = 'pcm_s24le',
|
||||
audioBitrate = null,
|
||||
audioChannels = 2,
|
||||
container = 'mov',
|
||||
// ── Proxy codec ─────────────────────────────────────────────────
|
||||
proxyEnabled = true,
|
||||
proxyVideoCodec = 'h264',
|
||||
proxyVideoBitrate = '8M',
|
||||
proxyFramerate = null,
|
||||
proxyAudioCodec = 'aac',
|
||||
proxyAudioBitrate = '192k',
|
||||
proxyAudioChannels = 2,
|
||||
proxyContainer = 'mp4',
|
||||
}) {
|
||||
this._assetIdForHls = assetId || null;
|
||||
if (this.state.recording) {
|
||||
|
|
@ -89,57 +277,96 @@ class CaptureManager {
|
|||
}
|
||||
|
||||
const sessionId = uuidv4();
|
||||
const hiresKey = `projects/${projectId}/masters/${clipName}.mov`;
|
||||
const hiresExt = CONTAINER_EXT[container] || 'mov';
|
||||
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
|
||||
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
|
||||
|
||||
// Network sources cannot be opened by two FFmpeg processes simultaneously.
|
||||
// proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy
|
||||
// after the recording stops (same pipeline used for uploaded files).
|
||||
const proxyKey = sourceType === 'sdi'
|
||||
? `projects/${projectId}/proxies/${clipName}.mp4`
|
||||
// Growing-files: write master to the local SMB share instead of streaming
|
||||
// to S3. Path is relative to the container's GROWING_PATH mount.
|
||||
const growingPath = GROWING_ENABLED
|
||||
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
|
||||
: null;
|
||||
if (growingPath) {
|
||||
try { mkdirSync(dirname(growingPath), { recursive: true }); }
|
||||
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
|
||||
}
|
||||
|
||||
// DeckLink hardware does NOT support concurrent capture from the same port.
|
||||
// Opening a second ffmpeg process on the same DeckLink input while the first
|
||||
// is already capturing causes "Cannot Autodetect input stream or No signal"
|
||||
// on the second process — making the proxy empty and potentially crashing the
|
||||
// container before the hires upload completes.
|
||||
//
|
||||
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
|
||||
// worker generate the proxy from the hires master after the recording stops.
|
||||
// The stop handler sets needsProxy=true so the worker picks it up.
|
||||
const proxyKey = null;
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
|
||||
const { inputArgs, isNetwork } = this._buildInputArgs({
|
||||
sourceType,
|
||||
device,
|
||||
sourceUrl,
|
||||
listen,
|
||||
listenPort,
|
||||
streamKey,
|
||||
const { inputArgs, isNetwork } = await this._buildInputArgs({
|
||||
sourceType, device, sourceUrl, listen, listenPort, streamKey,
|
||||
});
|
||||
|
||||
// ProRes hires — fragmented moov for pipe-safe output on network sources
|
||||
const hiresCodecArgs = isNetwork
|
||||
? [
|
||||
'-map', '0:v:0?',
|
||||
'-map', '0:a:0?',
|
||||
'-c:v', 'prores_ks',
|
||||
'-profile:v', '3',
|
||||
'-c:a', 'pcm_s24le',
|
||||
'-movflags', '+frag_keyframe+empty_moov',
|
||||
'-f', 'mov',
|
||||
]
|
||||
: [
|
||||
'-c:v', 'prores_ks',
|
||||
'-profile:v', '3',
|
||||
'-c:a', 'pcm_s24le',
|
||||
'-f', 'mov',
|
||||
];
|
||||
|
||||
// Spawn hires FFmpeg process
|
||||
const hiresProcess = spawn('ffmpeg', [
|
||||
...inputArgs,
|
||||
...hiresCodecArgs,
|
||||
'pipe:1',
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
const hiresCodecArgs = buildEncodeArgs({
|
||||
codec: videoCodec, videoBitrate, framerate,
|
||||
audioCodec, audioBitrate, audioChannels,
|
||||
container,
|
||||
isNetwork,
|
||||
isProxy: false,
|
||||
});
|
||||
|
||||
const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
|
||||
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
|
||||
|
||||
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
|
||||
|
||||
// When growing-files is on, write directly to the SMB share so Premier
|
||||
// can mount and edit the live file. Promotion worker uploads to S3 on EOF.
|
||||
// Otherwise, stream the master to S3 via stdout pipe (legacy behavior).
|
||||
const hiresOutput = growingPath ? growingPath : 'pipe:1';
|
||||
const hiresStdio = growingPath ? ['ignore', 'ignore', 'pipe'] : ['ignore', 'pipe', 'pipe'];
|
||||
|
||||
// For SDI we cannot open the DeckLink device a second time for a preview
|
||||
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
|
||||
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
|
||||
let sdiHlsDir = null;
|
||||
let hiresArgs;
|
||||
if (sourceType === 'sdi' && this._assetIdForHls) {
|
||||
const fsMod = await import('node:fs');
|
||||
sdiHlsDir = '/live/' + this._assetIdForHls;
|
||||
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
|
||||
hiresArgs = [
|
||||
...inputArgs,
|
||||
'-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
|
||||
// Output 0 — ProRes master (S3 pipe or growing file)
|
||||
'-map', '[vhi]', '-map', '0:a:0?',
|
||||
...hiresCodecArgs,
|
||||
hiresOutput,
|
||||
// Output 1 — low-latency H.264 HLS preview for the UI monitor
|
||||
'-map', '[vlo]', '-map', '0:a:0?',
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
|
||||
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
||||
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
||||
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
||||
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
|
||||
sdiHlsDir + '/index.m3u8',
|
||||
];
|
||||
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
|
||||
} else {
|
||||
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
|
||||
}
|
||||
|
||||
const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
|
||||
|
||||
const hiresUpload = growingPath
|
||||
? Promise.resolve({ growingPath })
|
||||
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
|
||||
|
||||
const processes = { hires: hiresProcess };
|
||||
const uploads = { hires: hiresUpload };
|
||||
|
||||
// ── HLS tee for network sources (live preview in the UI) ──────────
|
||||
let hlsProcess = null;
|
||||
let hlsDir = null;
|
||||
if (isNetwork && this._assetIdForHls) {
|
||||
|
|
@ -168,49 +395,22 @@ class CaptureManager {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
hiresProcess.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
console.error(`[HIRES] ${text}`);
|
||||
// Track stream signal: ffmpeg prints "frame= 123 fps= 30 ..." every ~1s
|
||||
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
|
||||
if (m) {
|
||||
this.state.framesReceived = parseInt(m[1], 10);
|
||||
this.state.currentFps = parseFloat(m[2]);
|
||||
this.state.lastFrameAt = new Date().toISOString();
|
||||
}
|
||||
// Surface fatal-looking lines for the status endpoint
|
||||
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
|
||||
this.state.lastError = text.trim().slice(0, 240);
|
||||
}
|
||||
});
|
||||
|
||||
// SDI only: spawn a second FFmpeg process for the proxy.
|
||||
// DeckLink cards can be opened simultaneously by multiple processes;
|
||||
// network streams cannot.
|
||||
if (!isNetwork) {
|
||||
const proxyProcess = spawn('ffmpeg', [
|
||||
...inputArgs,
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'fast',
|
||||
'-b:v', '10M',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '192k',
|
||||
'-movflags', '+frag_keyframe+empty_moov',
|
||||
'-f', 'mp4',
|
||||
'pipe:1',
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
|
||||
processes.proxy = proxyProcess;
|
||||
uploads.proxy = proxyUpload;
|
||||
|
||||
proxyProcess.stderr.on('data', (data) => {
|
||||
console.error(`[PROXY] ${data}`);
|
||||
});
|
||||
}
|
||||
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
|
||||
// DeckLink hardware does not support two concurrent readers on the same port.
|
||||
|
||||
this.state.recording = true;
|
||||
this.state.sessionId = sessionId;
|
||||
|
|
@ -229,19 +429,21 @@ class CaptureManager {
|
|||
sourceUrl,
|
||||
hiresKey,
|
||||
proxyKey,
|
||||
growingPath,
|
||||
startedAt,
|
||||
duration: 0,
|
||||
uploads,
|
||||
codecs: {
|
||||
videoCodec, videoBitrate, framerate,
|
||||
audioCodec, audioBitrate, audioChannels, container,
|
||||
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
|
||||
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
|
||||
},
|
||||
};
|
||||
|
||||
return this._formatSessionResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current capture session
|
||||
* @param {string} sessionId - Session ID to stop
|
||||
* @returns {Object} Completed session info
|
||||
*/
|
||||
async stop(sessionId) {
|
||||
if (!this.state.recording || this.state.sessionId !== sessionId) {
|
||||
throw new Error('No active capture session or session ID mismatch');
|
||||
|
|
@ -249,19 +451,13 @@ class CaptureManager {
|
|||
|
||||
const { processes, currentSession } = this.state;
|
||||
|
||||
// Gracefully terminate all FFmpeg processes
|
||||
if (processes.hires) {
|
||||
processes.hires.kill('SIGINT');
|
||||
}
|
||||
if (processes.hires) processes.hires.kill('SIGINT');
|
||||
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||
|
||||
try {
|
||||
// Wait for all in-flight S3 uploads to complete
|
||||
const uploadPromises = [currentSession.uploads.hires];
|
||||
if (currentSession.uploads.proxy) {
|
||||
uploadPromises.push(currentSession.uploads.proxy);
|
||||
}
|
||||
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
|
||||
await Promise.all(uploadPromises);
|
||||
} catch (error) {
|
||||
console.error('Error during upload completion:', error);
|
||||
|
|
@ -272,11 +468,15 @@ class CaptureManager {
|
|||
const stopTime = new Date(stoppedAt);
|
||||
const duration = Math.round((stopTime - startTime) / 1000);
|
||||
|
||||
// Reset state
|
||||
this.state.recording = false;
|
||||
this.state.sessionId = null;
|
||||
this.state.processes = {};
|
||||
|
||||
// No frames received → the upload (if any) produced a 0-byte object.
|
||||
// Surface that so the shutdown handler can mark the asset as 'error'
|
||||
// instead of posting a broken hi-res key downstream.
|
||||
const framesReceived = this.state.framesReceived;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectId: currentSession.projectId,
|
||||
|
|
@ -284,23 +484,18 @@ class CaptureManager {
|
|||
clipName: currentSession.clipName,
|
||||
sourceType: currentSession.sourceType,
|
||||
hiresKey: currentSession.hiresKey,
|
||||
proxyKey: currentSession.proxyKey, // null for SRT/RTMP
|
||||
proxyKey: currentSession.proxyKey,
|
||||
growingPath: currentSession.growingPath || null,
|
||||
startedAt: currentSession.startedAt,
|
||||
stoppedAt,
|
||||
duration,
|
||||
framesReceived,
|
||||
empty: framesReceived === 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current capture status
|
||||
* @returns {Object} Current state
|
||||
*/
|
||||
getStatus() {
|
||||
if (!this.state.recording) {
|
||||
return {
|
||||
recording: false,
|
||||
};
|
||||
}
|
||||
if (!this.state.recording) return { recording: false };
|
||||
|
||||
const startTime = new Date(this.state.currentSession.startedAt);
|
||||
const now = new Date();
|
||||
|
|
@ -330,13 +525,10 @@ class CaptureManager {
|
|||
lastFrameAt,
|
||||
msSinceFrame,
|
||||
lastError: this.state.lastError,
|
||||
codecs: this.state.currentSession.codecs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session response
|
||||
* @private
|
||||
*/
|
||||
_formatSessionResponse() {
|
||||
const { currentSession, sessionId } = this.state;
|
||||
return {
|
||||
|
|
@ -349,8 +541,10 @@ class CaptureManager {
|
|||
hiresKey: currentSession.hiresKey,
|
||||
proxyKey: currentSession.proxyKey,
|
||||
startedAt: currentSession.startedAt,
|
||||
codecs: currentSession.codecs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptureManager();
|
||||
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };
|
||||
|
|
|
|||
|
|
@ -9,25 +9,40 @@ dotenv.config();
|
|||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||
const MAM_API_TOKEN = process.env.MAM_API_TOKEN || '';
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/capture', captureRoutes);
|
||||
|
||||
// Start server
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
||||
bootstrapAutoStart();
|
||||
});
|
||||
|
||||
// Mapped from the env vars routes/recorders.js writes into the container.
|
||||
// Empty strings collapse to undefined so capture-manager's defaults win.
|
||||
function envOpt(name) {
|
||||
const v = process.env[name];
|
||||
return v === undefined || v === '' ? undefined : v;
|
||||
}
|
||||
function envInt(name) {
|
||||
const v = envOpt(name);
|
||||
if (v === undefined) return undefined;
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
function envBool(name) {
|
||||
const v = envOpt(name);
|
||||
if (v === undefined) return undefined;
|
||||
return v === 'true' || v === '1' || v === 'yes';
|
||||
}
|
||||
|
||||
async function bootstrapAutoStart() {
|
||||
const recorderId = process.env.RECORDER_ID;
|
||||
const sourceType = process.env.SOURCE_TYPE;
|
||||
|
|
@ -44,29 +59,42 @@ async function bootstrapAutoStart() {
|
|||
}
|
||||
|
||||
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
|
||||
const listenPort = process.env.LISTEN_PORT
|
||||
? parseInt(process.env.LISTEN_PORT, 10)
|
||||
: undefined;
|
||||
const streamKey = process.env.STREAM_KEY || undefined;
|
||||
const sourceUrl = process.env.SOURCE_URL || undefined;
|
||||
|
||||
if (sourceType === 'sdi') {
|
||||
console.warn('[bootstrap] SDI auto-start not supported');
|
||||
return;
|
||||
}
|
||||
const listenPort = envInt('LISTEN_PORT');
|
||||
const streamKey = envOpt('STREAM_KEY');
|
||||
const sourceUrl = envOpt('SOURCE_URL');
|
||||
const device = envInt('DEVICE_INDEX');
|
||||
|
||||
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
|
||||
try {
|
||||
const session = await captureManager.start({
|
||||
assetId: process.env.ASSET_ID || null,
|
||||
assetId: envOpt('ASSET_ID') || null,
|
||||
projectId,
|
||||
binId: process.env.BIN_ID || null,
|
||||
binId: envOpt('BIN_ID') || null,
|
||||
clipName,
|
||||
device,
|
||||
sourceType,
|
||||
sourceUrl,
|
||||
listen,
|
||||
listenPort,
|
||||
streamKey,
|
||||
|
||||
// Recording codec — recorders.js passes these straight through
|
||||
videoCodec: envOpt('RECORDING_CODEC') || 'prores_hq',
|
||||
videoBitrate: envOpt('RECORDING_VIDEO_BITRATE'),
|
||||
framerate: envOpt('RECORDING_FRAMERATE'),
|
||||
audioCodec: envOpt('RECORDING_AUDIO_CODEC') || 'pcm_s24le',
|
||||
audioBitrate: envOpt('RECORDING_AUDIO_BITRATE'),
|
||||
audioChannels: envInt('RECORDING_AUDIO_CHANNELS') ?? 2,
|
||||
container: envOpt('RECORDING_CONTAINER') || 'mov',
|
||||
|
||||
proxyEnabled: envBool('PROXY_ENABLED') ?? true,
|
||||
proxyVideoCodec: envOpt('PROXY_CODEC') || 'h264',
|
||||
proxyVideoBitrate: envOpt('PROXY_VIDEO_BITRATE') || '8M',
|
||||
proxyFramerate: envOpt('PROXY_FRAMERATE'),
|
||||
proxyAudioCodec: envOpt('PROXY_AUDIO_CODEC') || 'aac',
|
||||
proxyAudioBitrate: envOpt('PROXY_AUDIO_BITRATE') || '192k',
|
||||
proxyAudioChannels: envInt('PROXY_AUDIO_CHANNELS') ?? 2,
|
||||
proxyContainer: envOpt('PROXY_CONTAINER') || 'mp4',
|
||||
});
|
||||
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
|
||||
} catch (err) {
|
||||
|
|
@ -86,31 +114,68 @@ async function gracefulShutdown(signal) {
|
|||
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
||||
try {
|
||||
const completed = await captureManager.stop(status.sessionId);
|
||||
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
|
||||
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s frames=${completed.framesReceived}`);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: completed.projectId,
|
||||
binId: completed.binId,
|
||||
clipName: completed.clipName,
|
||||
sourceType: completed.sourceType,
|
||||
hiresKey: completed.hiresKey,
|
||||
proxyKey: completed.proxyKey,
|
||||
needsProxy: completed.proxyKey === null,
|
||||
duration: completed.duration,
|
||||
capturedAt: completed.startedAt,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
|
||||
} else {
|
||||
console.log('[shutdown] asset registered with mam-api');
|
||||
const liveAssetId = process.env.ASSET_ID || null;
|
||||
|
||||
// No frames received → the source never connected (bad SRT URL, dead
|
||||
// SDI signal, RTMP stream key mismatch, etc.). The S3 upload at this
|
||||
// point is 0 bytes and would just clog the proxy queue with "moov
|
||||
// atom not found" failures. Mark the pre-created live asset as
|
||||
// 'error' and skip the POST /assets registration entirely.
|
||||
if (completed.empty) {
|
||||
console.warn('[shutdown] no frames received — marking asset as error and skipping registration');
|
||||
if (liveAssetId) {
|
||||
try {
|
||||
await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/mark-empty`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[shutdown] failed to flag empty asset:', e.message);
|
||||
}
|
||||
}
|
||||
} else if (liveAssetId) {
|
||||
// Finalise the pre-created live asset by id (avoids POST / 409 collision).
|
||||
try {
|
||||
const res = await fetch(`${MAM_API_URL}/api/v1/assets/${liveAssetId}/finalize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||
body: JSON.stringify({ hiresKey: completed.hiresKey, proxyKey: completed.proxyKey, duration: completed.duration }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[shutdown] mam-api finalize returned ${res.status}: ${await res.text()}`);
|
||||
} else {
|
||||
console.log('[shutdown] live asset finalised with mam-api');
|
||||
}
|
||||
} catch (mamErr) {
|
||||
console.error('[shutdown] failed to finalise asset:', mamErr.message);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(MAM_API_TOKEN ? { 'Authorization': `Bearer ${MAM_API_TOKEN}` } : {}) },
|
||||
body: JSON.stringify({
|
||||
projectId: completed.projectId,
|
||||
binId: completed.binId,
|
||||
clipName: completed.clipName,
|
||||
sourceType: completed.sourceType,
|
||||
hiresKey: completed.hiresKey,
|
||||
proxyKey: completed.proxyKey,
|
||||
needsProxy: completed.proxyKey === null,
|
||||
duration: completed.duration,
|
||||
capturedAt: completed.startedAt,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
|
||||
} else {
|
||||
console.log('[shutdown] asset registered with mam-api');
|
||||
}
|
||||
} catch (mamErr) {
|
||||
console.error('[shutdown] failed to register asset:', mamErr.message);
|
||||
}
|
||||
} catch (mamErr) {
|
||||
console.error('[shutdown] failed to register asset:', mamErr.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[shutdown] error during stop:', err);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import express from 'express';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import captureManager from '../capture-manager.js';
|
||||
|
||||
import dgram from 'dgram';
|
||||
|
|
@ -87,7 +88,7 @@ router.get('/devices', (req, res) => {
|
|||
let output = '';
|
||||
|
||||
try {
|
||||
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
||||
output = execSync('ffmpeg -sources decklink 2>&1', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -95,13 +96,13 @@ router.get('/devices', (req, res) => {
|
|||
output = error.stderr ? error.stderr.toString() : error.toString();
|
||||
}
|
||||
|
||||
// Parse ffmpeg output for DeckLink device names
|
||||
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
||||
// Parse ffmpeg output for DeckLink device names.
|
||||
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
|
||||
const lines = output.split('\n');
|
||||
let deviceIndex = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||
const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||
if (match) {
|
||||
devices.push({
|
||||
index: deviceIndex,
|
||||
|
|
@ -118,6 +119,57 @@ router.get('/devices', (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /devices/deltacast
|
||||
* List available Deltacast ports.
|
||||
* Reads /dev/deltacast<N> nodes; falls back to env DELTACAST_PORT_COUNT
|
||||
* so nodes without hardware still report their configured port count
|
||||
* (test-card mode).
|
||||
*/
|
||||
router.get('/devices/deltacast', (req, res) => {
|
||||
try {
|
||||
const devices = [];
|
||||
|
||||
// First: enumerate actual /dev/deltacast* device nodes.
|
||||
try {
|
||||
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n));
|
||||
devEntries.sort();
|
||||
for (const entry of devEntries) {
|
||||
const m = entry.match(/^deltacast(\d+)$/);
|
||||
if (m) {
|
||||
devices.push({
|
||||
index: parseInt(m[1], 10),
|
||||
name: `Deltacast Port ${m[1]}`,
|
||||
device: `/dev/${entry}`,
|
||||
present: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) { /* /dev always exists; ignore */ }
|
||||
|
||||
// Second: if DELTACAST_PORT_COUNT env is set and larger than what we found,
|
||||
// fill in the remaining slots as test-card entries (no physical device).
|
||||
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
|
||||
const found = new Set(devices.map(d => d.index));
|
||||
for (let i = 0; i < envCount; i++) {
|
||||
if (!found.has(i)) {
|
||||
devices.push({
|
||||
index: i,
|
||||
name: `Deltacast Port ${i} (test card)`,
|
||||
device: `/dev/deltacast${i}`,
|
||||
present: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
devices.sort((a, b) => a.index - b.index);
|
||||
res.json({ devices });
|
||||
} catch (error) {
|
||||
console.error('Error listing Deltacast devices:', error);
|
||||
res.status(500).json({ error: 'Failed to list Deltacast devices' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Get current capture status
|
||||
|
|
@ -137,10 +189,10 @@ router.post('/probe', async (req, res) => {
|
|||
|
||||
if (source_type === 'sdi') {
|
||||
try {
|
||||
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||
const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||
const devices = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||||
if (m) devices.push(m[1]);
|
||||
}
|
||||
return res.json({ ok: true, source_type, devices });
|
||||
|
|
@ -150,6 +202,28 @@ router.post('/probe', async (req, res) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (source_type === 'deltacast') {
|
||||
// Enumerate /dev/deltacast* nodes; report present/absent per index.
|
||||
try {
|
||||
const envCount = parseInt(process.env.DELTACAST_PORT_COUNT || '0', 10);
|
||||
const devEntries = readdirSync('/dev').filter(n => /^deltacast\d+$/.test(n)).sort();
|
||||
const found = devEntries.map(n => {
|
||||
const m = n.match(/^deltacast(\d+)$/);
|
||||
return { index: parseInt(m[1], 10), device: `/dev/${n}`, present: true };
|
||||
});
|
||||
const foundIdx = new Set(found.map(d => d.index));
|
||||
for (let i = 0; i < envCount; i++) {
|
||||
if (!foundIdx.has(i)) {
|
||||
found.push({ index: i, device: `/dev/deltacast${i}`, present: false });
|
||||
}
|
||||
}
|
||||
found.sort((a, b) => a.index - b.index);
|
||||
return res.json({ ok: true, source_type, devices: found });
|
||||
} catch (err) {
|
||||
return res.json({ ok: false, source_type, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (listen) {
|
||||
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
|
||||
}
|
||||
|
|
|
|||
330
services/capture/src/routes/capture.js.bak
Normal file
330
services/capture/src/routes/capture.js.bak
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
import express from 'express';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import captureManager from '../capture-manager.js';
|
||||
|
||||
import dgram from 'dgram';
|
||||
import net from 'net';
|
||||
|
||||
function parseUrl(u) {
|
||||
try {
|
||||
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
|
||||
if (!m) return null;
|
||||
return { host: m[1], port: parseInt(m[2] || '0', 10) };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
async function checkReachable(host, port, sourceType) {
|
||||
if (!port) return { ok: true };
|
||||
if (sourceType === 'srt') return await udpSendProbe(host, port);
|
||||
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function udpSendProbe(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = dgram.createSocket('udp4');
|
||||
let done = false;
|
||||
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
|
||||
sock.on('error', (err) => {
|
||||
const msg = String(err && err.message || err);
|
||||
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
|
||||
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
|
||||
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
|
||||
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
|
||||
} else {
|
||||
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
|
||||
}
|
||||
});
|
||||
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
|
||||
setTimeout(() => finish({ ok: true }), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function tcpConnectProbe(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
const sock = new net.Socket();
|
||||
let done = false;
|
||||
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
|
||||
sock.setTimeout(2500);
|
||||
sock.once('connect', () => finish({ ok: true }));
|
||||
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
|
||||
sock.once('error', (err) => {
|
||||
const msg = String(err && err.message || err);
|
||||
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
|
||||
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
|
||||
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
|
||||
});
|
||||
sock.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
function classifyProbeError(raw, sourceType) {
|
||||
const r = (raw || '').toLowerCase();
|
||||
if (sourceType === 'srt') {
|
||||
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
|
||||
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
|
||||
}
|
||||
}
|
||||
if (sourceType === 'rtmp') {
|
||||
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
|
||||
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
|
||||
|
||||
/**
|
||||
* GET /devices
|
||||
* List available DeckLink devices
|
||||
*/
|
||||
router.get('/devices', (req, res) => {
|
||||
try {
|
||||
const devices = [];
|
||||
let output = '';
|
||||
|
||||
try {
|
||||
output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (error) {
|
||||
// ffmpeg returns non-zero, but stderr is still captured
|
||||
output = error.stderr ? error.stderr.toString() : error.toString();
|
||||
}
|
||||
|
||||
// Parse ffmpeg output for DeckLink device names
|
||||
// Format: [decklink @ ...] "DeckLink Quad 2" (input #0)
|
||||
const lines = output.split('\n');
|
||||
let deviceIndex = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||
if (match) {
|
||||
devices.push({
|
||||
index: deviceIndex,
|
||||
name: match[1],
|
||||
});
|
||||
deviceIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ devices });
|
||||
} catch (error) {
|
||||
console.error('Error listing devices:', error);
|
||||
res.status(500).json({ error: 'Failed to list devices' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /status
|
||||
* Get current capture status
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
try {
|
||||
const status = captureManager.getStatus();
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Error getting status:', error);
|
||||
res.status(500).json({ error: 'Failed to get status' });
|
||||
}
|
||||
});
|
||||
router.post('/probe', async (req, res) => {
|
||||
try {
|
||||
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
|
||||
|
||||
if (source_type === 'sdi') {
|
||||
try {
|
||||
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||||
const devices = [];
|
||||
for (const line of raw.split('\n')) {
|
||||
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
|
||||
if (m) devices.push(m[1]);
|
||||
}
|
||||
return res.json({ ok: true, source_type, devices });
|
||||
} catch (err) {
|
||||
const out = (err.stderr || err.stdout || err.toString()).toString();
|
||||
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
|
||||
}
|
||||
}
|
||||
|
||||
if (listen) {
|
||||
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
|
||||
}
|
||||
|
||||
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
|
||||
|
||||
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
|
||||
// an actionable error instead of the opaque libsrt "Input/output error".
|
||||
const parsed = parseUrl(source_url);
|
||||
if (!parsed) {
|
||||
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
|
||||
}
|
||||
const reach = await checkReachable(parsed.host, parsed.port, source_type);
|
||||
if (!reach.ok) {
|
||||
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
|
||||
}
|
||||
|
||||
let url = source_url;
|
||||
if (source_type === 'srt' && !/mode=/.test(url)) {
|
||||
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
||||
}
|
||||
|
||||
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
|
||||
const ff = spawn('ffprobe', args);
|
||||
let stdout = '', stderr = '';
|
||||
ff.stdout.on('data', (c) => { stdout += c; });
|
||||
ff.stderr.on('data', (c) => { stderr += c; });
|
||||
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
|
||||
ff.on('close', (code) => {
|
||||
clearTimeout(killer);
|
||||
if (code !== 0) {
|
||||
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
|
||||
const friendly = classifyProbeError(rawErr, source_type);
|
||||
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
const streams = (parsed.streams || []).map(s => ({
|
||||
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
|
||||
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
|
||||
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
|
||||
sample_rate: s.sample_rate, channels: s.channels,
|
||||
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
|
||||
}));
|
||||
return res.json({ ok: true, source_type, source_url,
|
||||
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
|
||||
streams });
|
||||
} catch (err) {
|
||||
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Probe error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /start
|
||||
* Start a new capture session
|
||||
*
|
||||
* Body (SDI):
|
||||
* { project_id, clip_name, device, bin_id?, source_type? }
|
||||
*
|
||||
* Body (SRT/RTMP caller):
|
||||
* { project_id, clip_name, source_type, source_url, bin_id? }
|
||||
*
|
||||
* Body (SRT/RTMP listener):
|
||||
* { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? }
|
||||
*/
|
||||
router.post('/start', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
project_id,
|
||||
bin_id,
|
||||
clip_name,
|
||||
device,
|
||||
source_type = 'sdi',
|
||||
source_url,
|
||||
listen = false,
|
||||
listen_port,
|
||||
stream_key,
|
||||
} = req.body;
|
||||
|
||||
if (!project_id || !clip_name) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required fields: project_id, clip_name',
|
||||
});
|
||||
}
|
||||
|
||||
// Source-specific validation
|
||||
if (source_type === 'sdi') {
|
||||
if (device === undefined || device === null) {
|
||||
return res.status(400).json({ error: 'SDI source requires: device' });
|
||||
}
|
||||
} else if (source_type === 'srt' || source_type === 'rtmp') {
|
||||
if (!listen && !source_url) {
|
||||
return res.status(400).json({
|
||||
error: `${source_type.toUpperCase()} caller mode requires: source_url`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await captureManager.start({
|
||||
projectId: project_id,
|
||||
binId: bin_id || null,
|
||||
clipName: clip_name,
|
||||
device,
|
||||
sourceType: source_type,
|
||||
sourceUrl: source_url,
|
||||
listen,
|
||||
listenPort: listen_port,
|
||||
streamKey: stream_key,
|
||||
});
|
||||
|
||||
res.json(session);
|
||||
} catch (error) {
|
||||
console.error('Error starting capture:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /stop
|
||||
* Stop the current capture session
|
||||
* Body: { session_id }
|
||||
*/
|
||||
router.post('/stop', async (req, res) => {
|
||||
try {
|
||||
const { session_id } = req.body;
|
||||
|
||||
if (!session_id) {
|
||||
return res.status(400).json({ error: 'Missing required field: session_id' });
|
||||
}
|
||||
|
||||
const completedSession = await captureManager.stop(session_id);
|
||||
|
||||
// Register asset with mam-api.
|
||||
// If proxyKey is null (SRT/RTMP source), set needsProxy=true so the
|
||||
// worker generates a proxy from the hires file asynchronously.
|
||||
try {
|
||||
const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId: completedSession.projectId,
|
||||
binId: completedSession.binId,
|
||||
clipName: completedSession.clipName,
|
||||
sourceType: completedSession.sourceType,
|
||||
hiresKey: completedSession.hiresKey,
|
||||
proxyKey: completedSession.proxyKey,
|
||||
needsProxy: completedSession.proxyKey === null,
|
||||
duration: completedSession.duration,
|
||||
capturedAt: completedSession.startedAt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!mamResponse.ok) {
|
||||
console.warn(
|
||||
`MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`,
|
||||
);
|
||||
}
|
||||
} catch (mamError) {
|
||||
console.warn('Failed to register asset with MAM API:', mamError.message);
|
||||
}
|
||||
|
||||
res.json(completedSession);
|
||||
} catch (error) {
|
||||
console.error('Error stopping capture:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
65
services/editor/.gitignore
vendored
65
services/editor/.gitignore
vendored
|
|
@ -1,65 +0,0 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
.cache/
|
||||
.temp/
|
||||
.docs/
|
||||
docs/
|
||||
# Project-specific
|
||||
/public/projects/
|
||||
*.openreel
|
||||
apps/cloud/
|
||||
apps/ios
|
||||
apps/android
|
||||
|
||||
|
||||
|
||||
# Local files
|
||||
FEATURES_TWITTER.md
|
||||
.claude-tasks.md
|
||||
CLAUDE.md
|
||||
*-PLAN.md
|
||||
*-PLAN-*.md
|
||||
.playwright-mcp/
|
||||
.wrangler/
|
||||
|
||||
|
||||
mobile-mockup/
|
||||
2
services/editor/.serena/.gitignore
vendored
2
services/editor/.serena/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/cache
|
||||
/project.local.yml
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
# the name by which the project can be referenced within Serena
|
||||
project_name: "openreel-video"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
# Contributing to OpenReel
|
||||
|
||||
Thank you for your interest in contributing to OpenReel! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Table of Contents
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Standards](#coding-standards)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Testing](#testing)
|
||||
- [Submitting Changes](#submitting-changes)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Be respectful, constructive, and professional. We're building something great together!
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18 or higher
|
||||
- pnpm (recommended) or npm
|
||||
- Git
|
||||
- Modern browser with WebCodecs support (Chrome 94+, Edge 94+)
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# 1. Fork and clone the repository
|
||||
git clone https://github.com/Augani/openreel-video.git
|
||||
cd openreel-video
|
||||
|
||||
# 2. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 3. Start development server
|
||||
pnpm dev
|
||||
|
||||
# 4. Open browser to http://localhost:5173
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
openreel/
|
||||
├── apps/
|
||||
│ └── web/ # Main web application
|
||||
│ ├── public/ # Static assets
|
||||
│ └── src/
|
||||
│ ├── components/ # React components
|
||||
│ ├── stores/ # State management (Zustand)
|
||||
│ ├── bridges/ # Core engine bridges
|
||||
│ └── services/ # Business logic
|
||||
├── packages/
|
||||
│ └── core/ # Shared core logic
|
||||
│ ├── src/
|
||||
│ │ ├── actions/ # Action system
|
||||
│ │ ├── video/ # Video processing
|
||||
│ │ ├── audio/ # Audio processing
|
||||
│ │ ├── graphics/ # Graphics & SVG
|
||||
│ │ ├── text/ # Text & titles
|
||||
│ │ └── export/ # Export engine
|
||||
│ └── types/ # TypeScript types
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict mode**: Always use TypeScript strict mode
|
||||
- **Types**: Prefer interfaces over types for object shapes
|
||||
- **No `any`**: Avoid `any` - use `unknown` or proper types
|
||||
- **Naming**:
|
||||
- Components: `PascalCase` (e.g., `Timeline`, `Preview`)
|
||||
- Functions: `camelCase` (e.g., `handleClick`, `processVideo`)
|
||||
- Constants: `UPPER_SNAKE_CASE` (e.g., `MAX_DURATION`)
|
||||
- Files: `kebab-case.tsx` or `PascalCase.tsx` for components
|
||||
|
||||
### Code Style
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface VideoClip {
|
||||
id: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
function processClip(clip: VideoClip): ProcessedClip {
|
||||
if (!clip.id) {
|
||||
throw new Error('Clip ID is required');
|
||||
}
|
||||
|
||||
return {
|
||||
...clip,
|
||||
processed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ❌ Avoid
|
||||
function processClip(clip: any) {
|
||||
console.log('Processing...'); // Remove debug logs
|
||||
const result = clip; // Unclear what's happening
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### React Components
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface TimelineProps {
|
||||
tracks: Track[];
|
||||
onClipSelect: (clipId: string) => void;
|
||||
}
|
||||
|
||||
export const Timeline: React.FC<TimelineProps> = ({ tracks, onClipSelect }) => {
|
||||
const handleClick = useCallback((id: string) => {
|
||||
onClipSelect(id);
|
||||
}, [onClipSelect]);
|
||||
|
||||
return (
|
||||
<div className="timeline">
|
||||
{tracks.map(track => (
|
||||
<Track key={track.id} track={track} onClick={handleClick} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
- **Do**: Comment complex algorithms and business logic
|
||||
- **Don't**: Comment obvious code
|
||||
- **Do**: Add JSDoc for public APIs
|
||||
- **Don't**: Leave TODO comments without issues
|
||||
|
||||
```typescript
|
||||
// ✅ Good - Explains WHY
|
||||
// Use binary search for O(log n) performance on large timelines
|
||||
const clipIndex = binarySearch(clips, targetTime);
|
||||
|
||||
// ❌ Bad - States the obvious
|
||||
// Loop through clips
|
||||
for (const clip of clips) { }
|
||||
|
||||
// ✅ Good - Public API documentation
|
||||
/**
|
||||
* Applies a filter to a video clip
|
||||
* @param clipId - The clip identifier
|
||||
* @param filter - Filter configuration
|
||||
* @returns Updated clip with filter applied
|
||||
*/
|
||||
export function applyFilter(clipId: string, filter: Filter): Clip {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### 1. Create a Branch
|
||||
|
||||
```bash
|
||||
# Feature branch
|
||||
git checkout -b feat/add-transition-effects
|
||||
|
||||
# Bug fix branch
|
||||
git checkout -b fix/timeline-scroll-bug
|
||||
|
||||
# Documentation
|
||||
git checkout -b docs/update-contributing-guide
|
||||
```
|
||||
|
||||
### 2. Make Your Changes
|
||||
|
||||
- Write clean, self-documenting code
|
||||
- Follow the existing code style
|
||||
- Keep commits focused and atomic
|
||||
- Write meaningful commit messages
|
||||
|
||||
### 3. Commit Messages
|
||||
|
||||
Follow conventional commits:
|
||||
|
||||
```
|
||||
feat: add crossfade transition effect
|
||||
fix: resolve timeline scrubbing lag
|
||||
docs: update API documentation
|
||||
refactor: simplify video processing pipeline
|
||||
test: add tests for audio mixer
|
||||
perf: optimize waveform rendering
|
||||
```
|
||||
|
||||
### 4. Keep Your Branch Updated
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git rebase origin/main
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (watch mode)
|
||||
pnpm test
|
||||
|
||||
# Run tests once (CI mode)
|
||||
pnpm test:run
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
|
||||
# Linting
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
### Writing Tests
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { processClip } from './clip-processor';
|
||||
|
||||
describe('processClip', () => {
|
||||
it('should process a valid clip', () => {
|
||||
const clip = { id: '123', duration: 10, startTime: 0 };
|
||||
const result = processClip(clip);
|
||||
|
||||
expect(result.processed).toBe(true);
|
||||
expect(result.id).toBe('123');
|
||||
});
|
||||
|
||||
it('should throw error for invalid clip', () => {
|
||||
const clip = { id: '', duration: 10, startTime: 0 };
|
||||
|
||||
expect(() => processClip(clip)).toThrow('Clip ID is required');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Submitting Changes
|
||||
|
||||
### 1. Push Your Branch
|
||||
|
||||
```bash
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
|
||||
### 2. Create a Pull Request
|
||||
|
||||
1. Go to GitHub and create a pull request
|
||||
2. Fill out the PR template:
|
||||
- **Description**: What does this PR do?
|
||||
- **Motivation**: Why is this change needed?
|
||||
- **Testing**: How was this tested?
|
||||
- **Screenshots**: For UI changes
|
||||
- **Breaking Changes**: Any breaking changes?
|
||||
|
||||
### 3. PR Template
|
||||
|
||||
```markdown
|
||||
## Description
|
||||
Brief description of changes
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Testing
|
||||
- [ ] Tested locally
|
||||
- [ ] Added/updated tests
|
||||
- [ ] All tests passing
|
||||
|
||||
## Screenshots (if applicable)
|
||||
[Add screenshots for UI changes]
|
||||
|
||||
## Checklist
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex code
|
||||
- [ ] Documentation updated
|
||||
- [ ] No console.log or debug code left
|
||||
- [ ] Tests pass
|
||||
```
|
||||
|
||||
### 4. Code Review Process
|
||||
|
||||
- Respond to feedback promptly
|
||||
- Make requested changes
|
||||
- Push updates to the same branch
|
||||
- Re-request review when ready
|
||||
|
||||
## Areas to Contribute
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Check [Issues](https://github.com/Augani/openreel-video/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
|
||||
- Reproduce the bug
|
||||
- Write a failing test
|
||||
- Fix the bug
|
||||
- Verify the test passes
|
||||
|
||||
### ✨ New Features
|
||||
- Discuss in [Discussions](https://github.com/Augani/openreel-video/discussions) first
|
||||
- Get approval before large changes
|
||||
- Break into smaller PRs if possible
|
||||
- Update documentation
|
||||
|
||||
### 📖 Documentation
|
||||
- Fix typos and errors
|
||||
- Add examples
|
||||
- Improve clarity
|
||||
- Add tutorials
|
||||
|
||||
### 🎨 Effects & Presets
|
||||
- Create new video effects
|
||||
- Add transition effects
|
||||
- Build color grading presets
|
||||
- Contribute templates
|
||||
|
||||
### 🧪 Testing
|
||||
- Add missing tests
|
||||
- Improve test coverage
|
||||
- Add integration tests
|
||||
- Performance testing
|
||||
|
||||
### 🌍 Translation
|
||||
- Add new language support
|
||||
- Improve existing translations
|
||||
- Fix translation errors
|
||||
|
||||
## Development Tips
|
||||
|
||||
### Hot Reload
|
||||
Changes to React components hot reload automatically. For core engine changes, you may need to refresh.
|
||||
|
||||
### Debugging
|
||||
```typescript
|
||||
// Use browser DevTools
|
||||
// Set breakpoints in TypeScript source
|
||||
// Check Network tab for media loading
|
||||
// Use Performance profiler for optimization
|
||||
```
|
||||
|
||||
### Performance
|
||||
- Profile before optimizing
|
||||
- Use Web Workers for heavy processing
|
||||
- Leverage WebCodecs API for video
|
||||
- Cache expensive computations
|
||||
- Use useMemo/useCallback appropriately
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Issue**: Video won't play
|
||||
- Check browser support for WebCodecs
|
||||
- Verify codec support
|
||||
- Check browser console for errors
|
||||
|
||||
**Issue**: Build fails
|
||||
- Clear node_modules and reinstall
|
||||
- Check Node.js version (18+)
|
||||
- Verify pnpm version
|
||||
|
||||
**Issue**: Tests fail
|
||||
- Try running `pnpm test:run` for a single run
|
||||
- Check for console errors
|
||||
- Verify test environment setup
|
||||
- Run `pnpm typecheck` to check for type errors
|
||||
|
||||
## Questions?
|
||||
|
||||
- **Discord**: [Join our Discord](https://discord.gg/openreeel)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/Augani/openreel-video/discussions)
|
||||
- **Email**: contribute@openreeel.video
|
||||
|
||||
## Recognition
|
||||
|
||||
Contributors are recognized in:
|
||||
- README.md contributors section
|
||||
- GitHub contributors page
|
||||
- Release notes for significant contributions
|
||||
|
||||
Thank you for contributing to OpenReel! 🎬
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.6
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache python3 make g++ git bash
|
||||
RUN corepack enable && corepack prepare pnpm@9.7.0 --activate
|
||||
WORKDIR /build
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json mediabunny.d.ts ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
RUN pnpm build:wasm || echo "no wasm build step, continuing"
|
||||
RUN pnpm --filter @openreel/web build
|
||||
RUN ls -la apps/web/dist
|
||||
|
||||
FROM nginx:alpine AS runtime
|
||||
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /build/apps/web/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Z-AMPP <-> openreel-video integration
|
||||
|
||||
Vendored from https://github.com/Augani/openreel-video (MIT). The upstream .git directory was removed so this lives as plain source we can patch freely.
|
||||
|
||||
## Files added (Z-AMPP-only, not upstream)
|
||||
- Dockerfile, nginx.conf, VENDOR.txt, INTEGRATION.md
|
||||
- apps/web/src/mam-bridge.ts: boot hook + pickFromMAM() modal
|
||||
- packages/core/src/export/mam-export-target.ts: helpers for upload-to-MAM
|
||||
|
||||
## Upstream files patched
|
||||
- apps/web/package.json: build script changed `tsc --noEmit && vite build` -> `vite build`. Original preserved as build:strict. (upstream tsc fails on pre-existing WebGPU + import.meta errors.)
|
||||
- apps/web/src/bridges/media-bridge.ts: appended importFromURL(url, name, contentType?) as the last method of the MediaBridge class.
|
||||
- apps/web/src/main.tsx: appended `import "./mam-bridge";` so the bridge boot hook runs.
|
||||
|
||||
## Query params honored
|
||||
- ?asset=<uuid> auto-imports that asset on load.
|
||||
- ?project=<uuid> stored in localStorage.mamProjectId for save-to-MAM.
|
||||
|
||||
## Ports
|
||||
Container exposes 80; compose maps ${PORT_EDITOR:-47435}:80.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Augustus Otu and Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
# OpenReel Video
|
||||
|
||||
> **The open source CapCut alternative. Professional video editing in your browser. No uploads. No installs. 100% open source.**
|
||||
|
||||
OpenReel Video is a fully-featured browser-based video editor that runs entirely client-side. Built with React, TypeScript, WebCodecs, and WebGPU for professional-grade video editing without the need for expensive software or cloud processing.
|
||||
|
||||
**[Try it Live](https://openreel.video)** | **[Documentation](CONTRIBUTING.md)** | **[Discussions](https://github.com/Augani/openreel-video/discussions)** | **[Twitter](https://x.com/python_xi)**
|
||||
|
||||
   
|
||||
|
||||
---
|
||||
|
||||
## Why OpenReel?
|
||||
|
||||
- **100% Client-Side** - Your videos never leave your device. No uploads, no cloud processing, complete privacy.
|
||||
- **No Installation** - Works in Chrome/Edge. Just open and start editing.
|
||||
- **Professional Features** - Multi-track timeline, keyframe animations, color grading, audio effects, and more.
|
||||
- **GPU Accelerated** - WebGPU and WebCodecs for smooth 4K editing and fast exports.
|
||||
- **Free Forever** - MIT licensed, no subscriptions, no watermarks.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Video Editing
|
||||
- **Multi-track timeline** - Unlimited video, audio, image, text, and graphics tracks
|
||||
- **Real-time preview** - Smooth playback with GPU acceleration
|
||||
- **Precision editing** - Frame-accurate scrubbing, cut, trim, split, ripple delete
|
||||
- **Transitions** - Crossfade, dip to black/white, wipe, slide effects
|
||||
- **Video effects** - Brightness, contrast, saturation, blur, sharpen, glow, vignette, chroma key
|
||||
- **Blend modes** - Multiply, screen, overlay, add, subtract, and more
|
||||
- **Speed control** - 0.25x to 4x with audio pitch preservation
|
||||
- **Crop & transform** - Position, scale, rotation with 3D perspective
|
||||
|
||||
### Graphics & Text
|
||||
- **Professional text editor** - Rich styling, shadows, outlines, gradients
|
||||
- **20+ text animations** - Typewriter, fade, slide, bounce, pop, elastic, glitch
|
||||
- **Karaoke-style subtitles** - Word-by-word highlighting synced to audio
|
||||
- **Shape tools** - Rectangle, circle, arrow, polygon, star with fill/stroke
|
||||
- **SVG support** - Import SVGs with color tinting and animations
|
||||
- **Stickers & emoji** - Built-in library
|
||||
- **Background generator** - Solid colors, gradients, mesh gradients, patterns
|
||||
- **Keyframe animations** - Animate any property over time with 20+ easing curves
|
||||
|
||||
### Audio
|
||||
- **Multi-track mixing** - Unlimited audio tracks with real-time mixing
|
||||
- **Waveform visualization** - Visual audio editing
|
||||
- **Audio effects** - EQ, compressor, reverb, delay, chorus, flanger, distortion
|
||||
- **Volume & panning** - Per-clip controls with fade in/out
|
||||
- **Beat detection** - Auto-generate markers synced to music
|
||||
- **Audio ducking** - Auto-reduce music when dialog plays
|
||||
- **Noise reduction** - 3-pass noise removal (tonal, broadband, rumble)
|
||||
|
||||
### Color Grading
|
||||
- **Color wheels** - Lift, gamma, gain controls
|
||||
- **HSL adjustments** - Hue, saturation, lightness fine-tuning
|
||||
- **Curves editor** - RGB and individual channel curves
|
||||
- **LUT support** - Import and apply 3D LUTs
|
||||
- **Built-in presets** - One-click color grading
|
||||
|
||||
### Export
|
||||
- **MP4 (H.264/H.265)** - Universal compatibility
|
||||
- **WebM (VP8/VP9/AV1)** - Web-optimized format
|
||||
- **ProRes** - Professional intermediate format (Proxy, LT, Standard, HQ, 4444)
|
||||
- **Quality presets** - 4K @ 60fps, 1080p, 720p, 480p
|
||||
- **Custom settings** - Bitrate, frame rate, codec options, color depth
|
||||
- **Hardware encoding** - WebCodecs for fast exports
|
||||
- **AI upscaling** - Enhance resolution with WebGPU shaders
|
||||
- **Audio export** - MP3, WAV, AAC, FLAC, OGG
|
||||
- **Image sequences** - JPG, PNG, WebP frame export
|
||||
- **Progress tracking** - Real-time progress with cancel support
|
||||
|
||||
### Professional Tools
|
||||
- **Unlimited undo/redo** - Full history with recovery
|
||||
- **Auto-save** - Never lose work (IndexedDB storage)
|
||||
- **Keyboard shortcuts** - Professional workflow
|
||||
- **Snap to grid** - Magnetic alignment
|
||||
- **Track management** - Show/hide, lock/unlock, reorder
|
||||
- **Subtitle support** - SRT import with customizable styling
|
||||
- **Screen recording** - Record screen, camera, or both
|
||||
- **Project sharing** - Export/import project files
|
||||
|
||||
### Performance
|
||||
- **WebGPU rendering** - GPU-accelerated compositing
|
||||
- **WebCodecs API** - Hardware video decoding/encoding
|
||||
- **Frame caching** - LRU cache for smooth playback
|
||||
- **Web Workers** - Background processing
|
||||
- **4K support** - Edit and export in 4K resolution
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Try Online
|
||||
Visit **[openreel.video](https://openreel.video)** to start editing immediately.
|
||||
|
||||
### Run Locally
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/Augani/openreel-video.git
|
||||
cd openreel-video
|
||||
|
||||
# Install dependencies (requires Node.js 18+)
|
||||
pnpm install
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||
# Open http://localhost:5173
|
||||
```
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser Requirements
|
||||
|
||||
| Browser | Version | Status |
|
||||
|---------|---------|--------|
|
||||
| Chrome | 94+ | Full support |
|
||||
| Edge | 94+ | Full support |
|
||||
| Firefox | 130+ | Full support |
|
||||
| Safari | 16.4+ | Full support |
|
||||
|
||||
All major browsers now support WebCodecs for hardware-accelerated video encoding/decoding.
|
||||
|
||||
**Recommended:**
|
||||
- 8GB+ RAM
|
||||
- Dedicated GPU for 4K editing
|
||||
- Modern multi-core CPU
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
openreel/
|
||||
├── apps/web/ # React frontend (~66k lines)
|
||||
│ └── src/
|
||||
│ ├── components/ # UI components
|
||||
│ │ └── editor/ # Editor panels (Timeline, Preview, Inspector)
|
||||
│ ├── stores/ # Zustand state management
|
||||
│ ├── services/ # Auto-save, shortcuts, screen recording
|
||||
│ └── bridges/ # Engine coordination
|
||||
│
|
||||
└── packages/core/ # Core engines (~59k lines)
|
||||
└── src/
|
||||
├── video/ # Video processing, WebGPU rendering
|
||||
├── audio/ # Web Audio API, effects, beat detection
|
||||
├── graphics/ # Canvas/THREE.js, shapes, SVG
|
||||
├── text/ # Text rendering, animations
|
||||
├── export/ # MP4/WebM encoding
|
||||
└── storage/ # IndexedDB, serialization
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
- **React 18** + **TypeScript** - Type-safe UI
|
||||
- **Zustand** - Lightweight state management
|
||||
- **MediaBunny** - Video/audio processing
|
||||
- **WebCodecs** - Hardware encoding/decoding
|
||||
- **WebGPU** - GPU-accelerated rendering
|
||||
- **Web Audio API** - Professional audio processing
|
||||
- **THREE.js** - 3D transforms and effects
|
||||
- **IndexedDB** - Local project storage
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **Action-based editing** - Every edit is an undoable action
|
||||
- **Immutable state** - Predictable updates with Zustand
|
||||
- **Engine separation** - Video, audio, graphics engines are independent
|
||||
- **Progressive enhancement** - Graceful fallbacks (WebGPU → Canvas2D)
|
||||
|
||||
---
|
||||
|
||||
## AI-Managed Development
|
||||
|
||||
OpenReel is an experiment in AI-assisted open source development. Claude AI helps manage:
|
||||
|
||||
- **Issue triage** - Reviews and responds to issues
|
||||
- **Code implementation** - Writes features and fixes bugs
|
||||
- **Code review** - Maintains quality standards
|
||||
- **Documentation** - Keeps docs up to date
|
||||
|
||||
Human oversight from Augustus ensures strategic direction and final approval on major changes. All code is public, tested, and follows best practices.
|
||||
|
||||
**What this means for contributors:**
|
||||
- Issues get reviewed quickly (usually within 24 hours)
|
||||
- Bug fixes ship fast
|
||||
- Clear, detailed responses to questions
|
||||
- High code quality standards
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
**Ways to contribute:**
|
||||
- Report bugs with reproduction steps
|
||||
- Suggest features in Discussions
|
||||
- Submit PRs for bugs or features
|
||||
- Improve documentation
|
||||
- Write tests
|
||||
- Share effect presets
|
||||
|
||||
**Development workflow:**
|
||||
```bash
|
||||
# Fork and clone
|
||||
git clone https://github.com/Augani/openreel-video.git
|
||||
|
||||
# Create feature branch
|
||||
git checkout -b feat/your-feature
|
||||
|
||||
# Make changes, then test
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm lint
|
||||
|
||||
# Commit with conventional commits
|
||||
git commit -m "feat: add your feature"
|
||||
|
||||
# Push and open PR
|
||||
git push origin feat/your-feature
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Completed
|
||||
- Multi-track timeline with drag-and-drop
|
||||
- Real-time video preview with GPU acceleration
|
||||
- Full editing suite (cut, trim, split, transitions)
|
||||
- Text editor with 20+ animations
|
||||
- Graphics (shapes, SVG, stickers, backgrounds)
|
||||
- Audio mixing with effects and beat detection
|
||||
- Color grading with LUT support
|
||||
- Keyframe animation system
|
||||
- Export to MP4/WebM (4K supported)
|
||||
- Screen recording
|
||||
- AI upscaling
|
||||
- Undo/redo with auto-save
|
||||
|
||||
### In Progress
|
||||
- Nested sequences (timeline in timeline)
|
||||
- Motion tracking
|
||||
- More export formats (ProRes, GIF)
|
||||
- Plugin system
|
||||
|
||||
### Planned
|
||||
- Adjustment layers
|
||||
- Advanced masking
|
||||
- Audio spectral editing
|
||||
- Collaborative editing
|
||||
- Mobile optimization
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Use freely for personal and commercial projects.
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
**Built with:**
|
||||
- [MediaBunny](https://mediabunny.dev) - Media processing
|
||||
- [React](https://react.dev) - UI framework
|
||||
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
|
||||
- [THREE.js](https://threejs.org) - 3D rendering
|
||||
- [TailwindCSS](https://tailwindcss.com) - Styling
|
||||
|
||||
**Inspired by:**
|
||||
- DaVinci Resolve - Professional tools done right
|
||||
- CapCut - Accessible editing for everyone
|
||||
- Figma - Browser-based professional software
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- **GitHub Issues** - Bug reports and feature requests
|
||||
- **GitHub Discussions** - Questions and community chat
|
||||
- **Twitter/X** - [@python_xi](https://x.com/python_xi)
|
||||
|
||||
---
|
||||
|
||||
## $OPENREEL Token
|
||||
|
||||
CA: `B7wDnfrdtvdG7SCkRjSMJ6LkVwGWvdWrQ75iV8G9pump`
|
||||
|
||||
---
|
||||
|
||||
**Built with care by [@python_xi](https://x.com/python_xi) and AI working together.**
|
||||
|
||||
*Making professional video editing accessible to everyone. Forever free. Forever open source.*
|
||||
|
|
@ -1 +0,0 @@
|
|||
Vendored from Augani/openreel-video @ 2026-05-18T01:29:08Z
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import js from "@eslint/js";
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsparser from "@typescript-eslint/parser";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
...globals.node,
|
||||
NodeJS: "readonly",
|
||||
CanvasTextAlign: "readonly",
|
||||
CanvasTextBaseline: "readonly",
|
||||
CanvasLineCap: "readonly",
|
||||
CanvasLineJoin: "readonly",
|
||||
CanvasFillRule: "readonly",
|
||||
GlobalCompositeOperation: "readonly",
|
||||
ImageBitmap: "readonly",
|
||||
OffscreenCanvas: "readonly",
|
||||
OffscreenCanvasRenderingContext2D: "readonly",
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"prefer-const": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"no-empty": "warn",
|
||||
"no-case-declarations": "warn",
|
||||
"react-hooks/rules-of-hooks": "warn",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"*.config.js",
|
||||
"*.config.ts",
|
||||
"vite.config.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#22c55e" />
|
||||
<meta name="description" content="Professional browser-based graphic design editor - Create stunning visuals offline" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=DM+Sans:wght@400;500;700&family=Poppins:wght@300;400;500;600;700;800;900&family=Montserrat:wght@300;400;500;600;700;800;900&family=Playfair+Display:wght@400;500;600;700;800;900&family=Roboto:wght@300;400;500;700;900&family=Open+Sans:wght@300;400;600;700;800&family=Lato:wght@300;400;700;900&family=Oswald:wght@300;400;500;600;700&family=Bebas+Neue&family=Pacifico&family=Lobster&family=Dancing+Script:wght@400;700&family=Great+Vibes&display=swap" rel="stylesheet" />
|
||||
<title>OpenReel Image - Professional Graphic Design Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
{
|
||||
"name": "@openreel/image",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "wrangler pages deploy dist --project-name=openreel-image",
|
||||
"deploy:preview": "wrangler pages deploy dist --project-name=openreel-image --branch=preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist node_modules/.vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"@openreel/image-core": "workspace:*",
|
||||
"@openreel/ui": "workspace:*",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^24.1.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0",
|
||||
"wrangler": "^3.114.17"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="20" fill="#22c55e"/>
|
||||
<rect x="20" y="20" width="60" height="60" rx="8" fill="white" fill-opacity="0.9"/>
|
||||
<circle cx="35" cy="38" r="8" fill="#22c55e"/>
|
||||
<path d="M20 65 L45 45 L60 55 L80 35 L80 72 A8 8 0 0 1 72 80 L28 80 A8 8 0 0 1 20 72 Z" fill="#22c55e" fill-opacity="0.8"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 389 B |
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "OpenReel Image",
|
||||
"short_name": "OpenReel",
|
||||
"description": "Professional browser-based graphic design editor",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#22c55e",
|
||||
"orientation": "landscape",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["graphics", "design", "productivity"]
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
const CACHE_NAME = 'openreel-image-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok && response.status === 200) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useUIStore } from './stores/ui-store';
|
||||
import { WelcomeScreen } from './components/welcome/WelcomeScreen';
|
||||
import { EditorInterface } from './components/editor/EditorInterface';
|
||||
import { KeyboardShortcutsPanel } from './components/editor/KeyboardShortcutsPanel';
|
||||
import { SettingsDialog } from './components/editor/SettingsDialog';
|
||||
import { useKeyboardShortcuts } from './services/keyboard-service';
|
||||
import { useAutoSave } from './hooks/useAutoSave';
|
||||
|
||||
export default function App() {
|
||||
const { currentView, setCurrentView, showShortcutsPanel, toggleShortcutsPanel, showSettingsDialog, closeSettingsDialog } = useUIStore();
|
||||
|
||||
useKeyboardShortcuts();
|
||||
useAutoSave();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && currentView === 'editor') {
|
||||
setCurrentView('welcome');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [currentView, setCurrentView]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-background">
|
||||
{currentView === 'welcome' && <WelcomeScreen />}
|
||||
{currentView === 'editor' && <EditorInterface />}
|
||||
<KeyboardShortcutsPanel isOpen={showShortcutsPanel} onClose={toggleShortcutsPanel} />
|
||||
<SettingsDialog isOpen={showSettingsDialog} onClose={closeSettingsDialog} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
export interface BlackWhiteSettings {
|
||||
reds: number;
|
||||
yellows: number;
|
||||
greens: number;
|
||||
cyans: number;
|
||||
blues: number;
|
||||
magentas: number;
|
||||
tint: {
|
||||
enabled: boolean;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_BLACK_WHITE: BlackWhiteSettings = {
|
||||
reds: 40,
|
||||
yellows: 60,
|
||||
greens: 40,
|
||||
cyans: 60,
|
||||
blues: 20,
|
||||
magentas: 80,
|
||||
tint: {
|
||||
enabled: false,
|
||||
hue: 30,
|
||||
saturation: 25,
|
||||
},
|
||||
};
|
||||
|
||||
export const BLACK_WHITE_PRESETS = {
|
||||
default: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
|
||||
highContrast: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
|
||||
infrared: { reds: -70, yellows: 200, greens: -70, cyans: 200, blues: -20, magentas: -20 },
|
||||
maximumWhite: { reds: 100, yellows: 100, greens: 100, cyans: 100, blues: 100, magentas: 100 },
|
||||
maximumBlack: { reds: -200, yellows: -200, greens: -200, cyans: -200, blues: -200, magentas: -200 },
|
||||
neutral: { reds: 33, yellows: 33, greens: 33, cyans: 33, blues: 33, magentas: 33 },
|
||||
redFilter: { reds: 106, yellows: 52, greens: -10, cyans: -40, blues: -30, magentas: 94 },
|
||||
yellowFilter: { reds: 34, yellows: 106, greens: 54, cyans: -26, blues: -50, magentas: 14 },
|
||||
greenFilter: { reds: -44, yellows: 64, greens: 106, cyans: 60, blues: -30, magentas: -70 },
|
||||
blueFilter: { reds: -30, yellows: -46, greens: -16, cyans: 30, blues: 106, magentas: 30 },
|
||||
};
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
let h: number;
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
if (s === 0) {
|
||||
const gray = Math.round(l * 255);
|
||||
return { r: gray, g: gray, b: gray };
|
||||
}
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
return {
|
||||
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
g: Math.round(hue2rgb(p, q, h) * 255),
|
||||
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
function getColorWeight(hue: number, targetHue: number, spread: number = 60): number {
|
||||
let diff = Math.abs(hue - targetHue);
|
||||
if (diff > 180) diff = 360 - diff;
|
||||
if (diff >= spread) return 0;
|
||||
return 1 - diff / spread;
|
||||
}
|
||||
|
||||
export function applyBlackWhite(imageData: ImageData, settings: BlackWhiteSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const { h, s } = rgbToHsl(r, g, b);
|
||||
const hue = h * 360;
|
||||
|
||||
let gray = (r + g + b) / 3;
|
||||
|
||||
if (s > 0.05) {
|
||||
const redWeight = getColorWeight(hue, 0) + getColorWeight(hue, 360);
|
||||
const yellowWeight = getColorWeight(hue, 60);
|
||||
const greenWeight = getColorWeight(hue, 120);
|
||||
const cyanWeight = getColorWeight(hue, 180);
|
||||
const blueWeight = getColorWeight(hue, 240);
|
||||
const magentaWeight = getColorWeight(hue, 300);
|
||||
|
||||
const totalWeight = redWeight + yellowWeight + greenWeight + cyanWeight + blueWeight + magentaWeight;
|
||||
|
||||
if (totalWeight > 0) {
|
||||
const adjustment =
|
||||
(redWeight * settings.reds +
|
||||
yellowWeight * settings.yellows +
|
||||
greenWeight * settings.greens +
|
||||
cyanWeight * settings.cyans +
|
||||
blueWeight * settings.blues +
|
||||
magentaWeight * settings.magentas) / totalWeight;
|
||||
|
||||
gray = gray * (1 + (adjustment - 50) / 100 * s);
|
||||
}
|
||||
}
|
||||
|
||||
gray = Math.max(0, Math.min(255, gray));
|
||||
|
||||
let finalR = gray;
|
||||
let finalG = gray;
|
||||
let finalB = gray;
|
||||
|
||||
if (settings.tint.enabled) {
|
||||
const tintH = settings.tint.hue / 360;
|
||||
const tintS = settings.tint.saturation / 100;
|
||||
const tintL = gray / 255;
|
||||
|
||||
const tinted = hslToRgb(tintH, tintS, tintL);
|
||||
finalR = tinted.r;
|
||||
finalG = tinted.g;
|
||||
finalB = tinted.b;
|
||||
}
|
||||
|
||||
resultData[i] = finalR;
|
||||
resultData[i + 1] = finalG;
|
||||
resultData[i + 2] = finalB;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
export interface ChannelMixerSettings {
|
||||
red: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
constant: number;
|
||||
};
|
||||
green: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
constant: number;
|
||||
};
|
||||
blue: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
constant: number;
|
||||
};
|
||||
monochrome: boolean;
|
||||
monoRed: number;
|
||||
monoGreen: number;
|
||||
monoBlue: number;
|
||||
monoConstant: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHANNEL_MIXER: ChannelMixerSettings = {
|
||||
red: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
monochrome: false,
|
||||
monoRed: 40,
|
||||
monoGreen: 40,
|
||||
monoBlue: 20,
|
||||
monoConstant: 0,
|
||||
};
|
||||
|
||||
export const CHANNEL_MIXER_PRESETS = {
|
||||
default: {
|
||||
red: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
},
|
||||
swapRedBlue: {
|
||||
red: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
},
|
||||
sepia: {
|
||||
red: { red: 100, green: 50, blue: 0, constant: 0 },
|
||||
green: { red: 60, green: 60, blue: 0, constant: 0 },
|
||||
blue: { red: 30, green: 30, blue: 30, constant: 0 },
|
||||
},
|
||||
cyberPunk: {
|
||||
red: { red: 100, green: 0, blue: 50, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 50, constant: 0 },
|
||||
blue: { red: 50, green: 0, blue: 100, constant: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
export function applyChannelMixer(imageData: ImageData, settings: ChannelMixerSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
let newR: number, newG: number, newB: number;
|
||||
|
||||
if (settings.monochrome) {
|
||||
const gray =
|
||||
r * (settings.monoRed / 100) +
|
||||
g * (settings.monoGreen / 100) +
|
||||
b * (settings.monoBlue / 100) +
|
||||
settings.monoConstant * 2.55;
|
||||
|
||||
newR = newG = newB = Math.max(0, Math.min(255, gray));
|
||||
} else {
|
||||
newR =
|
||||
r * (settings.red.red / 100) +
|
||||
g * (settings.red.green / 100) +
|
||||
b * (settings.red.blue / 100) +
|
||||
settings.red.constant * 2.55;
|
||||
|
||||
newG =
|
||||
r * (settings.green.red / 100) +
|
||||
g * (settings.green.green / 100) +
|
||||
b * (settings.green.blue / 100) +
|
||||
settings.green.constant * 2.55;
|
||||
|
||||
newB =
|
||||
r * (settings.blue.red / 100) +
|
||||
g * (settings.blue.green / 100) +
|
||||
b * (settings.blue.blue / 100) +
|
||||
settings.blue.constant * 2.55;
|
||||
}
|
||||
|
||||
resultData[i] = Math.max(0, Math.min(255, newR));
|
||||
resultData[i + 1] = Math.max(0, Math.min(255, newG));
|
||||
resultData[i + 2] = Math.max(0, Math.min(255, newB));
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
export interface ColorBalanceSettings {
|
||||
shadows: {
|
||||
cyanRed: number;
|
||||
magentaGreen: number;
|
||||
yellowBlue: number;
|
||||
};
|
||||
midtones: {
|
||||
cyanRed: number;
|
||||
magentaGreen: number;
|
||||
yellowBlue: number;
|
||||
};
|
||||
highlights: {
|
||||
cyanRed: number;
|
||||
magentaGreen: number;
|
||||
yellowBlue: number;
|
||||
};
|
||||
preserveLuminosity: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOR_BALANCE: ColorBalanceSettings = {
|
||||
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
preserveLuminosity: true,
|
||||
};
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
return r * 0.299 + g * 0.587 + b * 0.114;
|
||||
}
|
||||
|
||||
function getToneWeight(luminance: number, tone: 'shadows' | 'midtones' | 'highlights'): number {
|
||||
const normalized = luminance / 255;
|
||||
|
||||
switch (tone) {
|
||||
case 'shadows':
|
||||
if (normalized <= 0.25) return 1;
|
||||
if (normalized <= 0.5) return 1 - (normalized - 0.25) / 0.25;
|
||||
return 0;
|
||||
|
||||
case 'highlights':
|
||||
if (normalized >= 0.75) return 1;
|
||||
if (normalized >= 0.5) return (normalized - 0.5) / 0.25;
|
||||
return 0;
|
||||
|
||||
case 'midtones':
|
||||
if (normalized >= 0.25 && normalized <= 0.75) {
|
||||
const distFromCenter = Math.abs(normalized - 0.5);
|
||||
return 1 - distFromCenter / 0.25;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyColorBalance(imageData: ImageData, settings: ColorBalanceSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const luminance = getLuminance(r, g, b);
|
||||
|
||||
const shadowWeight = getToneWeight(luminance, 'shadows');
|
||||
const midtoneWeight = getToneWeight(luminance, 'midtones');
|
||||
const highlightWeight = getToneWeight(luminance, 'highlights');
|
||||
|
||||
let rShift = 0, gShift = 0, bShift = 0;
|
||||
|
||||
if (shadowWeight > 0) {
|
||||
rShift += settings.shadows.cyanRed * shadowWeight;
|
||||
gShift += settings.shadows.magentaGreen * shadowWeight;
|
||||
bShift += settings.shadows.yellowBlue * shadowWeight;
|
||||
}
|
||||
|
||||
if (midtoneWeight > 0) {
|
||||
rShift += settings.midtones.cyanRed * midtoneWeight;
|
||||
gShift += settings.midtones.magentaGreen * midtoneWeight;
|
||||
bShift += settings.midtones.yellowBlue * midtoneWeight;
|
||||
}
|
||||
|
||||
if (highlightWeight > 0) {
|
||||
rShift += settings.highlights.cyanRed * highlightWeight;
|
||||
gShift += settings.highlights.magentaGreen * highlightWeight;
|
||||
bShift += settings.highlights.yellowBlue * highlightWeight;
|
||||
}
|
||||
|
||||
r = Math.max(0, Math.min(255, r + rShift));
|
||||
g = Math.max(0, Math.min(255, g + gShift));
|
||||
b = Math.max(0, Math.min(255, b + bShift));
|
||||
|
||||
if (settings.preserveLuminosity) {
|
||||
const newLuminance = getLuminance(r, g, b);
|
||||
if (newLuminance > 0) {
|
||||
const ratio = luminance / newLuminance;
|
||||
r = Math.max(0, Math.min(255, r * ratio));
|
||||
g = Math.max(0, Math.min(255, g * ratio));
|
||||
b = Math.max(0, Math.min(255, b * ratio));
|
||||
}
|
||||
}
|
||||
|
||||
resultData[i] = r;
|
||||
resultData[i + 1] = g;
|
||||
resultData[i + 2] = b;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
export interface ColorLookupSettings {
|
||||
lutData: Float32Array | null;
|
||||
lutSize: number;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOR_LOOKUP: ColorLookupSettings = {
|
||||
lutData: null,
|
||||
lutSize: 0,
|
||||
strength: 100,
|
||||
};
|
||||
|
||||
export function parseCubeLUT(content: string): { data: Float32Array; size: number } | null {
|
||||
const lines = content.split('\n');
|
||||
let size = 0;
|
||||
const data: number[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('#') || trimmed === '') continue;
|
||||
|
||||
if (trimmed.startsWith('LUT_3D_SIZE')) {
|
||||
const match = trimmed.match(/LUT_3D_SIZE\s+(\d+)/);
|
||||
if (match) {
|
||||
size = parseInt(match[1], 10);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('TITLE') || trimmed.startsWith('DOMAIN_')) continue;
|
||||
|
||||
const values = trimmed.split(/\s+/).map(parseFloat);
|
||||
if (values.length === 3 && values.every((v) => !isNaN(v))) {
|
||||
data.push(...values);
|
||||
}
|
||||
}
|
||||
|
||||
if (size === 0 || data.length !== size * size * size * 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { data: new Float32Array(data), size };
|
||||
}
|
||||
|
||||
export function parse3dlLUT(content: string): { data: Float32Array; size: number } | null {
|
||||
const lines = content.split('\n');
|
||||
const data: number[] = [];
|
||||
let size = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
||||
|
||||
const values = trimmed.split(/\s+/).map(parseFloat);
|
||||
|
||||
if (values.length === 1 && size === 0) {
|
||||
size = Math.round(Math.cbrt(values[0]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (values.length === 3 && values.every((v) => !isNaN(v))) {
|
||||
data.push(values[0] / 4095, values[1] / 4095, values[2] / 4095);
|
||||
}
|
||||
}
|
||||
|
||||
if (size === 0) {
|
||||
size = Math.round(Math.cbrt(data.length / 3));
|
||||
}
|
||||
|
||||
if (size === 0 || data.length !== size * size * size * 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { data: new Float32Array(data), size };
|
||||
}
|
||||
|
||||
function trilinearInterpolate(
|
||||
lutData: Float32Array,
|
||||
size: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): { r: number; g: number; b: number } {
|
||||
const rScaled = r * (size - 1);
|
||||
const gScaled = g * (size - 1);
|
||||
const bScaled = b * (size - 1);
|
||||
|
||||
const r0 = Math.floor(rScaled);
|
||||
const g0 = Math.floor(gScaled);
|
||||
const b0 = Math.floor(bScaled);
|
||||
|
||||
const r1 = Math.min(r0 + 1, size - 1);
|
||||
const g1 = Math.min(g0 + 1, size - 1);
|
||||
const b1 = Math.min(b0 + 1, size - 1);
|
||||
|
||||
const rFrac = rScaled - r0;
|
||||
const gFrac = gScaled - g0;
|
||||
const bFrac = bScaled - b0;
|
||||
|
||||
const getIndex = (ri: number, gi: number, bi: number) => (bi * size * size + gi * size + ri) * 3;
|
||||
|
||||
const c000 = getIndex(r0, g0, b0);
|
||||
const c100 = getIndex(r1, g0, b0);
|
||||
const c010 = getIndex(r0, g1, b0);
|
||||
const c110 = getIndex(r1, g1, b0);
|
||||
const c001 = getIndex(r0, g0, b1);
|
||||
const c101 = getIndex(r1, g0, b1);
|
||||
const c011 = getIndex(r0, g1, b1);
|
||||
const c111 = getIndex(r1, g1, b1);
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
const interpolate = (channel: number) => {
|
||||
const c00 = lerp(lutData[c000 + channel], lutData[c100 + channel], rFrac);
|
||||
const c01 = lerp(lutData[c001 + channel], lutData[c101 + channel], rFrac);
|
||||
const c10 = lerp(lutData[c010 + channel], lutData[c110 + channel], rFrac);
|
||||
const c11 = lerp(lutData[c011 + channel], lutData[c111 + channel], rFrac);
|
||||
|
||||
const c0 = lerp(c00, c10, gFrac);
|
||||
const c1 = lerp(c01, c11, gFrac);
|
||||
|
||||
return lerp(c0, c1, bFrac);
|
||||
};
|
||||
|
||||
return {
|
||||
r: interpolate(0),
|
||||
g: interpolate(1),
|
||||
b: interpolate(2),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyColorLookup(imageData: ImageData, settings: ColorLookupSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
if (!settings.lutData || settings.lutSize === 0) {
|
||||
resultData.set(data);
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
const strength = settings.strength / 100;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i] / 255;
|
||||
const g = data[i + 1] / 255;
|
||||
const b = data[i + 2] / 255;
|
||||
const a = data[i + 3];
|
||||
|
||||
const lutColor = trilinearInterpolate(settings.lutData, settings.lutSize, r, g, b);
|
||||
|
||||
resultData[i] = Math.max(0, Math.min(255, (r + (lutColor.r - r) * strength) * 255));
|
||||
resultData[i + 1] = Math.max(0, Math.min(255, (g + (lutColor.g - g) * strength) * 255));
|
||||
resultData[i + 2] = Math.max(0, Math.min(255, (b + (lutColor.b - b) * strength) * 255));
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function createIdentityLUT(size: number): Float32Array {
|
||||
const data = new Float32Array(size * size * size * 3);
|
||||
|
||||
for (let b = 0; b < size; b++) {
|
||||
for (let g = 0; g < size; g++) {
|
||||
for (let r = 0; r < size; r++) {
|
||||
const idx = (b * size * size + g * size + r) * 3;
|
||||
data[idx] = r / (size - 1);
|
||||
data[idx + 1] = g / (size - 1);
|
||||
data[idx + 2] = b / (size - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
export interface GradientStop {
|
||||
position: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GradientMapSettings {
|
||||
stops: GradientStop[];
|
||||
dither: boolean;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRADIENT_MAP: GradientMapSettings = {
|
||||
stops: [
|
||||
{ position: 0, color: '#000000' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
dither: false,
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
export const GRADIENT_MAP_PRESETS = {
|
||||
blackWhite: [
|
||||
{ position: 0, color: '#000000' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
sepiaTone: [
|
||||
{ position: 0, color: '#1a0f00' },
|
||||
{ position: 50, color: '#8b6914' },
|
||||
{ position: 100, color: '#ffe7b3' },
|
||||
],
|
||||
duotoneBlueOrange: [
|
||||
{ position: 0, color: '#001f4d' },
|
||||
{ position: 100, color: '#ff8c00' },
|
||||
],
|
||||
duotonePurpleTeal: [
|
||||
{ position: 0, color: '#2d1b4e' },
|
||||
{ position: 100, color: '#00d4aa' },
|
||||
],
|
||||
sunset: [
|
||||
{ position: 0, color: '#1a0533' },
|
||||
{ position: 33, color: '#6b1839' },
|
||||
{ position: 66, color: '#d44d1b' },
|
||||
{ position: 100, color: '#ffd700' },
|
||||
],
|
||||
coolBlue: [
|
||||
{ position: 0, color: '#000033' },
|
||||
{ position: 50, color: '#0066cc' },
|
||||
{ position: 100, color: '#99ccff' },
|
||||
],
|
||||
warmRed: [
|
||||
{ position: 0, color: '#1a0000' },
|
||||
{ position: 50, color: '#cc3300' },
|
||||
{ position: 100, color: '#ffcc99' },
|
||||
],
|
||||
greenForest: [
|
||||
{ position: 0, color: '#001a00' },
|
||||
{ position: 50, color: '#336600' },
|
||||
{ position: 100, color: '#99cc66' },
|
||||
],
|
||||
infrared: [
|
||||
{ position: 0, color: '#000000' },
|
||||
{ position: 25, color: '#330066' },
|
||||
{ position: 50, color: '#ff0066' },
|
||||
{ position: 75, color: '#ffcc00' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
thermal: [
|
||||
{ position: 0, color: '#000033' },
|
||||
{ position: 25, color: '#6600cc' },
|
||||
{ position: 50, color: '#ff0000' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
};
|
||||
|
||||
function parseColor(color: string): { r: number; g: number; b: number } {
|
||||
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (match) {
|
||||
return {
|
||||
r: parseInt(match[1], 16),
|
||||
g: parseInt(match[2], 16),
|
||||
b: parseInt(match[3], 16),
|
||||
};
|
||||
}
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
function interpolateGradient(
|
||||
stops: GradientStop[],
|
||||
position: number
|
||||
): { r: number; g: number; b: number } {
|
||||
if (stops.length === 0) return { r: 0, g: 0, b: 0 };
|
||||
if (stops.length === 1) return parseColor(stops[0].color);
|
||||
|
||||
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
||||
|
||||
if (position <= sortedStops[0].position) {
|
||||
return parseColor(sortedStops[0].color);
|
||||
}
|
||||
if (position >= sortedStops[sortedStops.length - 1].position) {
|
||||
return parseColor(sortedStops[sortedStops.length - 1].color);
|
||||
}
|
||||
|
||||
for (let i = 0; i < sortedStops.length - 1; i++) {
|
||||
const stop1 = sortedStops[i];
|
||||
const stop2 = sortedStops[i + 1];
|
||||
|
||||
if (position >= stop1.position && position <= stop2.position) {
|
||||
const t = (position - stop1.position) / (stop2.position - stop1.position);
|
||||
const c1 = parseColor(stop1.color);
|
||||
const c2 = parseColor(stop2.color);
|
||||
|
||||
return {
|
||||
r: Math.round(c1.r + (c2.r - c1.r) * t),
|
||||
g: Math.round(c1.g + (c2.g - c1.g) * t),
|
||||
b: Math.round(c1.b + (c2.b - c1.b) * t),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return parseColor(sortedStops[sortedStops.length - 1].color);
|
||||
}
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) / 255;
|
||||
}
|
||||
|
||||
export function applyGradientMap(imageData: ImageData, settings: GradientMapSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const lookupTable: Array<{ r: number; g: number; b: number }> = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let position = (i / 255) * 100;
|
||||
if (settings.reverse) {
|
||||
position = 100 - position;
|
||||
}
|
||||
lookupTable[i] = interpolateGradient(settings.stops, position);
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
let luminance = getLuminance(r, g, b);
|
||||
|
||||
if (settings.dither) {
|
||||
const noise = (Math.random() - 0.5) * (1 / 255);
|
||||
luminance = Math.max(0, Math.min(1, luminance + noise));
|
||||
}
|
||||
|
||||
const idx = Math.round(luminance * 255);
|
||||
const mappedColor = lookupTable[idx];
|
||||
|
||||
resultData[i] = mappedColor.r;
|
||||
resultData[i + 1] = mappedColor.g;
|
||||
resultData[i + 2] = mappedColor.b;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
export interface HistogramData {
|
||||
red: Uint32Array;
|
||||
green: Uint32Array;
|
||||
blue: Uint32Array;
|
||||
luminosity: Uint32Array;
|
||||
}
|
||||
|
||||
export interface HistogramStatistics {
|
||||
mean: number;
|
||||
stdDev: number;
|
||||
median: number;
|
||||
min: number;
|
||||
max: number;
|
||||
pixelCount: number;
|
||||
shadowsClipped: number;
|
||||
highlightsClipped: number;
|
||||
}
|
||||
|
||||
export interface HistogramResult {
|
||||
data: HistogramData;
|
||||
statistics: {
|
||||
red: HistogramStatistics;
|
||||
green: HistogramStatistics;
|
||||
blue: HistogramStatistics;
|
||||
luminosity: HistogramStatistics;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ColorInfo {
|
||||
rgb: { r: number; g: number; b: number };
|
||||
hsb: { h: number; s: number; b: number };
|
||||
hsl: { h: number; s: number; l: number };
|
||||
lab: { l: number; a: number; b: number };
|
||||
cmyk: { c: number; m: number; y: number; k: number };
|
||||
hex: string;
|
||||
}
|
||||
|
||||
function calculateStatistics(histogram: Uint32Array, totalPixels: number): HistogramStatistics {
|
||||
let sum = 0;
|
||||
let min = 255;
|
||||
let max = 0;
|
||||
let pixelCount = 0;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const count = histogram[i];
|
||||
if (count > 0) {
|
||||
sum += i * count;
|
||||
pixelCount += count;
|
||||
if (i < min) min = i;
|
||||
if (i > max) max = i;
|
||||
}
|
||||
}
|
||||
|
||||
const mean = pixelCount > 0 ? sum / pixelCount : 0;
|
||||
|
||||
let varianceSum = 0;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const count = histogram[i];
|
||||
if (count > 0) {
|
||||
varianceSum += count * Math.pow(i - mean, 2);
|
||||
}
|
||||
}
|
||||
const stdDev = pixelCount > 0 ? Math.sqrt(varianceSum / pixelCount) : 0;
|
||||
|
||||
let medianCount = 0;
|
||||
let median = 0;
|
||||
const halfCount = pixelCount / 2;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
medianCount += histogram[i];
|
||||
if (medianCount >= halfCount) {
|
||||
median = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const shadowsClipped = (histogram[0] / totalPixels) * 100;
|
||||
const highlightsClipped = (histogram[255] / totalPixels) * 100;
|
||||
|
||||
return {
|
||||
mean,
|
||||
stdDev,
|
||||
median,
|
||||
min: pixelCount > 0 ? min : 0,
|
||||
max: pixelCount > 0 ? max : 0,
|
||||
pixelCount,
|
||||
shadowsClipped,
|
||||
highlightsClipped,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateHistogram(imageData: ImageData): HistogramResult {
|
||||
const { data } = imageData;
|
||||
|
||||
const histogramData: HistogramData = {
|
||||
red: new Uint32Array(256),
|
||||
green: new Uint32Array(256),
|
||||
blue: new Uint32Array(256),
|
||||
luminosity: new Uint32Array(256),
|
||||
};
|
||||
|
||||
const totalPixels = data.length / 4;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
histogramData.red[r]++;
|
||||
histogramData.green[g]++;
|
||||
histogramData.blue[b]++;
|
||||
|
||||
const luminosity = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
|
||||
histogramData.luminosity[luminosity]++;
|
||||
}
|
||||
|
||||
return {
|
||||
data: histogramData,
|
||||
statistics: {
|
||||
red: calculateStatistics(histogramData.red, totalPixels),
|
||||
green: calculateStatistics(histogramData.green, totalPixels),
|
||||
blue: calculateStatistics(histogramData.blue, totalPixels),
|
||||
luminosity: calculateStatistics(histogramData.luminosity, totalPixels),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getColorInfo(r: number, g: number, b: number): ColorInfo {
|
||||
const rNorm = r / 255;
|
||||
const gNorm = g / 255;
|
||||
const bNorm = b / 255;
|
||||
|
||||
const max = Math.max(rNorm, gNorm, bNorm);
|
||||
const min = Math.min(rNorm, gNorm, bNorm);
|
||||
const delta = max - min;
|
||||
|
||||
let h = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === rNorm) {
|
||||
h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6;
|
||||
} else if (max === gNorm) {
|
||||
h = ((bNorm - rNorm) / delta + 2) / 6;
|
||||
} else {
|
||||
h = ((rNorm - gNorm) / delta + 4) / 6;
|
||||
}
|
||||
}
|
||||
|
||||
const l = (max + min) / 2;
|
||||
const sHsl = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||
|
||||
const sBrightness = max === 0 ? 0 : delta / max;
|
||||
|
||||
const k = 1 - max;
|
||||
const c = max === 0 ? 0 : (1 - rNorm - k) / (1 - k);
|
||||
const m = max === 0 ? 0 : (1 - gNorm - k) / (1 - k);
|
||||
const y = max === 0 ? 0 : (1 - bNorm - k) / (1 - k);
|
||||
|
||||
const xyzR = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
|
||||
const xyzG = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
|
||||
const xyzB = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
|
||||
|
||||
const x = (xyzR * 0.4124564 + xyzG * 0.3575761 + xyzB * 0.1804375) / 0.95047;
|
||||
const yVal = xyzR * 0.2126729 + xyzG * 0.7151522 + xyzB * 0.0721750;
|
||||
const z = (xyzR * 0.0193339 + xyzG * 0.1191920 + xyzB * 0.9503041) / 1.08883;
|
||||
|
||||
const f = (t: number) => t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116;
|
||||
|
||||
const labL = 116 * f(yVal) - 16;
|
||||
const labA = 500 * (f(x) - f(yVal));
|
||||
const labB = 200 * (f(yVal) - f(z));
|
||||
|
||||
const hex = '#' +
|
||||
r.toString(16).padStart(2, '0') +
|
||||
g.toString(16).padStart(2, '0') +
|
||||
b.toString(16).padStart(2, '0');
|
||||
|
||||
return {
|
||||
rgb: { r, g, b },
|
||||
hsb: {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(sBrightness * 100),
|
||||
b: Math.round(max * 100),
|
||||
},
|
||||
hsl: {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(sHsl * 100),
|
||||
l: Math.round(l * 100),
|
||||
},
|
||||
lab: {
|
||||
l: Math.round(labL),
|
||||
a: Math.round(labA),
|
||||
b: Math.round(labB),
|
||||
},
|
||||
cmyk: {
|
||||
c: Math.round(c * 100),
|
||||
m: Math.round(m * 100),
|
||||
y: Math.round(y * 100),
|
||||
k: Math.round(k * 100),
|
||||
},
|
||||
hex,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderHistogram(
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
||||
histogram: Uint32Array,
|
||||
color: string,
|
||||
width: number,
|
||||
height: number,
|
||||
logarithmic: boolean = false
|
||||
): void {
|
||||
const maxValue = Math.max(...histogram);
|
||||
if (maxValue === 0) return;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 0.7;
|
||||
|
||||
const barWidth = width / 256;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let normalizedValue: number;
|
||||
if (logarithmic && histogram[i] > 0) {
|
||||
normalizedValue = Math.log10(histogram[i] + 1) / Math.log10(maxValue + 1);
|
||||
} else {
|
||||
normalizedValue = histogram[i] / maxValue;
|
||||
}
|
||||
|
||||
const barHeight = normalizedValue * height;
|
||||
ctx.fillRect(i * barWidth, height - barHeight, barWidth, barHeight);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function autoLevels(imageData: ImageData, clipPercent: number = 0.1): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const histogram = calculateHistogram(imageData);
|
||||
const totalPixels = data.length / 4;
|
||||
const clipPixels = Math.round(totalPixels * (clipPercent / 100));
|
||||
|
||||
const findClipPoint = (hist: Uint32Array, fromStart: boolean): number => {
|
||||
let count = 0;
|
||||
if (fromStart) {
|
||||
for (let i = 0; i < 256; i++) {
|
||||
count += hist[i];
|
||||
if (count > clipPixels) return i;
|
||||
}
|
||||
return 0;
|
||||
} else {
|
||||
for (let i = 255; i >= 0; i--) {
|
||||
count += hist[i];
|
||||
if (count > clipPixels) return i;
|
||||
}
|
||||
return 255;
|
||||
}
|
||||
};
|
||||
|
||||
const channels = ['red', 'green', 'blue'] as const;
|
||||
const adjustments = channels.map((channel) => {
|
||||
const hist = histogram.data[channel];
|
||||
const inputBlack = findClipPoint(hist, true);
|
||||
const inputWhite = findClipPoint(hist, false);
|
||||
return { inputBlack, inputWhite };
|
||||
});
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const { inputBlack, inputWhite } = adjustments[c];
|
||||
const range = inputWhite - inputBlack || 1;
|
||||
const value = data[i + c];
|
||||
const adjusted = ((value - inputBlack) / range) * 255;
|
||||
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
|
||||
}
|
||||
resultData[i + 3] = data[i + 3];
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function autoContrast(imageData: ImageData): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
let minLum = 255;
|
||||
let maxLum = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const lum = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
|
||||
if (lum < minLum) minLum = lum;
|
||||
if (lum > maxLum) maxLum = lum;
|
||||
}
|
||||
|
||||
const range = maxLum - minLum || 1;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const adjusted = ((data[i + c] - minLum) / range) * 255;
|
||||
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
|
||||
}
|
||||
resultData[i + 3] = data[i + 3];
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
export type PhotoFilterPreset =
|
||||
| 'warming-85'
|
||||
| 'warming-81'
|
||||
| 'warming-lba'
|
||||
| 'cooling-80'
|
||||
| 'cooling-82'
|
||||
| 'cooling-lbb'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'cyan'
|
||||
| 'blue'
|
||||
| 'violet'
|
||||
| 'magenta'
|
||||
| 'sepia'
|
||||
| 'deep-red'
|
||||
| 'deep-blue'
|
||||
| 'deep-emerald'
|
||||
| 'deep-yellow'
|
||||
| 'underwater'
|
||||
| 'custom';
|
||||
|
||||
export interface PhotoFilterSettings {
|
||||
filter: PhotoFilterPreset;
|
||||
color: string;
|
||||
density: number;
|
||||
preserveLuminosity: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PHOTO_FILTER: PhotoFilterSettings = {
|
||||
filter: 'warming-85',
|
||||
color: '#ec8a00',
|
||||
density: 25,
|
||||
preserveLuminosity: true,
|
||||
};
|
||||
|
||||
export const PHOTO_FILTER_COLORS: Record<PhotoFilterPreset, string> = {
|
||||
'warming-85': '#ec8a00',
|
||||
'warming-81': '#ebb113',
|
||||
'warming-lba': '#fa9600',
|
||||
'cooling-80': '#006dff',
|
||||
'cooling-82': '#00b5ff',
|
||||
'cooling-lbb': '#005fcc',
|
||||
red: '#ea1a1a',
|
||||
orange: '#f28e00',
|
||||
yellow: '#f9d71c',
|
||||
green: '#1ab800',
|
||||
cyan: '#00e5e5',
|
||||
blue: '#0000ff',
|
||||
violet: '#8000ff',
|
||||
magenta: '#ea00ea',
|
||||
sepia: '#ac7a33',
|
||||
'deep-red': '#a10000',
|
||||
'deep-blue': '#000066',
|
||||
'deep-emerald': '#003d00',
|
||||
'deep-yellow': '#998c00',
|
||||
underwater: '#00c2b0',
|
||||
custom: '#ffffff',
|
||||
};
|
||||
|
||||
function parseColor(color: string): { r: number; g: number; b: number } {
|
||||
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (match) {
|
||||
return {
|
||||
r: parseInt(match[1], 16),
|
||||
g: parseInt(match[2], 16),
|
||||
b: parseInt(match[3], 16),
|
||||
};
|
||||
}
|
||||
return { r: 255, g: 255, b: 255 };
|
||||
}
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
return r * 0.299 + g * 0.587 + b * 0.114;
|
||||
}
|
||||
|
||||
export function applyPhotoFilter(imageData: ImageData, settings: PhotoFilterSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const filterColor = settings.filter === 'custom'
|
||||
? parseColor(settings.color)
|
||||
: parseColor(PHOTO_FILTER_COLORS[settings.filter]);
|
||||
|
||||
const density = settings.density / 100;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const originalLuminance = getLuminance(r, g, b);
|
||||
|
||||
let newR = r + (filterColor.r - r) * density;
|
||||
let newG = g + (filterColor.g - g) * density;
|
||||
let newB = b + (filterColor.b - b) * density;
|
||||
|
||||
if (settings.preserveLuminosity) {
|
||||
const newLuminance = getLuminance(newR, newG, newB);
|
||||
if (newLuminance > 0) {
|
||||
const ratio = originalLuminance / newLuminance;
|
||||
newR *= ratio;
|
||||
newG *= ratio;
|
||||
newB *= ratio;
|
||||
}
|
||||
}
|
||||
|
||||
resultData[i] = Math.max(0, Math.min(255, newR));
|
||||
resultData[i + 1] = Math.max(0, Math.min(255, newG));
|
||||
resultData[i + 2] = Math.max(0, Math.min(255, newB));
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
export interface PosterizeSettings {
|
||||
levels: number;
|
||||
}
|
||||
|
||||
export interface ThresholdSettings {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_POSTERIZE: PosterizeSettings = {
|
||||
levels: 4,
|
||||
};
|
||||
|
||||
export const DEFAULT_THRESHOLD: ThresholdSettings = {
|
||||
level: 128,
|
||||
};
|
||||
|
||||
export function applyPosterize(imageData: ImageData, settings: PosterizeSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const levels = Math.max(2, Math.min(255, Math.round(settings.levels)));
|
||||
const step = 255 / (levels - 1);
|
||||
const divisor = 256 / levels;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
resultData[i] = Math.round(Math.floor(r / divisor) * step);
|
||||
resultData[i + 1] = Math.round(Math.floor(g / divisor) * step);
|
||||
resultData[i + 2] = Math.round(Math.floor(b / divisor) * step);
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function applyThreshold(imageData: ImageData, settings: ThresholdSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const level = Math.max(0, Math.min(255, settings.level));
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const luminance = r * 0.299 + g * 0.587 + b * 0.114;
|
||||
const value = luminance >= level ? 255 : 0;
|
||||
|
||||
resultData[i] = value;
|
||||
resultData[i + 1] = value;
|
||||
resultData[i + 2] = value;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function applyAdaptiveThreshold(
|
||||
imageData: ImageData,
|
||||
blockSize: number = 11,
|
||||
constant: number = 2
|
||||
): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const grayData = new Uint8Array(width * height);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const idx = i / 4;
|
||||
grayData[idx] = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
|
||||
}
|
||||
|
||||
const halfBlock = Math.floor(blockSize / 2);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let by = -halfBlock; by <= halfBlock; by++) {
|
||||
for (let bx = -halfBlock; bx <= halfBlock; bx++) {
|
||||
const nx = Math.min(Math.max(x + bx, 0), width - 1);
|
||||
const ny = Math.min(Math.max(y + by, 0), height - 1);
|
||||
sum += grayData[ny * width + nx];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
const mean = sum / count;
|
||||
const threshold = mean - constant;
|
||||
const pixelIdx = y * width + x;
|
||||
const value = grayData[pixelIdx] > threshold ? 255 : 0;
|
||||
|
||||
const i = pixelIdx * 4;
|
||||
resultData[i] = value;
|
||||
resultData[i + 1] = value;
|
||||
resultData[i + 2] = value;
|
||||
resultData[i + 3] = data[i + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
export type SelectiveColorRange =
|
||||
| 'reds'
|
||||
| 'yellows'
|
||||
| 'greens'
|
||||
| 'cyans'
|
||||
| 'blues'
|
||||
| 'magentas'
|
||||
| 'whites'
|
||||
| 'neutrals'
|
||||
| 'blacks';
|
||||
|
||||
export interface SelectiveColorAdjustment {
|
||||
cyan: number;
|
||||
magenta: number;
|
||||
yellow: number;
|
||||
black: number;
|
||||
}
|
||||
|
||||
export interface SelectiveColorSettings {
|
||||
reds: SelectiveColorAdjustment;
|
||||
yellows: SelectiveColorAdjustment;
|
||||
greens: SelectiveColorAdjustment;
|
||||
cyans: SelectiveColorAdjustment;
|
||||
blues: SelectiveColorAdjustment;
|
||||
magentas: SelectiveColorAdjustment;
|
||||
whites: SelectiveColorAdjustment;
|
||||
neutrals: SelectiveColorAdjustment;
|
||||
blacks: SelectiveColorAdjustment;
|
||||
method: 'relative' | 'absolute';
|
||||
}
|
||||
|
||||
const DEFAULT_ADJUSTMENT: SelectiveColorAdjustment = {
|
||||
cyan: 0,
|
||||
magenta: 0,
|
||||
yellow: 0,
|
||||
black: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_SELECTIVE_COLOR: SelectiveColorSettings = {
|
||||
reds: { ...DEFAULT_ADJUSTMENT },
|
||||
yellows: { ...DEFAULT_ADJUSTMENT },
|
||||
greens: { ...DEFAULT_ADJUSTMENT },
|
||||
cyans: { ...DEFAULT_ADJUSTMENT },
|
||||
blues: { ...DEFAULT_ADJUSTMENT },
|
||||
magentas: { ...DEFAULT_ADJUSTMENT },
|
||||
whites: { ...DEFAULT_ADJUSTMENT },
|
||||
neutrals: { ...DEFAULT_ADJUSTMENT },
|
||||
blacks: { ...DEFAULT_ADJUSTMENT },
|
||||
method: 'relative',
|
||||
};
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
let h: number;
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function getColorRangeWeight(r: number, g: number, b: number, range: SelectiveColorRange): number {
|
||||
const { h, s, l } = rgbToHsl(r, g, b);
|
||||
const hue = h * 360;
|
||||
|
||||
switch (range) {
|
||||
case 'reds':
|
||||
if (s < 0.1) return 0;
|
||||
if ((hue >= 345 || hue <= 15)) return s;
|
||||
if (hue > 15 && hue <= 45) return s * (1 - (hue - 15) / 30);
|
||||
if (hue >= 315 && hue < 345) return s * ((hue - 315) / 30);
|
||||
return 0;
|
||||
|
||||
case 'yellows':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 45 && hue <= 75) return s;
|
||||
if (hue > 15 && hue < 45) return s * ((hue - 15) / 30);
|
||||
if (hue > 75 && hue <= 105) return s * (1 - (hue - 75) / 30);
|
||||
return 0;
|
||||
|
||||
case 'greens':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 105 && hue <= 135) return s;
|
||||
if (hue > 75 && hue < 105) return s * ((hue - 75) / 30);
|
||||
if (hue > 135 && hue <= 165) return s * (1 - (hue - 135) / 30);
|
||||
return 0;
|
||||
|
||||
case 'cyans':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 165 && hue <= 195) return s;
|
||||
if (hue > 135 && hue < 165) return s * ((hue - 135) / 30);
|
||||
if (hue > 195 && hue <= 225) return s * (1 - (hue - 195) / 30);
|
||||
return 0;
|
||||
|
||||
case 'blues':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 225 && hue <= 255) return s;
|
||||
if (hue > 195 && hue < 225) return s * ((hue - 195) / 30);
|
||||
if (hue > 255 && hue <= 285) return s * (1 - (hue - 255) / 30);
|
||||
return 0;
|
||||
|
||||
case 'magentas':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 285 && hue <= 315) return s;
|
||||
if (hue > 255 && hue < 285) return s * ((hue - 255) / 30);
|
||||
if (hue > 315 && hue <= 345) return s * (1 - (hue - 315) / 30);
|
||||
return 0;
|
||||
|
||||
case 'whites':
|
||||
if (l >= 0.8) return (l - 0.8) / 0.2;
|
||||
return 0;
|
||||
|
||||
case 'blacks':
|
||||
if (l <= 0.2) return (0.2 - l) / 0.2;
|
||||
return 0;
|
||||
|
||||
case 'neutrals':
|
||||
if (s < 0.2 && l > 0.2 && l < 0.8) {
|
||||
return (0.2 - s) / 0.2 * Math.min((l - 0.2) / 0.3, (0.8 - l) / 0.3, 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToCmyk(r: number, g: number, b: number): { c: number; m: number; y: number; k: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const k = 1 - Math.max(r, g, b);
|
||||
if (k === 1) {
|
||||
return { c: 0, m: 0, y: 0, k: 1 };
|
||||
}
|
||||
|
||||
const c = (1 - r - k) / (1 - k);
|
||||
const m = (1 - g - k) / (1 - k);
|
||||
const y = (1 - b - k) / (1 - k);
|
||||
|
||||
return { c, m, y, k };
|
||||
}
|
||||
|
||||
function cmykToRgb(c: number, m: number, y: number, k: number): { r: number; g: number; b: number } {
|
||||
const r = 255 * (1 - c) * (1 - k);
|
||||
const g = 255 * (1 - m) * (1 - k);
|
||||
const b = 255 * (1 - y) * (1 - k);
|
||||
|
||||
return {
|
||||
r: Math.max(0, Math.min(255, Math.round(r))),
|
||||
g: Math.max(0, Math.min(255, Math.round(g))),
|
||||
b: Math.max(0, Math.min(255, Math.round(b))),
|
||||
};
|
||||
}
|
||||
|
||||
export function applySelectiveColor(imageData: ImageData, settings: SelectiveColorSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const ranges: SelectiveColorRange[] = [
|
||||
'reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'whites', 'neutrals', 'blacks'
|
||||
];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
let { c, m, y, k } = rgbToCmyk(r, g, b);
|
||||
|
||||
for (const range of ranges) {
|
||||
const weight = getColorRangeWeight(r, g, b, range);
|
||||
if (weight <= 0) continue;
|
||||
|
||||
const adj = settings[range];
|
||||
|
||||
if (settings.method === 'relative') {
|
||||
c = c + (adj.cyan / 100) * c * weight;
|
||||
m = m + (adj.magenta / 100) * m * weight;
|
||||
y = y + (adj.yellow / 100) * y * weight;
|
||||
k = k + (adj.black / 100) * k * weight;
|
||||
} else {
|
||||
c = c + (adj.cyan / 100) * weight;
|
||||
m = m + (adj.magenta / 100) * weight;
|
||||
y = y + (adj.yellow / 100) * weight;
|
||||
k = k + (adj.black / 100) * weight;
|
||||
}
|
||||
}
|
||||
|
||||
c = Math.max(0, Math.min(1, c));
|
||||
m = Math.max(0, Math.min(1, m));
|
||||
y = Math.max(0, Math.min(1, y));
|
||||
k = Math.max(0, Math.min(1, k));
|
||||
|
||||
const rgb = cmykToRgb(c, m, y, k);
|
||||
|
||||
resultData[i] = rgb.r;
|
||||
resultData[i + 1] = rgb.g;
|
||||
resultData[i + 2] = rgb.b;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseProject } from './services/project-schema';
|
||||
import { migrateProject, CURRENT_VERSION } from './services/project-migration';
|
||||
|
||||
// ── App smoke tests ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// These tests exercise the integration seam between the project schema,
|
||||
// migration utilities, and the store to confirm the whole pipeline is wired up
|
||||
// and importing correctly.
|
||||
|
||||
describe('OpenReel Image – baseline smoke tests', () => {
|
||||
// Schema is importable.
|
||||
it('project schema module is importable', () => {
|
||||
expect(typeof parseProject).toBe('function');
|
||||
});
|
||||
|
||||
// Migration is importable and exposes the current version constant.
|
||||
it('migration module exposes CURRENT_VERSION', () => {
|
||||
expect(typeof CURRENT_VERSION).toBe('number');
|
||||
expect(CURRENT_VERSION).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// A minimal valid project document passes schema validation.
|
||||
it('validates a minimal valid project', () => {
|
||||
const baseLayer = {
|
||||
id: 'l1',
|
||||
name: 'Layer',
|
||||
type: 'text' as const,
|
||||
visible: true,
|
||||
locked: false,
|
||||
transform: {
|
||||
x: 0, y: 0, width: 200, height: 50, rotation: 0,
|
||||
scaleX: 1, scaleY: 1, skewX: 0, skewY: 0, opacity: 1,
|
||||
},
|
||||
blendMode: { mode: 'normal' as const },
|
||||
shadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 0, offsetY: 4 },
|
||||
innerShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 2, offsetY: 2 },
|
||||
stroke: { enabled: false, color: '#000000', width: 1, style: 'solid' as const },
|
||||
glow: { enabled: false, color: '#ffffff', blur: 20, intensity: 1 },
|
||||
filters: {
|
||||
brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0,
|
||||
vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0,
|
||||
blurType: 'gaussian' as const, blurAngle: 0, sharpen: 0, vignette: 0,
|
||||
grain: 0, sepia: 0, invert: 0,
|
||||
},
|
||||
parentId: null,
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
mask: null,
|
||||
clippingMask: false,
|
||||
levels: {
|
||||
enabled: false,
|
||||
master: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
red: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
green: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
blue: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
},
|
||||
curves: {
|
||||
enabled: false,
|
||||
master: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
red: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
green: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
blue: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
},
|
||||
colorBalance: {
|
||||
enabled: false,
|
||||
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
preserveLuminosity: true,
|
||||
},
|
||||
selectiveColor: {
|
||||
enabled: false, method: 'relative' as const,
|
||||
reds: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
yellows: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
greens: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
cyans: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
blues: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
magentas: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
whites: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
neutrals: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
blacks: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
},
|
||||
blackWhite: {
|
||||
enabled: false, reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20,
|
||||
magentas: 80, tintEnabled: false, tintHue: 35, tintSaturation: 25,
|
||||
},
|
||||
photoFilter: {
|
||||
enabled: false, filter: 'warming-85' as const, color: '#ec8a00',
|
||||
density: 25, preserveLuminosity: true,
|
||||
},
|
||||
channelMixer: {
|
||||
enabled: false, monochrome: false,
|
||||
red: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
},
|
||||
gradientMap: {
|
||||
enabled: false,
|
||||
stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }],
|
||||
reverse: false, dither: false,
|
||||
},
|
||||
posterize: { enabled: false, levels: 4 },
|
||||
threshold: { enabled: false, level: 128 },
|
||||
content: 'Hello',
|
||||
style: {
|
||||
fontFamily: 'Inter', fontSize: 24, fontWeight: 400,
|
||||
fontStyle: 'normal' as const, textDecoration: 'none' as const,
|
||||
textAlign: 'left' as const, verticalAlign: 'top' as const,
|
||||
lineHeight: 1.4, letterSpacing: 0, fillType: 'solid' as const,
|
||||
color: '#ffffff', gradient: null, strokeColor: null, strokeWidth: 0,
|
||||
backgroundColor: null, backgroundPadding: 8, backgroundRadius: 4,
|
||||
textShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 4, offsetX: 0, offsetY: 2 },
|
||||
},
|
||||
autoSize: true,
|
||||
};
|
||||
|
||||
const validProject = {
|
||||
id: 'p1',
|
||||
name: 'Smoke Test',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
artboards: [
|
||||
{
|
||||
id: 'ab1',
|
||||
name: 'Artboard 1',
|
||||
size: { width: 1080, height: 1080 },
|
||||
background: { type: 'color', color: '#ffffff' },
|
||||
layerIds: ['l1'],
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
layers: { l1: baseLayer },
|
||||
assets: {},
|
||||
activeArtboardId: 'ab1',
|
||||
};
|
||||
|
||||
const result = parseProject(validProject);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
// An invalid document is rejected.
|
||||
it('rejects an invalid project document', () => {
|
||||
const result = parseProject({ id: 42, broken: true });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
// Migration promotes a v0 document to v1.
|
||||
it('migrates a v0 project to v1', () => {
|
||||
const v0 = {
|
||||
id: 'old',
|
||||
name: 'Legacy',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
artboards: [{ id: 'ab-old', name: 'Page 1' }],
|
||||
layers: {},
|
||||
assets: {},
|
||||
};
|
||||
|
||||
const migrated = migrateProject(v0 as Record<string, unknown>);
|
||||
expect(migrated.version).toBe(1);
|
||||
expect(migrated.activeArtboardId).toBe('ab-old');
|
||||
});
|
||||
|
||||
// A project that already has version 1 is returned unchanged.
|
||||
it('does not re-migrate a current-version project', () => {
|
||||
const v1 = {
|
||||
id: 'current',
|
||||
name: 'New',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
version: 1,
|
||||
artboards: [],
|
||||
layers: {},
|
||||
assets: {},
|
||||
activeArtboardId: null,
|
||||
};
|
||||
|
||||
const migrated = migrateProject(v1 as Record<string, unknown>);
|
||||
expect(migrated.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { useState, lazy, Suspense } from 'react';
|
||||
import { Toolbar } from './toolbar/Toolbar';
|
||||
import { LeftPanel } from './panels/LeftPanel';
|
||||
import { Canvas } from './canvas/Canvas';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { LayerPanel } from './layers/LayerPanel';
|
||||
import { HistoryPanel } from './panels/HistoryPanel';
|
||||
import { GuidePanel } from './panels/GuidePanel';
|
||||
import { PagesBar } from './pages/PagesBar';
|
||||
import { useUIStore } from '../../stores/ui-store';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
import { Layers, History, Ruler } from 'lucide-react';
|
||||
|
||||
const ExportDialog = lazy(() => import('./ExportDialog').then(m => ({ default: m.ExportDialog })));
|
||||
|
||||
type BottomTab = 'layers' | 'history' | 'guides';
|
||||
|
||||
export function EditorInterface() {
|
||||
const { isPanelCollapsed, isInspectorCollapsed, isExportDialogOpen, closeExportDialog } = useUIStore();
|
||||
const { project } = useProjectStore();
|
||||
const [bottomTab, setBottomTab] = useState<BottomTab>('layers');
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">No project loaded</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background overflow-hidden">
|
||||
<Toolbar />
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{!isPanelCollapsed && (
|
||||
<div className="w-72 border-r border-border flex flex-col bg-card">
|
||||
<LeftPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<Canvas />
|
||||
</div>
|
||||
<PagesBar />
|
||||
</div>
|
||||
|
||||
{!isInspectorCollapsed && (
|
||||
<div className="w-72 border-l border-border flex flex-col bg-card">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Inspector />
|
||||
</div>
|
||||
<div className="h-64 border-t border-border flex flex-col">
|
||||
<div className="flex border-b border-border">
|
||||
<button
|
||||
onClick={() => setBottomTab('layers')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
|
||||
bottomTab === 'layers'
|
||||
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
<Layers size={14} />
|
||||
Layers
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('guides')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
|
||||
bottomTab === 'guides'
|
||||
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
<Ruler size={14} />
|
||||
Guides
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBottomTab('history')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
|
||||
bottomTab === 'history'
|
||||
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
<History size={14} />
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{bottomTab === 'layers' && <LayerPanel />}
|
||||
{bottomTab === 'guides' && <GuidePanel />}
|
||||
{bottomTab === 'history' && <HistoryPanel />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExportDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<ExportDialog open={isExportDialogOpen} onClose={closeExportDialog} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,626 +0,0 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Download, FileImage, Loader2, Link2, Link2Off, Printer, Instagram, Youtube, Twitter, Linkedin, Facebook, Image } from 'lucide-react';
|
||||
import { Dialog, DialogFooter } from '../ui/Dialog';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
import { useUIStore } from '../../stores/ui-store';
|
||||
import {
|
||||
exportProject,
|
||||
downloadBlob,
|
||||
getExportFilename,
|
||||
type ExportFormat,
|
||||
type ExportQuality,
|
||||
type ExportOptions,
|
||||
} from '../../services/export-service';
|
||||
|
||||
interface ExportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type FormatInfo = {
|
||||
id: ExportFormat;
|
||||
name: string;
|
||||
description: string;
|
||||
supportsTransparency: boolean;
|
||||
supportsQuality: boolean;
|
||||
};
|
||||
|
||||
const FORMATS: FormatInfo[] = [
|
||||
{ id: 'png', name: 'PNG', description: 'Lossless, best for graphics', supportsTransparency: true, supportsQuality: false },
|
||||
{ id: 'jpg', name: 'JPG', description: 'Smaller size, photos', supportsTransparency: false, supportsQuality: true },
|
||||
{ id: 'webp', name: 'WebP', description: 'Modern, best compression', supportsTransparency: true, supportsQuality: true },
|
||||
];
|
||||
|
||||
const QUALITY_PRESETS: { id: ExportQuality; name: string; value: number }[] = [
|
||||
{ id: 'low', name: 'Low', value: 60 },
|
||||
{ id: 'medium', name: 'Medium', value: 80 },
|
||||
{ id: 'high', name: 'High', value: 92 },
|
||||
{ id: 'max', name: 'Maximum', value: 100 },
|
||||
];
|
||||
|
||||
const SCALE_OPTIONS = [
|
||||
{ value: 0.5, label: '0.5x' },
|
||||
{ value: 1, label: '1x' },
|
||||
{ value: 2, label: '2x' },
|
||||
{ value: 3, label: '3x' },
|
||||
{ value: 4, label: '4x' },
|
||||
];
|
||||
|
||||
const DPI_OPTIONS = [
|
||||
{ value: 72, label: '72 DPI', description: 'Screen' },
|
||||
{ value: 150, label: '150 DPI', description: 'Web print' },
|
||||
{ value: 300, label: '300 DPI', description: 'Print' },
|
||||
{ value: 600, label: '600 DPI', description: 'High quality' },
|
||||
];
|
||||
|
||||
type PlatformPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
format: ExportFormat;
|
||||
quality: ExportQuality;
|
||||
maxFileSize?: string;
|
||||
recommendedSize?: { width: number; height: number };
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PLATFORM_PRESETS: PlatformPreset[] = [
|
||||
{
|
||||
id: 'instagram-post',
|
||||
name: 'Instagram Post',
|
||||
icon: Instagram,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1080, height: 1080 },
|
||||
description: 'Square post, max 30MB',
|
||||
},
|
||||
{
|
||||
id: 'instagram-story',
|
||||
name: 'Instagram Story',
|
||||
icon: Instagram,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1080, height: 1920 },
|
||||
description: '9:16 vertical',
|
||||
},
|
||||
{
|
||||
id: 'youtube-thumbnail',
|
||||
name: 'YouTube Thumbnail',
|
||||
icon: Youtube,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
maxFileSize: '2MB',
|
||||
recommendedSize: { width: 1280, height: 720 },
|
||||
description: '16:9, under 2MB',
|
||||
},
|
||||
{
|
||||
id: 'twitter-post',
|
||||
name: 'Twitter/X Post',
|
||||
icon: Twitter,
|
||||
format: 'png',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1200, height: 675 },
|
||||
description: '16:9 landscape',
|
||||
},
|
||||
{
|
||||
id: 'facebook-post',
|
||||
name: 'Facebook Post',
|
||||
icon: Facebook,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1200, height: 630 },
|
||||
description: '1.91:1 ratio',
|
||||
},
|
||||
{
|
||||
id: 'linkedin-post',
|
||||
name: 'LinkedIn Post',
|
||||
icon: Linkedin,
|
||||
format: 'png',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1200, height: 627 },
|
||||
description: 'Professional feed',
|
||||
},
|
||||
{
|
||||
id: 'web-optimized',
|
||||
name: 'Web Optimized',
|
||||
icon: Image,
|
||||
format: 'webp',
|
||||
quality: 'medium',
|
||||
description: 'Smallest file size',
|
||||
},
|
||||
{
|
||||
id: 'print-ready',
|
||||
name: 'Print Ready',
|
||||
icon: Printer,
|
||||
format: 'png',
|
||||
quality: 'max',
|
||||
description: 'Highest quality PNG',
|
||||
},
|
||||
];
|
||||
|
||||
type SizeMode = 'scale' | 'custom' | 'dpi';
|
||||
|
||||
export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
const { project, selectedArtboardId } = useProjectStore();
|
||||
const { showNotification } = useUIStore();
|
||||
|
||||
const [format, setFormat] = useState<ExportFormat>('png');
|
||||
const [quality, setQuality] = useState<ExportQuality>('high');
|
||||
const [scale, setScale] = useState(1);
|
||||
const [sizeMode, setSizeMode] = useState<SizeMode>('scale');
|
||||
const [selectedPreset, setSelectedPreset] = useState<string | null>(null);
|
||||
const [customWidth, setCustomWidth] = useState(0);
|
||||
const [customHeight, setCustomHeight] = useState(0);
|
||||
const [dpi, setDpi] = useState(72);
|
||||
const [lockAspectRatio, setLockAspectRatio] = useState(true);
|
||||
const [background, setBackground] = useState<'include' | 'transparent'>('include');
|
||||
const [exportAll, setExportAll] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [progressMessage, setProgressMessage] = useState('');
|
||||
|
||||
const currentFormat = FORMATS.find((f) => f.id === format)!;
|
||||
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
|
||||
|
||||
const effectiveScale = useMemo(() => {
|
||||
if (!artboard) return 1;
|
||||
if (sizeMode === 'scale') return scale;
|
||||
if (sizeMode === 'custom' && customWidth > 0) {
|
||||
return customWidth / artboard.size.width;
|
||||
}
|
||||
if (sizeMode === 'dpi') {
|
||||
return dpi / 72;
|
||||
}
|
||||
return 1;
|
||||
}, [artboard, sizeMode, scale, customWidth, dpi]);
|
||||
|
||||
const dimensions = useMemo(() => {
|
||||
if (!artboard) return null;
|
||||
if (sizeMode === 'custom') {
|
||||
return { width: customWidth || artboard.size.width, height: customHeight || artboard.size.height };
|
||||
}
|
||||
return {
|
||||
width: Math.round(artboard.size.width * effectiveScale),
|
||||
height: Math.round(artboard.size.height * effectiveScale),
|
||||
};
|
||||
}, [artboard, sizeMode, effectiveScale, customWidth, customHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (artboard) {
|
||||
setCustomWidth(artboard.size.width);
|
||||
setCustomHeight(artboard.size.height);
|
||||
}
|
||||
}, [artboard?.id]);
|
||||
|
||||
const handleCustomWidthChange = (newWidth: number) => {
|
||||
setCustomWidth(newWidth);
|
||||
if (lockAspectRatio && artboard && newWidth > 0) {
|
||||
const aspectRatio = artboard.size.width / artboard.size.height;
|
||||
setCustomHeight(Math.round(newWidth / aspectRatio));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomHeightChange = (newHeight: number) => {
|
||||
setCustomHeight(newHeight);
|
||||
if (lockAspectRatio && artboard && newHeight > 0) {
|
||||
const aspectRatio = artboard.size.width / artboard.size.height;
|
||||
setCustomWidth(Math.round(newHeight * aspectRatio));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: PlatformPreset) => {
|
||||
setSelectedPreset(preset.id);
|
||||
setFormat(preset.format);
|
||||
setQuality(preset.quality);
|
||||
|
||||
if (preset.recommendedSize && artboard) {
|
||||
const artboardRatio = artboard.size.width / artboard.size.height;
|
||||
const presetRatio = preset.recommendedSize.width / preset.recommendedSize.height;
|
||||
const ratioMatch = Math.abs(artboardRatio - presetRatio) < 0.1;
|
||||
|
||||
if (ratioMatch) {
|
||||
const targetScale = preset.recommendedSize.width / artboard.size.width;
|
||||
if (targetScale <= 4 && targetScale >= 0.5) {
|
||||
setScale(targetScale);
|
||||
setSizeMode('scale');
|
||||
} else {
|
||||
setSizeMode('custom');
|
||||
setCustomWidth(preset.recommendedSize.width);
|
||||
setCustomHeight(preset.recommendedSize.height);
|
||||
setLockAspectRatio(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearPreset = () => {
|
||||
setSelectedPreset(null);
|
||||
};
|
||||
|
||||
const printDimensions = useMemo(() => {
|
||||
if (!dimensions) return null;
|
||||
const inches = {
|
||||
width: (dimensions.width / dpi).toFixed(2),
|
||||
height: (dimensions.height / dpi).toFixed(2),
|
||||
};
|
||||
const cm = {
|
||||
width: ((dimensions.width / dpi) * 2.54).toFixed(2),
|
||||
height: ((dimensions.height / dpi) * 2.54).toFixed(2),
|
||||
};
|
||||
return { inches, cm };
|
||||
}, [dimensions, dpi]);
|
||||
|
||||
const estimatedSize = useMemo(() => {
|
||||
if (!dimensions) return null;
|
||||
const pixels = dimensions.width * dimensions.height;
|
||||
const bytesPerPixel = format === 'png' ? 3 : format === 'jpg' ? 0.5 : 0.4;
|
||||
const qualityMultiplier = QUALITY_PRESETS.find((q) => q.id === quality)?.value ?? 80;
|
||||
const estimated = pixels * bytesPerPixel * (qualityMultiplier / 100);
|
||||
|
||||
if (estimated > 1024 * 1024) {
|
||||
return `~${(estimated / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `~${Math.round(estimated / 1024)} KB`;
|
||||
}, [dimensions, format, quality]);
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!project) return;
|
||||
|
||||
setIsExporting(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const options: ExportOptions = {
|
||||
format,
|
||||
quality,
|
||||
scale: effectiveScale,
|
||||
background: currentFormat.supportsTransparency ? background : 'include',
|
||||
artboardIds: exportAll ? undefined : selectedArtboardId ? [selectedArtboardId] : undefined,
|
||||
};
|
||||
|
||||
const blobs = await exportProject(project, options, (p, msg) => {
|
||||
setProgress(p);
|
||||
setProgressMessage(msg);
|
||||
});
|
||||
|
||||
const artboards = exportAll
|
||||
? project.artboards
|
||||
: project.artboards.filter((a) => a.id === selectedArtboardId);
|
||||
|
||||
blobs.forEach((blob, index) => {
|
||||
const artboardName = artboards[index]?.name ?? `artboard-${index + 1}`;
|
||||
const filename = getExportFilename(project.name, artboardName, format);
|
||||
downloadBlob(blob, filename);
|
||||
});
|
||||
|
||||
showNotification('success', `Exported ${blobs.length} artboard${blobs.length > 1 ? 's' : ''}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showNotification('error', 'Export failed. Please try again.');
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (!project || !artboard) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Export Image"
|
||||
description="Choose format and quality settings"
|
||||
maxWidth="md"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Quick Presets
|
||||
</label>
|
||||
{selectedPreset && (
|
||||
<button
|
||||
onClick={clearPreset}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{PLATFORM_PRESETS.map((preset) => {
|
||||
const Icon = preset.icon;
|
||||
const isSelected = selectedPreset === preset.id;
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
className={`p-2 rounded-lg border text-center transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} className={`mx-auto mb-1 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span className="block text-[10px] font-medium truncate">{preset.name}</span>
|
||||
<span className="block text-[8px] text-muted-foreground truncate">{preset.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Format
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{FORMATS.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFormat(f.id)}
|
||||
className={`p-3 rounded-lg border text-left transition-all ${
|
||||
format === f.id
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileImage size={16} className={format === f.id ? 'text-primary' : 'text-muted-foreground'} />
|
||||
<span className="font-medium text-sm">{f.name}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{f.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentFormat.supportsQuality && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Quality
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{QUALITY_PRESETS.map((q) => (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setQuality(q.id)}
|
||||
className={`px-3 py-2 rounded-lg border text-center transition-all ${
|
||||
quality === q.id
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{q.name}</span>
|
||||
<span className="block text-[10px] text-muted-foreground">{q.value}%</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Size
|
||||
</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => setSizeMode('scale')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
|
||||
sizeMode === 'scale'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Scale
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSizeMode('custom')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
|
||||
sizeMode === 'custom'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSizeMode('dpi')}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all flex items-center justify-center gap-1.5 ${
|
||||
sizeMode === 'dpi'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<Printer size={14} />
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sizeMode === 'scale' && (
|
||||
<div className="flex gap-2">
|
||||
{SCALE_OPTIONS.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
onClick={() => setScale(s.value)}
|
||||
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
|
||||
scale === s.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sizeMode === 'custom' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-muted-foreground mb-1">Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customWidth}
|
||||
onChange={(e) => handleCustomWidthChange(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
min={1}
|
||||
max={16384}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLockAspectRatio(!lockAspectRatio)}
|
||||
className={`mt-5 p-2 rounded-lg transition-colors ${
|
||||
lockAspectRatio ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
title={lockAspectRatio ? 'Unlock aspect ratio' : 'Lock aspect ratio'}
|
||||
>
|
||||
{lockAspectRatio ? <Link2 size={16} /> : <Link2Off size={16} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label className="block text-[10px] text-muted-foreground mb-1">Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customHeight}
|
||||
onChange={(e) => handleCustomHeightChange(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 text-sm bg-background border border-border rounded-lg focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
min={1}
|
||||
max={16384}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sizeMode === 'dpi' && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{DPI_OPTIONS.map((d) => (
|
||||
<button
|
||||
key={d.value}
|
||||
onClick={() => setDpi(d.value)}
|
||||
className={`px-2 py-2 rounded-lg border text-center transition-all ${
|
||||
dpi === d.value
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<span className="block text-sm font-medium">{d.value}</span>
|
||||
<span className="block text-[9px] text-muted-foreground">{d.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{printDimensions && (
|
||||
<div className="p-3 bg-secondary/30 rounded-lg text-xs text-muted-foreground">
|
||||
<p>Print size at {dpi} DPI:</p>
|
||||
<p className="font-medium text-foreground mt-1">
|
||||
{printDimensions.inches.width}" × {printDimensions.inches.height}" ({printDimensions.cm.width} × {printDimensions.cm.height} cm)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentFormat.supportsTransparency && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Background
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setBackground('include')}
|
||||
className={`px-3 py-2.5 rounded-lg border text-sm font-medium transition-all ${
|
||||
background === 'include'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Include Background
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBackground('transparent')}
|
||||
className={`px-3 py-2.5 rounded-lg border text-sm font-medium transition-all ${
|
||||
background === 'transparent'
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary'
|
||||
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
Transparent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.artboards.length > 1 && (
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exportAll}
|
||||
onChange={(e) => setExportAll(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border bg-background text-primary focus:ring-primary/50"
|
||||
/>
|
||||
<span className="text-sm">Export all artboards ({project.artboards.length})</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-secondary/50 rounded-lg space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Dimensions</span>
|
||||
<span className="font-medium">
|
||||
{dimensions?.width} × {dimensions?.height} px
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Estimated size</span>
|
||||
<span className="font-medium">{estimatedSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExporting && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{progressMessage}</span>
|
||||
<span className="font-medium">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isExporting}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={16} />
|
||||
Export
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import { X, Keyboard } from 'lucide-react';
|
||||
|
||||
interface ShortcutItem {
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: ShortcutItem[];
|
||||
}
|
||||
|
||||
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Tools',
|
||||
shortcuts: [
|
||||
{ keys: ['V'], description: 'Select tool' },
|
||||
{ keys: ['H'], description: 'Hand/Pan tool' },
|
||||
{ keys: ['T'], description: 'Text tool' },
|
||||
{ keys: ['S'], description: 'Shape tool' },
|
||||
{ keys: ['P'], description: 'Pen tool' },
|
||||
{ keys: ['I'], description: 'Eyedropper' },
|
||||
{ keys: ['Z'], description: 'Zoom tool' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Edit',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', 'Z'], description: 'Undo' },
|
||||
{ keys: ['⌘', '⇧', 'Z'], description: 'Redo' },
|
||||
{ keys: ['⌘', 'C'], description: 'Copy' },
|
||||
{ keys: ['⌘', 'X'], description: 'Cut' },
|
||||
{ keys: ['⌘', 'V'], description: 'Paste' },
|
||||
{ keys: ['⌘', 'D'], description: 'Duplicate' },
|
||||
{ keys: ['Delete'], description: 'Delete selected' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Selection',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', 'A'], description: 'Select all' },
|
||||
{ keys: ['Esc'], description: 'Deselect all' },
|
||||
{ keys: ['⌘', 'G'], description: 'Group layers' },
|
||||
{ keys: ['⌘', '⇧', 'G'], description: 'Ungroup layers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Layer Order',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', ']'], description: 'Bring forward' },
|
||||
{ keys: ['⌘', '['], description: 'Send backward' },
|
||||
{ keys: ['⌘', '⇧', ']'], description: 'Bring to front' },
|
||||
{ keys: ['⌘', '⇧', '['], description: 'Send to back' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'View',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', '+'], description: 'Zoom in' },
|
||||
{ keys: ['⌘', '-'], description: 'Zoom out' },
|
||||
{ keys: ['⌘', '0'], description: 'Zoom to fit' },
|
||||
{ keys: ["⌘", "'"], description: 'Toggle grid' },
|
||||
{ keys: ['⌘', ';'], description: 'Toggle guides' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
shortcuts: [
|
||||
{ keys: ['?'], description: 'Show shortcuts' },
|
||||
{ keys: ['⌘', ','], description: 'Settings' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsPanel({ isOpen, onClose }: Props) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Keyboard size={20} className="text-primary" />
|
||||
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(80vh-80px)]">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{SHORTCUT_GROUPS.map((group) => (
|
||||
<div key={group.title} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-foreground">{group.title}</h3>
|
||||
<div className="space-y-1.5">
|
||||
{group.shortcuts.map((shortcut, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, keyIndex) => (
|
||||
<kbd
|
||||
key={keyIndex}
|
||||
className="min-w-[24px] h-6 px-1.5 flex items-center justify-center text-[11px] font-medium bg-secondary border border-border rounded shadow-sm"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Press <kbd className="px-1.5 py-0.5 bg-secondary border border-border rounded text-[10px]">?</kbd> to toggle this panel
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { X, Settings, Grid3X3, MousePointer, Save, Palette, Monitor } from 'lucide-react';
|
||||
import { useUIStore } from '../../stores/ui-store';
|
||||
import { Slider } from '@openreel/ui';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SettingsTab = 'canvas' | 'snapping' | 'appearance';
|
||||
|
||||
export function SettingsDialog({ isOpen, onClose }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('canvas');
|
||||
|
||||
const {
|
||||
showGrid,
|
||||
showGuides,
|
||||
showRulers,
|
||||
snapToGrid,
|
||||
snapToGuides,
|
||||
snapToObjects,
|
||||
gridSize,
|
||||
toggleGrid,
|
||||
toggleGuides,
|
||||
toggleRulers,
|
||||
toggleSnapToGrid,
|
||||
toggleSnapToGuides,
|
||||
toggleSnapToObjects,
|
||||
setGridSize,
|
||||
} = useUIStore();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'canvas', label: 'Canvas', icon: <Grid3X3 size={16} /> },
|
||||
{ id: 'snapping', label: 'Snapping', icon: <MousePointer size={16} /> },
|
||||
{ id: 'appearance', label: 'Appearance', icon: <Palette size={16} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings size={20} className="text-primary" />
|
||||
<h2 className="text-lg font-semibold">Settings</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex">
|
||||
<div className="w-40 border-r border-border p-2 space-y-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary/20 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-6 min-h-[300px]">
|
||||
{activeTab === 'canvas' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Canvas Options</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<ToggleOption
|
||||
label="Show Grid"
|
||||
description="Display grid overlay on canvas"
|
||||
checked={showGrid}
|
||||
onChange={toggleGrid}
|
||||
/>
|
||||
|
||||
<ToggleOption
|
||||
label="Show Guides"
|
||||
description="Display alignment guides"
|
||||
checked={showGuides}
|
||||
onChange={toggleGuides}
|
||||
/>
|
||||
|
||||
<ToggleOption
|
||||
label="Show Rulers"
|
||||
description="Display rulers on edges"
|
||||
checked={showRulers}
|
||||
onChange={toggleRulers}
|
||||
/>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-foreground">Grid Size</label>
|
||||
<span className="text-sm text-muted-foreground">{gridSize}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridSize]}
|
||||
onValueChange={([value]) => setGridSize(value)}
|
||||
min={5}
|
||||
max={50}
|
||||
step={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'snapping' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Snap Options</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<ToggleOption
|
||||
label="Snap to Grid"
|
||||
description="Snap objects to grid intersections"
|
||||
checked={snapToGrid}
|
||||
onChange={toggleSnapToGrid}
|
||||
/>
|
||||
|
||||
<ToggleOption
|
||||
label="Snap to Guides"
|
||||
description="Snap objects to guide lines"
|
||||
checked={snapToGuides}
|
||||
onChange={toggleSnapToGuides}
|
||||
/>
|
||||
|
||||
<ToggleOption
|
||||
label="Snap to Objects"
|
||||
description="Snap objects to other objects"
|
||||
checked={snapToObjects}
|
||||
onChange={toggleSnapToObjects}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-4">Appearance</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Monitor size={18} className="text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Theme</p>
|
||||
<p className="text-xs text-muted-foreground">Interface appearance</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-1.5 text-xs bg-primary/20 text-primary rounded-md">
|
||||
Dark (System)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-secondary/50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Save size={18} className="text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Auto Save</p>
|
||||
<p className="text-xs text-muted-foreground">Automatically save projects</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects are automatically saved to browser storage every 30 seconds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleOptionProps {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
function ToggleOption({ label, description, checked, onChange }: ToggleOptionProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{label}</p>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
checked ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,363 +0,0 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Clipboard,
|
||||
Scissors,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Unlock,
|
||||
ArrowUpToLine,
|
||||
ArrowDownToLine,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
RotateCcw,
|
||||
FolderPlus,
|
||||
FolderOpen,
|
||||
Type,
|
||||
Square,
|
||||
Circle,
|
||||
Triangle,
|
||||
Star,
|
||||
Hexagon,
|
||||
Minus,
|
||||
Grid3X3,
|
||||
Ruler,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignStartVertical,
|
||||
AlignCenterVertical,
|
||||
AlignEndVertical,
|
||||
Paintbrush,
|
||||
MousePointer,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ContextMenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type ContextMenuType = 'layer' | 'multi-layer' | 'canvas' | 'group';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
submenu?: MenuItem[];
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
position: ContextMenuPosition;
|
||||
type: ContextMenuType;
|
||||
onClose: () => void;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleVisibility: () => void;
|
||||
onToggleLock: () => void;
|
||||
onBringToFront: () => void;
|
||||
onBringForward: () => void;
|
||||
onSendBackward: () => void;
|
||||
onSendToBack: () => void;
|
||||
onGroup: () => void;
|
||||
onUngroup: () => void;
|
||||
onFlipHorizontal: () => void;
|
||||
onFlipVertical: () => void;
|
||||
onResetTransform: () => void;
|
||||
onCopyStyle: () => void;
|
||||
onPasteStyle: () => void;
|
||||
onAddText: () => void;
|
||||
onAddShape: (type: 'rectangle' | 'ellipse' | 'triangle' | 'star' | 'polygon' | 'line') => void;
|
||||
onToggleGrid: () => void;
|
||||
onToggleRulers: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomFit: () => void;
|
||||
onAlignLeft: () => void;
|
||||
onAlignCenter: () => void;
|
||||
onAlignRight: () => void;
|
||||
onAlignTop: () => void;
|
||||
onAlignMiddle: () => void;
|
||||
onAlignBottom: () => void;
|
||||
isVisible: boolean;
|
||||
isLocked: boolean;
|
||||
showGrid: boolean;
|
||||
showRulers: boolean;
|
||||
hasClipboard: boolean;
|
||||
hasStyleClipboard: boolean;
|
||||
selectedCount: number;
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
position,
|
||||
type,
|
||||
onClose,
|
||||
onCut,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onSelectAll,
|
||||
onToggleVisibility,
|
||||
onToggleLock,
|
||||
onBringToFront,
|
||||
onBringForward,
|
||||
onSendBackward,
|
||||
onSendToBack,
|
||||
onGroup,
|
||||
onUngroup,
|
||||
onFlipHorizontal,
|
||||
onFlipVertical,
|
||||
onResetTransform,
|
||||
onCopyStyle,
|
||||
onPasteStyle,
|
||||
onAddText,
|
||||
onAddShape,
|
||||
onToggleGrid,
|
||||
onToggleRulers,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomFit,
|
||||
onAlignLeft,
|
||||
onAlignCenter,
|
||||
onAlignRight,
|
||||
onAlignTop,
|
||||
onAlignMiddle,
|
||||
onAlignBottom,
|
||||
isVisible,
|
||||
isLocked,
|
||||
showGrid,
|
||||
showRulers,
|
||||
hasClipboard,
|
||||
hasStyleClipboard,
|
||||
selectedCount,
|
||||
}: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let adjustedX = position.x;
|
||||
let adjustedY = position.y;
|
||||
|
||||
if (position.x + rect.width > viewportWidth) {
|
||||
adjustedX = viewportWidth - rect.width - 8;
|
||||
}
|
||||
if (position.y + rect.height > viewportHeight) {
|
||||
adjustedY = viewportHeight - rect.height - 8;
|
||||
}
|
||||
|
||||
menuRef.current.style.left = `${adjustedX}px`;
|
||||
menuRef.current.style.top = `${adjustedY}px`;
|
||||
}
|
||||
}, [position]);
|
||||
|
||||
const getMenuItems = (): MenuItem[] => {
|
||||
if (type === 'canvas') {
|
||||
return [
|
||||
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
|
||||
{ label: 'Paste Style', icon: <Paintbrush size={14} />, shortcut: '⌘⇧V', action: onPasteStyle, disabled: !hasStyleClipboard },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Select All', icon: <MousePointer size={14} />, shortcut: '⌘A', action: onSelectAll },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Add Text', icon: <Type size={14} />, shortcut: 'T', action: onAddText },
|
||||
{
|
||||
label: 'Add Shape',
|
||||
icon: <Square size={14} />,
|
||||
action: () => {},
|
||||
submenu: [
|
||||
{ label: 'Rectangle', icon: <Square size={14} />, action: () => onAddShape('rectangle') },
|
||||
{ label: 'Ellipse', icon: <Circle size={14} />, action: () => onAddShape('ellipse') },
|
||||
{ label: 'Triangle', icon: <Triangle size={14} />, action: () => onAddShape('triangle') },
|
||||
{ label: 'Star', icon: <Star size={14} />, action: () => onAddShape('star') },
|
||||
{ label: 'Polygon', icon: <Hexagon size={14} />, action: () => onAddShape('polygon') },
|
||||
{ label: 'Line', icon: <Minus size={14} />, action: () => onAddShape('line') },
|
||||
],
|
||||
},
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: showGrid ? 'Hide Grid' : 'Show Grid', icon: <Grid3X3 size={14} />, shortcut: "⌘'", action: onToggleGrid },
|
||||
{ label: showRulers ? 'Hide Rulers' : 'Show Rulers', icon: <Ruler size={14} />, shortcut: '⌘R', action: onToggleRulers },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Zoom In', icon: <ZoomIn size={14} />, shortcut: '⌘+', action: onZoomIn },
|
||||
{ label: 'Zoom Out', icon: <ZoomOut size={14} />, shortcut: '⌘-', action: onZoomOut },
|
||||
{ label: 'Zoom to Fit', icon: <Maximize size={14} />, shortcut: '⌘0', action: onZoomFit },
|
||||
];
|
||||
}
|
||||
|
||||
if (type === 'multi-layer') {
|
||||
return [
|
||||
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
|
||||
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
|
||||
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
|
||||
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
|
||||
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: `Group ${selectedCount} Layers`, icon: <FolderPlus size={14} />, shortcut: '⌘G', action: onGroup },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{
|
||||
label: 'Align',
|
||||
icon: <AlignLeft size={14} />,
|
||||
action: () => {},
|
||||
submenu: [
|
||||
{ label: 'Align Left', icon: <AlignLeft size={14} />, action: onAlignLeft },
|
||||
{ label: 'Align Center', icon: <AlignCenter size={14} />, action: onAlignCenter },
|
||||
{ label: 'Align Right', icon: <AlignRight size={14} />, action: onAlignRight },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Align Top', icon: <AlignStartVertical size={14} />, action: onAlignTop },
|
||||
{ label: 'Align Middle', icon: <AlignCenterVertical size={14} />, action: onAlignMiddle },
|
||||
{ label: 'Align Bottom', icon: <AlignEndVertical size={14} />, action: onAlignBottom },
|
||||
],
|
||||
},
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
|
||||
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
|
||||
];
|
||||
}
|
||||
|
||||
if (type === 'group') {
|
||||
return [
|
||||
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
|
||||
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
|
||||
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
|
||||
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
|
||||
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Ungroup', icon: <FolderOpen size={14} />, shortcut: '⌘⇧G', action: onUngroup },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: isVisible ? 'Hide' : 'Show', icon: isVisible ? <EyeOff size={14} /> : <Eye size={14} />, shortcut: '⌘⇧H', action: onToggleVisibility },
|
||||
{ label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? <Unlock size={14} /> : <Lock size={14} />, shortcut: '⌘⇧L', action: onToggleLock },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
|
||||
{ label: 'Bring Forward', icon: <ChevronUp size={14} />, shortcut: '⌘]', action: onBringForward },
|
||||
{ label: 'Send Backward', icon: <ChevronDown size={14} />, shortcut: '⌘[', action: onSendBackward },
|
||||
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Flip Horizontal', icon: <FlipHorizontal size={14} />, action: onFlipHorizontal },
|
||||
{ label: 'Flip Vertical', icon: <FlipVertical size={14} />, action: onFlipVertical },
|
||||
{ label: 'Reset Transform', icon: <RotateCcw size={14} />, action: onResetTransform },
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
|
||||
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
|
||||
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
|
||||
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
|
||||
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Copy Style', icon: <Paintbrush size={14} />, shortcut: '⌘⌥C', action: onCopyStyle },
|
||||
{ label: 'Paste Style', icon: <Paintbrush size={14} />, shortcut: '⌘⌥V', action: onPasteStyle, disabled: !hasStyleClipboard },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: isVisible ? 'Hide' : 'Show', icon: isVisible ? <EyeOff size={14} /> : <Eye size={14} />, shortcut: '⌘⇧H', action: onToggleVisibility },
|
||||
{ label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? <Unlock size={14} /> : <Lock size={14} />, shortcut: '⌘⇧L', action: onToggleLock },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
|
||||
{ label: 'Bring Forward', icon: <ChevronUp size={14} />, shortcut: '⌘]', action: onBringForward },
|
||||
{ label: 'Send Backward', icon: <ChevronDown size={14} />, shortcut: '⌘[', action: onSendBackward },
|
||||
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
|
||||
{ label: '', action: () => {}, divider: true },
|
||||
{ label: 'Flip Horizontal', icon: <FlipHorizontal size={14} />, action: onFlipHorizontal },
|
||||
{ label: 'Flip Vertical', icon: <FlipVertical size={14} />, action: onFlipVertical },
|
||||
{ label: 'Reset Transform', icon: <RotateCcw size={14} />, action: onResetTransform },
|
||||
];
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem, index: number) => {
|
||||
if (item.divider) {
|
||||
return <div key={index} className="h-px bg-border my-1" />;
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
return (
|
||||
<div key={index} className="relative group/submenu">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-foreground hover:bg-accent rounded-sm transition-colors"
|
||||
>
|
||||
{item.icon && <span className="text-muted-foreground">{item.icon}</span>}
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
<ChevronUp size={12} className="text-muted-foreground rotate-90" />
|
||||
</button>
|
||||
<div className="absolute left-full top-0 ml-1 hidden group-hover/submenu:block">
|
||||
<div className="bg-popover border border-border rounded-lg shadow-lg py-1 min-w-[160px]">
|
||||
{item.submenu.map((subItem, subIndex) => renderMenuItem(subItem, subIndex))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.action();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs rounded-sm transition-colors ${
|
||||
item.disabled
|
||||
? 'text-muted-foreground/50 cursor-not-allowed'
|
||||
: 'text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className={item.disabled ? 'text-muted-foreground/50' : 'text-muted-foreground'}>{item.icon}</span>}
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{item.shortcut}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const menuItems = getMenuItems();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 bg-popover border border-border rounded-lg shadow-xl py-1 min-w-[200px] animate-in fade-in-0 zoom-in-95 duration-100"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{menuItems.map((item, index) => renderMenuItem(item, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
|
||||
const RULER_SIZE = 20;
|
||||
const RULER_BG = '#1f1f23';
|
||||
const RULER_TEXT = '#71717a';
|
||||
const RULER_TICK = '#3f3f46';
|
||||
const RULER_HIGHLIGHT = '#3b82f6';
|
||||
|
||||
interface RulersProps {
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
}
|
||||
|
||||
export function Rulers({ containerWidth, containerHeight }: RulersProps) {
|
||||
const horizontalRef = useRef<HTMLCanvasElement>(null);
|
||||
const verticalRef = useRef<HTMLCanvasElement>(null);
|
||||
const cornerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { zoom, panX, panY, showRulers } = useUIStore();
|
||||
const { project, selectedArtboardId } = useProjectStore();
|
||||
|
||||
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showRulers || !artboard) return;
|
||||
if (containerWidth <= RULER_SIZE || containerHeight <= RULER_SIZE) return;
|
||||
|
||||
const hCanvas = horizontalRef.current;
|
||||
const vCanvas = verticalRef.current;
|
||||
if (!hCanvas || !vCanvas) return;
|
||||
|
||||
const hCtx = hCanvas.getContext('2d');
|
||||
const vCtx = vCanvas.getContext('2d');
|
||||
if (!hCtx || !vCtx) return;
|
||||
|
||||
hCanvas.width = containerWidth - RULER_SIZE;
|
||||
hCanvas.height = RULER_SIZE;
|
||||
vCanvas.width = RULER_SIZE;
|
||||
vCanvas.height = containerHeight - RULER_SIZE;
|
||||
|
||||
const centerX = containerWidth / 2 + panX;
|
||||
const centerY = containerHeight / 2 + panY;
|
||||
const artboardX = centerX - (artboard.size.width * zoom) / 2;
|
||||
const artboardY = centerY - (artboard.size.height * zoom) / 2;
|
||||
|
||||
renderHorizontalRuler(hCtx, containerWidth, artboardX, artboard.size.width, zoom);
|
||||
renderVerticalRuler(vCtx, containerHeight, artboardY, artboard.size.height, zoom);
|
||||
}, [containerWidth, containerHeight, zoom, panX, panY, showRulers, artboard]);
|
||||
|
||||
if (!showRulers) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={cornerRef}
|
||||
className="absolute top-0 left-0 z-20"
|
||||
style={{
|
||||
width: RULER_SIZE,
|
||||
height: RULER_SIZE,
|
||||
backgroundColor: RULER_BG,
|
||||
borderRight: `1px solid ${RULER_TICK}`,
|
||||
borderBottom: `1px solid ${RULER_TICK}`,
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={horizontalRef}
|
||||
className="absolute top-0 z-10"
|
||||
style={{
|
||||
left: RULER_SIZE,
|
||||
width: containerWidth - RULER_SIZE,
|
||||
height: RULER_SIZE,
|
||||
backgroundColor: RULER_BG,
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={verticalRef}
|
||||
className="absolute left-0 z-10"
|
||||
style={{
|
||||
top: RULER_SIZE,
|
||||
width: RULER_SIZE,
|
||||
height: containerHeight - RULER_SIZE,
|
||||
backgroundColor: RULER_BG,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getTickInterval(zoom: number): { major: number; minor: number } {
|
||||
const baseUnit = 100;
|
||||
const scaledUnit = baseUnit / zoom;
|
||||
|
||||
if (scaledUnit < 50) return { major: 50, minor: 10 };
|
||||
if (scaledUnit < 100) return { major: 100, minor: 20 };
|
||||
if (scaledUnit < 200) return { major: 100, minor: 25 };
|
||||
if (scaledUnit < 500) return { major: 200, minor: 50 };
|
||||
if (scaledUnit < 1000) return { major: 500, minor: 100 };
|
||||
return { major: 1000, minor: 200 };
|
||||
}
|
||||
|
||||
function renderHorizontalRuler(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
artboardX: number,
|
||||
artboardWidth: number,
|
||||
zoom: number
|
||||
) {
|
||||
ctx.fillStyle = RULER_BG;
|
||||
ctx.fillRect(0, 0, width, RULER_SIZE);
|
||||
|
||||
const { major, minor } = getTickInterval(zoom);
|
||||
|
||||
ctx.strokeStyle = RULER_TICK;
|
||||
ctx.fillStyle = RULER_TEXT;
|
||||
ctx.font = '9px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const startX = -Math.ceil(artboardX / (minor * zoom)) * minor;
|
||||
const endX = artboardWidth + Math.ceil((width - artboardX - artboardWidth * zoom) / (minor * zoom)) * minor;
|
||||
|
||||
for (let i = startX; i <= endX; i += minor) {
|
||||
const screenX = artboardX + i * zoom - RULER_SIZE;
|
||||
if (screenX < 0 || screenX > width) continue;
|
||||
|
||||
const isMajor = i % major === 0;
|
||||
const tickHeight = isMajor ? 12 : 6;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(screenX, RULER_SIZE);
|
||||
ctx.lineTo(screenX, RULER_SIZE - tickHeight);
|
||||
ctx.stroke();
|
||||
|
||||
if (isMajor) {
|
||||
ctx.fillText(String(i), screenX, 2);
|
||||
}
|
||||
}
|
||||
|
||||
const artboardStart = artboardX - RULER_SIZE;
|
||||
const artboardEnd = artboardX + artboardWidth * zoom - RULER_SIZE;
|
||||
|
||||
ctx.strokeStyle = RULER_HIGHLIGHT;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(Math.max(0, artboardStart), RULER_SIZE - 1);
|
||||
ctx.lineTo(Math.min(width, artboardEnd), RULER_SIZE - 1);
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
|
||||
function renderVerticalRuler(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
height: number,
|
||||
artboardY: number,
|
||||
artboardHeight: number,
|
||||
zoom: number
|
||||
) {
|
||||
ctx.fillStyle = RULER_BG;
|
||||
ctx.fillRect(0, 0, RULER_SIZE, height);
|
||||
|
||||
const { major, minor } = getTickInterval(zoom);
|
||||
|
||||
ctx.strokeStyle = RULER_TICK;
|
||||
ctx.fillStyle = RULER_TEXT;
|
||||
ctx.font = '9px Inter, system-ui, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const startY = -Math.ceil(artboardY / (minor * zoom)) * minor;
|
||||
const endY = artboardHeight + Math.ceil((height - artboardY - artboardHeight * zoom) / (minor * zoom)) * minor;
|
||||
|
||||
for (let i = startY; i <= endY; i += minor) {
|
||||
const screenY = artboardY + i * zoom - RULER_SIZE;
|
||||
if (screenY < 0 || screenY > height) continue;
|
||||
|
||||
const isMajor = i % major === 0;
|
||||
const tickWidth = isMajor ? 12 : 6;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(RULER_SIZE, screenY);
|
||||
ctx.lineTo(RULER_SIZE - tickWidth, screenY);
|
||||
ctx.stroke();
|
||||
|
||||
if (isMajor) {
|
||||
ctx.save();
|
||||
ctx.translate(10, screenY);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(i), 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
const artboardStart = artboardY - RULER_SIZE;
|
||||
const artboardEnd = artboardY + artboardHeight * zoom - RULER_SIZE;
|
||||
|
||||
ctx.strokeStyle = RULER_HIGHLIGHT;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(RULER_SIZE - 1, Math.max(0, artboardStart));
|
||||
ctx.lineTo(RULER_SIZE - 1, Math.min(height, artboardEnd));
|
||||
ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import {
|
||||
AlignHorizontalJustifyStart,
|
||||
AlignHorizontalJustifyCenter,
|
||||
AlignHorizontalJustifyEnd,
|
||||
AlignVerticalJustifyStart,
|
||||
AlignVerticalJustifyCenter,
|
||||
AlignVerticalJustifyEnd,
|
||||
AlignHorizontalSpaceBetween,
|
||||
AlignVerticalSpaceBetween,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layers: Layer[];
|
||||
}
|
||||
|
||||
export function AlignmentSection({ layers }: Props) {
|
||||
const { project, selectedArtboardId, updateLayerTransform } = useProjectStore();
|
||||
|
||||
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
|
||||
|
||||
if (!artboard || layers.length === 0) return null;
|
||||
|
||||
const alignLeft = () => {
|
||||
if (layers.length === 1) {
|
||||
updateLayerTransform(layers[0].id, { x: 0 });
|
||||
} else {
|
||||
const minX = Math.min(...layers.map((l) => l.transform.x));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { x: minX });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignCenterH = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
x: (artboard.size.width - layer.transform.width) / 2,
|
||||
});
|
||||
} else {
|
||||
const bounds = getBounds(layers);
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
x: centerX - layer.transform.width / 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignRight = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
x: artboard.size.width - layer.transform.width,
|
||||
});
|
||||
} else {
|
||||
const maxRight = Math.max(...layers.map((l) => l.transform.x + l.transform.width));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
x: maxRight - layer.transform.width,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignTop = () => {
|
||||
if (layers.length === 1) {
|
||||
updateLayerTransform(layers[0].id, { y: 0 });
|
||||
} else {
|
||||
const minY = Math.min(...layers.map((l) => l.transform.y));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { y: minY });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignCenterV = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
y: (artboard.size.height - layer.transform.height) / 2,
|
||||
});
|
||||
} else {
|
||||
const bounds = getBounds(layers);
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
y: centerY - layer.transform.height / 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignBottom = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
y: artboard.size.height - layer.transform.height,
|
||||
});
|
||||
} else {
|
||||
const maxBottom = Math.max(...layers.map((l) => l.transform.y + l.transform.height));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
y: maxBottom - layer.transform.height,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const distributeH = () => {
|
||||
if (layers.length < 3) return;
|
||||
|
||||
const sorted = [...layers].sort((a, b) => a.transform.x - b.transform.x);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const totalWidth = last.transform.x + last.transform.width - first.transform.x;
|
||||
const layersWidth = sorted.reduce((sum, l) => sum + l.transform.width, 0);
|
||||
const gap = (totalWidth - layersWidth) / (sorted.length - 1);
|
||||
|
||||
let x = first.transform.x;
|
||||
sorted.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { x });
|
||||
x += layer.transform.width + gap;
|
||||
});
|
||||
};
|
||||
|
||||
const distributeV = () => {
|
||||
if (layers.length < 3) return;
|
||||
|
||||
const sorted = [...layers].sort((a, b) => a.transform.y - b.transform.y);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const totalHeight = last.transform.y + last.transform.height - first.transform.y;
|
||||
const layersHeight = sorted.reduce((sum, l) => sum + l.transform.height, 0);
|
||||
const gap = (totalHeight - layersHeight) / (sorted.length - 1);
|
||||
|
||||
let y = first.transform.y;
|
||||
sorted.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { y });
|
||||
y += layer.transform.height + gap;
|
||||
});
|
||||
};
|
||||
|
||||
const isSingleLayer = layers.length === 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Alignment
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-6 gap-1">
|
||||
<AlignButton
|
||||
icon={<AlignHorizontalJustifyStart size={14} />}
|
||||
onClick={alignLeft}
|
||||
title={isSingleLayer ? 'Align to canvas left' : 'Align left edges'}
|
||||
/>
|
||||
<AlignButton
|
||||
icon={<AlignHorizontalJustifyCenter size={14} />}
|
||||
onClick={alignCenterH}
|
||||
title={isSingleLayer ? 'Center horizontally on canvas' : 'Align horizontal centers'}
|
||||
/>
|
||||
<AlignButton
|
||||
icon={<AlignHorizontalJustifyEnd size={14} />}
|
||||
onClick={alignRight}
|
||||
title={isSingleLayer ? 'Align to canvas right' : 'Align right edges'}
|
||||
/>
|
||||
<AlignButton
|
||||
icon={<AlignVerticalJustifyStart size={14} />}
|
||||
onClick={alignTop}
|
||||
title={isSingleLayer ? 'Align to canvas top' : 'Align top edges'}
|
||||
/>
|
||||
<AlignButton
|
||||
icon={<AlignVerticalJustifyCenter size={14} />}
|
||||
onClick={alignCenterV}
|
||||
title={isSingleLayer ? 'Center vertically on canvas' : 'Align vertical centers'}
|
||||
/>
|
||||
<AlignButton
|
||||
icon={<AlignVerticalJustifyEnd size={14} />}
|
||||
onClick={alignBottom}
|
||||
title={isSingleLayer ? 'Align to canvas bottom' : 'Align bottom edges'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{layers.length >= 3 && (
|
||||
<div className="grid grid-cols-2 gap-1 pt-2 border-t border-border">
|
||||
<AlignButton
|
||||
icon={<AlignHorizontalSpaceBetween size={14} />}
|
||||
onClick={distributeH}
|
||||
title="Distribute horizontally"
|
||||
label="Distribute H"
|
||||
/>
|
||||
<AlignButton
|
||||
icon={<AlignVerticalSpaceBetween size={14} />}
|
||||
onClick={distributeV}
|
||||
title="Distribute vertically"
|
||||
label="Distribute V"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AlignButtonProps {
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function AlignButton({ icon, onClick, title, label }: AlignButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className="flex items-center justify-center gap-1 p-2 rounded-md bg-secondary/50 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
{icon}
|
||||
{label && <span className="text-[9px]">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function getBounds(layers: Layer[]): { x: number; y: number; width: number; height: number } {
|
||||
const minX = Math.min(...layers.map((l) => l.transform.x));
|
||||
const minY = Math.min(...layers.map((l) => l.transform.y));
|
||||
const maxX = Math.max(...layers.map((l) => l.transform.x + l.transform.width));
|
||||
const maxY = Math.max(...layers.map((l) => l.transform.y + l.transform.height));
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer, BlendMode } from '../../../types/project';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const BLEND_MODES: BlendMode['mode'][] = [
|
||||
'normal',
|
||||
'multiply',
|
||||
'screen',
|
||||
'overlay',
|
||||
'darken',
|
||||
'lighten',
|
||||
'color-dodge',
|
||||
'color-burn',
|
||||
'hard-light',
|
||||
'soft-light',
|
||||
'difference',
|
||||
'exclusion',
|
||||
];
|
||||
|
||||
export function AppearanceSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
|
||||
const handleBlendModeChange = (mode: BlendMode['mode']) => {
|
||||
updateLayer(layer.id, { blendMode: { mode } });
|
||||
};
|
||||
|
||||
const handleShadowToggle = () => {
|
||||
updateLayer(layer.id, {
|
||||
shadow: { ...layer.shadow, enabled: !layer.shadow.enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleShadowChange = (key: string, value: string | number) => {
|
||||
updateLayer(layer.id, {
|
||||
shadow: { ...layer.shadow, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrokeToggle = () => {
|
||||
updateLayer(layer.id, {
|
||||
stroke: { ...layer.stroke, enabled: !layer.stroke.enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrokeChange = (key: string, value: string | number) => {
|
||||
updateLayer(layer.id, {
|
||||
stroke: { ...layer.stroke, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1">Blend Mode</label>
|
||||
<select
|
||||
value={layer.blendMode.mode}
|
||||
onChange={(e) => handleBlendModeChange(e.target.value as BlendMode['mode'])}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary capitalize"
|
||||
>
|
||||
{BLEND_MODES.map((mode) => (
|
||||
<option key={mode} value={mode} className="capitalize">
|
||||
{mode.replace('-', ' ')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] text-muted-foreground">Drop Shadow</label>
|
||||
<button
|
||||
onClick={handleShadowToggle}
|
||||
className={`w-8 h-5 rounded-full transition-colors ${
|
||||
layer.shadow.enabled ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
|
||||
layer.shadow.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{layer.shadow.enabled && (
|
||||
<div className="pl-2 border-l-2 border-border space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-muted-foreground w-12">Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={layer.shadow.color.replace(/rgba?\([^)]+\)/, '#000000')}
|
||||
onChange={(e) => handleShadowChange('color', e.target.value)}
|
||||
className="w-6 h-6 rounded border border-input cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-muted-foreground w-12">Blur</label>
|
||||
<input
|
||||
type="range"
|
||||
value={layer.shadow.blur}
|
||||
onChange={(e) => handleShadowChange('blur', Number(e.target.value))}
|
||||
min={0}
|
||||
max={50}
|
||||
className="flex-1 h-1 accent-primary"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground w-6 text-right">
|
||||
{layer.shadow.blur}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-muted-foreground w-12">X</label>
|
||||
<input
|
||||
type="number"
|
||||
value={layer.shadow.offsetX}
|
||||
onChange={(e) => handleShadowChange('offsetX', Number(e.target.value))}
|
||||
className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md"
|
||||
/>
|
||||
<label className="text-[10px] text-muted-foreground w-4">Y</label>
|
||||
<input
|
||||
type="number"
|
||||
value={layer.shadow.offsetY}
|
||||
onChange={(e) => handleShadowChange('offsetY', Number(e.target.value))}
|
||||
className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] text-muted-foreground">Stroke</label>
|
||||
<button
|
||||
onClick={handleStrokeToggle}
|
||||
className={`w-8 h-5 rounded-full transition-colors ${
|
||||
layer.stroke.enabled ? 'bg-primary' : 'bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
|
||||
layer.stroke.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{layer.stroke.enabled && (
|
||||
<div className="pl-2 border-l-2 border-border space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-muted-foreground w-12">Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={layer.stroke.color}
|
||||
onChange={(e) => handleStrokeChange('color', e.target.value)}
|
||||
className="w-6 h-6 rounded border border-input cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-muted-foreground w-12">Width</label>
|
||||
<input
|
||||
type="range"
|
||||
value={layer.stroke.width}
|
||||
onChange={(e) => handleStrokeChange('width', Number(e.target.value))}
|
||||
min={1}
|
||||
max={20}
|
||||
className="flex-1 h-1 accent-primary"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground w-6 text-right">
|
||||
{layer.stroke.width}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Artboard, CanvasBackground } from '../../../types/project';
|
||||
|
||||
interface Props {
|
||||
artboard: Artboard;
|
||||
}
|
||||
|
||||
export function ArtboardSection({ artboard }: Props) {
|
||||
const { updateArtboard } = useProjectStore();
|
||||
|
||||
const handleSizeChange = (key: 'width' | 'height', value: number) => {
|
||||
updateArtboard(artboard.id, {
|
||||
size: { ...artboard.size, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackgroundTypeChange = (type: CanvasBackground['type']) => {
|
||||
let background: CanvasBackground;
|
||||
switch (type) {
|
||||
case 'color':
|
||||
background = { type: 'color', color: '#ffffff' };
|
||||
break;
|
||||
case 'transparent':
|
||||
background = { type: 'transparent' };
|
||||
break;
|
||||
case 'gradient':
|
||||
background = {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
type: 'linear',
|
||||
angle: 180,
|
||||
stops: [
|
||||
{ offset: 0, color: '#ffffff' },
|
||||
{ offset: 1, color: '#000000' },
|
||||
],
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
background = { type: 'color', color: '#ffffff' };
|
||||
}
|
||||
updateArtboard(artboard.id, { background });
|
||||
};
|
||||
|
||||
const handleBackgroundColorChange = (color: string) => {
|
||||
updateArtboard(artboard.id, {
|
||||
background: { type: 'color', color },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={artboard.name}
|
||||
onChange={(e) => updateArtboard(artboard.id, { name: e.target.value })}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1">Width</label>
|
||||
<input
|
||||
type="number"
|
||||
value={artboard.size.width}
|
||||
onChange={(e) => handleSizeChange('width', Number(e.target.value))}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
min={1}
|
||||
max={8000}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1">Height</label>
|
||||
<input
|
||||
type="number"
|
||||
value={artboard.size.height}
|
||||
onChange={(e) => handleSizeChange('height', Number(e.target.value))}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
min={1}
|
||||
max={8000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1">Background</label>
|
||||
<select
|
||||
value={artboard.background.type}
|
||||
onChange={(e) => handleBackgroundTypeChange(e.target.value as CanvasBackground['type'])}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary mb-2"
|
||||
>
|
||||
<option value="color">Solid Color</option>
|
||||
<option value="transparent">Transparent</option>
|
||||
<option value="gradient">Gradient</option>
|
||||
</select>
|
||||
|
||||
{artboard.background.type === 'color' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={artboard.background.color ?? '#ffffff'}
|
||||
onChange={(e) => handleBackgroundColorChange(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-input cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={artboard.background.color ?? '#ffffff'}
|
||||
onChange={(e) => handleBackgroundColorChange(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{artboard.background.type === 'transparent' && (
|
||||
<div className="p-3 rounded-md bg-background border border-input">
|
||||
<div
|
||||
className="h-8 rounded"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
|
||||
backgroundSize: '10px 10px',
|
||||
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
|
||||
}}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-2 text-center">
|
||||
Transparency pattern
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Wand2, Loader2 } from 'lucide-react';
|
||||
import { Slider } from '@openreel/ui';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { ImageLayer } from '../../../types/project';
|
||||
import {
|
||||
getBackgroundRemovalService,
|
||||
BackgroundMode,
|
||||
DEFAULT_OPTIONS,
|
||||
} from '../../../services/background-removal-service';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
export function BackgroundRemovalSection({ layer }: Props) {
|
||||
const { project, addAsset, updateLayer } = useProjectStore();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [mode, setMode] = useState<BackgroundMode>('transparent');
|
||||
const [backgroundColor, setBackgroundColor] = useState(DEFAULT_OPTIONS.backgroundColor!);
|
||||
const [blurAmount, setBlurAmount] = useState(DEFAULT_OPTIONS.blurAmount!);
|
||||
|
||||
const asset = project?.assets[layer.sourceId];
|
||||
|
||||
const handleRemoveBackground = async () => {
|
||||
if (!asset?.dataUrl && !asset?.thumbnailUrl) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const service = getBackgroundRemovalService();
|
||||
const imageUrl = asset.dataUrl || asset.thumbnailUrl;
|
||||
|
||||
const resultDataUrl = await service.removeBackground(
|
||||
imageUrl,
|
||||
{
|
||||
mode,
|
||||
backgroundColor,
|
||||
blurAmount,
|
||||
},
|
||||
setProgress
|
||||
);
|
||||
|
||||
const newAssetId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
addAsset({
|
||||
id: newAssetId,
|
||||
name: `${asset.name} (no bg)`,
|
||||
type: 'image',
|
||||
mimeType: 'image/png',
|
||||
size: 0,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
thumbnailUrl: resultDataUrl,
|
||||
dataUrl: resultDataUrl,
|
||||
});
|
||||
|
||||
updateLayer<ImageLayer>(layer.id, { sourceId: newAssetId });
|
||||
} catch (error) {
|
||||
console.error('Background removal failed:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Background Removal
|
||||
</h4>
|
||||
|
||||
<div className="p-3 space-y-4 bg-secondary/50 rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] text-muted-foreground">Mode</label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['transparent', 'color', 'blur'] as BackgroundMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-2 py-1.5 text-[10px] font-medium rounded transition-colors ${
|
||||
mode === m
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background text-muted-foreground hover:text-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'color' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[10px] text-muted-foreground">Background</label>
|
||||
<input
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-input cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="flex-1 px-2 py-1 text-xs bg-background border border-input rounded-md font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'blur' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Blur Amount</label>
|
||||
<span className="text-[10px] text-muted-foreground">{blurAmount}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[blurAmount]}
|
||||
onValueChange={([v]) => setBlurAmount(v)}
|
||||
min={5}
|
||||
max={30}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-200"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground text-center">
|
||||
{progress < 15 ? 'Loading AI model...' :
|
||||
progress < 90 ? 'Analyzing image...' :
|
||||
'Finalizing...'}
|
||||
{' '}{Math.round(progress)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleRemoveBackground}
|
||||
disabled={isProcessing}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Wand2 size={16} />
|
||||
Remove Background
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-[9px] text-muted-foreground text-center">
|
||||
AI-powered background removal for any image
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { BlackWhiteAdjustment } from '../../../types/adjustments';
|
||||
import { DEFAULT_BLACK_WHITE } from '../../../types/adjustments';
|
||||
import { BLACK_WHITE_PRESETS } from '../../../adjustments/black-white';
|
||||
import { SunMoon, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const COLOR_SLIDERS: { key: keyof BlackWhiteAdjustment; label: string; color: string }[] = [
|
||||
{ key: 'reds', label: 'Reds', color: 'bg-red-500' },
|
||||
{ key: 'yellows', label: 'Yellows', color: 'bg-yellow-500' },
|
||||
{ key: 'greens', label: 'Greens', color: 'bg-green-500' },
|
||||
{ key: 'cyans', label: 'Cyans', color: 'bg-cyan-500' },
|
||||
{ key: 'blues', label: 'Blues', color: 'bg-blue-500' },
|
||||
{ key: 'magentas', label: 'Magentas', color: 'bg-pink-500' },
|
||||
];
|
||||
|
||||
const PRESET_OPTIONS = [
|
||||
{ id: 'default', label: 'Default' },
|
||||
{ id: 'highContrast', label: 'High Contrast' },
|
||||
{ id: 'infrared', label: 'Infrared' },
|
||||
{ id: 'maximumBlack', label: 'Maximum Black' },
|
||||
{ id: 'maximumWhite', label: 'Maximum White' },
|
||||
{ id: 'neutralDensity', label: 'Neutral Density' },
|
||||
{ id: 'redFilter', label: 'Red Filter' },
|
||||
{ id: 'yellowFilter', label: 'Yellow Filter' },
|
||||
{ id: 'greenFilter', label: 'Green Filter' },
|
||||
{ id: 'blueFilter', label: 'Blue Filter' },
|
||||
] as const;
|
||||
|
||||
function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) {
|
||||
const percentage = ((value + 200) / 400) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${color}`} />
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={-200}
|
||||
max={200}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlackWhiteSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const blackWhite = layer.blackWhite;
|
||||
|
||||
const handleValueChange = (key: keyof BlackWhiteAdjustment, value: number | boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
blackWhite: {
|
||||
...blackWhite,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePresetChange = (presetId: string) => {
|
||||
const preset = BLACK_WHITE_PRESETS[presetId as keyof typeof BLACK_WHITE_PRESETS];
|
||||
if (preset) {
|
||||
updateLayer(layer.id, {
|
||||
blackWhite: {
|
||||
...blackWhite,
|
||||
...preset,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
blackWhite: {
|
||||
...blackWhite,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetBlackWhite = () => {
|
||||
updateLayer(layer.id, {
|
||||
blackWhite: { ...DEFAULT_BLACK_WHITE },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SunMoon size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Black & White</span>
|
||||
{blackWhite.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blackWhite.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
className="text-[10px] bg-secondary border-none rounded px-2 py-1 text-foreground"
|
||||
>
|
||||
<option value="">Preset</option>
|
||||
{PRESET_OPTIONS.map((preset) => (
|
||||
<option key={preset.id} value={preset.id}>{preset.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={resetBlackWhite}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{COLOR_SLIDERS.map(({ key, label, color }) => (
|
||||
<ChannelSlider
|
||||
key={key}
|
||||
label={label}
|
||||
color={color}
|
||||
value={blackWhite[key] as number}
|
||||
onChange={(v) => handleValueChange(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border/50">
|
||||
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blackWhite.tintEnabled}
|
||||
onChange={(e) => handleValueChange('tintEnabled', e.target.checked)}
|
||||
className="w-3 h-3 rounded border-border"
|
||||
/>
|
||||
Tint
|
||||
</label>
|
||||
{blackWhite.tintEnabled && (
|
||||
<div className="space-y-2 pl-5">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">Hue</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{blackWhite.tintHue}°</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={blackWhite.tintHue}
|
||||
min={0}
|
||||
max={360}
|
||||
onChange={(e) => handleValueChange('tintHue', Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none rounded-full cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(0, 70%, 50%), hsl(60, 70%, 50%), hsl(120, 70%, 50%), hsl(180, 70%, 50%), hsl(240, 70%, 50%), hsl(300, 70%, 50%), hsl(360, 70%, 50%))`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">Saturation</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{blackWhite.tintSaturation}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={blackWhite.tintSaturation}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleValueChange('tintSaturation', Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Droplets, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function BlurSharpenToolPanel() {
|
||||
const { blurSharpenSettings, setBlurSharpenSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setBlurSharpenSettings({
|
||||
size: 30,
|
||||
strength: 50,
|
||||
mode: 'blur',
|
||||
sampleAllLayers: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Droplets size={16} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">
|
||||
{blurSharpenSettings.mode === 'blur' ? 'Blur' : 'Sharpen'} Tool
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{blurSharpenSettings.mode === 'blur'
|
||||
? 'Paint to blur and soften areas.'
|
||||
: 'Paint to sharpen and enhance details.'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<button
|
||||
onClick={() => setBlurSharpenSettings({ mode: 'blur' })}
|
||||
className={`px-3 py-2 text-xs rounded transition-colors ${
|
||||
blurSharpenSettings.mode === 'blur'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
Blur
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBlurSharpenSettings({ mode: 'sharpen' })}
|
||||
className={`px-3 py-2 text-xs rounded transition-colors ${
|
||||
blurSharpenSettings.mode === 'sharpen'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
Sharpen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Size</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{blurSharpenSettings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={500}
|
||||
value={blurSharpenSettings.size}
|
||||
onChange={(e) => setBlurSharpenSettings({ size: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Strength</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{blurSharpenSettings.strength}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={blurSharpenSettings.strength}
|
||||
onChange={(e) => setBlurSharpenSettings({ strength: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blurSharpenSettings.sampleAllLayers}
|
||||
onChange={(e) => setBlurSharpenSettings({ sampleAllLayers: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Sample All Layers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Paintbrush, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function BrushToolPanel() {
|
||||
const { brushSettings, setBrushSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setBrushSettings({
|
||||
size: 20,
|
||||
hardness: 100,
|
||||
opacity: 1,
|
||||
flow: 1,
|
||||
color: '#000000',
|
||||
blendMode: 'normal',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paintbrush size={16} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Brush</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Color</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={brushSettings.color}
|
||||
onChange={(e) => setBrushSettings({ color: e.target.value })}
|
||||
className="w-10 h-10 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={brushSettings.color}
|
||||
onChange={(e) => setBrushSettings({ color: e.target.value })}
|
||||
className="flex-1 px-2 py-1 text-xs font-mono bg-secondary/50 border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Size</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{brushSettings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={500}
|
||||
value={brushSettings.size}
|
||||
onChange={(e) => setBrushSettings({ size: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Hardness</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{brushSettings.hardness}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={brushSettings.hardness}
|
||||
onChange={(e) => setBrushSettings({ hardness: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Opacity</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{Math.round(brushSettings.opacity * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={brushSettings.opacity * 100}
|
||||
onChange={(e) => setBrushSettings({ opacity: Number(e.target.value) / 100 })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Flow</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{Math.round(brushSettings.flow * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={brushSettings.flow * 100}
|
||||
onChange={(e) => setBrushSettings({ flow: Number(e.target.value) / 100 })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Blend Mode</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{(['normal', 'multiply', 'screen', 'overlay'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setBrushSettings({ blendMode: mode })}
|
||||
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
|
||||
brushSettings.blendMode === mode
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { ChannelMixerAdjustment, ChannelMixerChannel } from '../../../types/adjustments';
|
||||
import { DEFAULT_CHANNEL_MIXER } from '../../../types/adjustments';
|
||||
import { Blend, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type OutputChannel = 'red' | 'green' | 'blue';
|
||||
|
||||
const CHANNEL_COLORS: Record<OutputChannel, string> = {
|
||||
red: 'bg-red-500',
|
||||
green: 'bg-green-500',
|
||||
blue: 'bg-blue-500',
|
||||
};
|
||||
|
||||
function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) {
|
||||
const percentage = ((value + 200) / 400) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${color}`} />
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={-200}
|
||||
max={200}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--secondary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelMixerSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [activeChannel, setActiveChannel] = useState<OutputChannel>('red');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const channelMixer = layer.channelMixer;
|
||||
const currentChannel = channelMixer[activeChannel];
|
||||
|
||||
const handleValueChange = (key: keyof ChannelMixerChannel, value: number) => {
|
||||
updateLayer(layer.id, {
|
||||
channelMixer: {
|
||||
...channelMixer,
|
||||
[activeChannel]: {
|
||||
...currentChannel,
|
||||
[key]: value,
|
||||
},
|
||||
} as ChannelMixerAdjustment,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMonochromeChange = (monochrome: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
channelMixer: {
|
||||
...channelMixer,
|
||||
monochrome,
|
||||
} as ChannelMixerAdjustment,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
channelMixer: {
|
||||
...channelMixer,
|
||||
enabled,
|
||||
} as ChannelMixerAdjustment,
|
||||
});
|
||||
};
|
||||
|
||||
const resetChannelMixer = () => {
|
||||
updateLayer(layer.id, {
|
||||
channelMixer: { ...DEFAULT_CHANNEL_MIXER },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Blend size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Channel Mixer</span>
|
||||
{channelMixer.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channelMixer.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{(['red', 'green', 'blue'] as OutputChannel[]).map((channel) => (
|
||||
<button
|
||||
key={channel}
|
||||
onClick={() => setActiveChannel(channel)}
|
||||
className={`px-2 py-1 text-[10px] rounded transition-colors ${
|
||||
activeChannel === channel
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block w-2 h-2 rounded-full mr-1 ${CHANNEL_COLORS[channel]}`} />
|
||||
{channel.charAt(0).toUpperCase() + channel.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={resetChannelMixer}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ChannelSlider label="Red" color="bg-red-500" value={currentChannel.red} onChange={(v) => handleValueChange('red', v)} />
|
||||
<ChannelSlider label="Green" color="bg-green-500" value={currentChannel.green} onChange={(v) => handleValueChange('green', v)} />
|
||||
<ChannelSlider label="Blue" color="bg-blue-500" value={currentChannel.blue} onChange={(v) => handleValueChange('blue', v)} />
|
||||
<ChannelSlider label="Constant" color="bg-gray-500" value={currentChannel.constant} onChange={(v) => handleValueChange('constant', v)} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={channelMixer.monochrome}
|
||||
onChange={(e) => handleMonochromeChange(e.target.checked)}
|
||||
className="w-3 h-3 rounded border-border"
|
||||
/>
|
||||
Monochrome
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Stamp, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function CloneStampToolPanel() {
|
||||
const { cloneStampSettings, setCloneStampSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setCloneStampSettings({
|
||||
size: 30,
|
||||
hardness: 50,
|
||||
opacity: 1,
|
||||
flow: 1,
|
||||
aligned: true,
|
||||
sampleAllLayers: false,
|
||||
sourcePoint: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Stamp size={16} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Clone Stamp</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hold Alt/Option and click to set source point, then paint to clone.
|
||||
</p>
|
||||
|
||||
{cloneStampSettings.sourcePoint && (
|
||||
<div className="text-xs text-muted-foreground bg-secondary/50 p-2 rounded">
|
||||
Source: ({Math.round(cloneStampSettings.sourcePoint.x)}, {Math.round(cloneStampSettings.sourcePoint.y)})
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Size</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{cloneStampSettings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={500}
|
||||
value={cloneStampSettings.size}
|
||||
onChange={(e) => setCloneStampSettings({ size: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Hardness</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{cloneStampSettings.hardness}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={cloneStampSettings.hardness}
|
||||
onChange={(e) => setCloneStampSettings({ hardness: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Opacity</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{Math.round(cloneStampSettings.opacity * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={cloneStampSettings.opacity * 100}
|
||||
onChange={(e) => setCloneStampSettings({ opacity: Number(e.target.value) / 100 })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Flow</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{Math.round(cloneStampSettings.flow * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={cloneStampSettings.flow * 100}
|
||||
onChange={(e) => setCloneStampSettings({ flow: Number(e.target.value) / 100 })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cloneStampSettings.aligned}
|
||||
onChange={(e) => setCloneStampSettings({ aligned: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Aligned
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cloneStampSettings.sampleAllLayers}
|
||||
onChange={(e) => setCloneStampSettings({ sampleAllLayers: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Sample All Layers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { ColorBalanceValues } from '../../../types/adjustments';
|
||||
import { DEFAULT_COLOR_BALANCE } from '../../../types/adjustments';
|
||||
import { Palette, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type ToneType = 'shadows' | 'midtones' | 'highlights';
|
||||
|
||||
interface BalanceSliderProps {
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
leftColor: string;
|
||||
rightColor: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function BalanceSlider({
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
leftColor,
|
||||
rightColor,
|
||||
value,
|
||||
onChange,
|
||||
}: BalanceSliderProps) {
|
||||
const percentage = ((value + 100) / 200) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px]" style={{ color: leftColor }}>
|
||||
{leftLabel}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{value}</span>
|
||||
<span className="text-[10px]" style={{ color: rightColor }}>
|
||||
{rightLabel}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={-100}
|
||||
max={100}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-foreground
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${leftColor} 0%, hsl(var(--secondary)) ${percentage}%, ${rightColor} 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColorBalanceSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [activeTone, setActiveTone] = useState<ToneType>('midtones');
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const colorBalance = layer.colorBalance;
|
||||
const currentTone = colorBalance[activeTone];
|
||||
|
||||
const handleToneChange = (key: keyof ColorBalanceValues, value: number) => {
|
||||
updateLayer(layer.id, {
|
||||
colorBalance: {
|
||||
...colorBalance,
|
||||
[activeTone]: {
|
||||
...currentTone,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
colorBalance: {
|
||||
...colorBalance,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
colorBalance: {
|
||||
...colorBalance,
|
||||
preserveLuminosity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetColorBalance = () => {
|
||||
updateLayer(layer.id, {
|
||||
colorBalance: { ...DEFAULT_COLOR_BALANCE },
|
||||
});
|
||||
};
|
||||
|
||||
const tones: { id: ToneType; label: string }[] = [
|
||||
{ id: 'shadows', label: 'Shadows' },
|
||||
{ id: 'midtones', label: 'Midtones' },
|
||||
{ id: 'highlights', label: 'Highlights' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Color Balance</span>
|
||||
{colorBalance.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={colorBalance.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{tones.map((tone) => (
|
||||
<button
|
||||
key={tone.id}
|
||||
onClick={() => setActiveTone(tone.id)}
|
||||
className={`px-2 py-1 text-[10px] rounded transition-colors ${
|
||||
activeTone === tone.id
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tone.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={resetColorBalance}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset Color Balance"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-1">
|
||||
<BalanceSlider
|
||||
leftLabel="Cyan"
|
||||
rightLabel="Red"
|
||||
leftColor="#00bcd4"
|
||||
rightColor="#f44336"
|
||||
value={currentTone.cyanRed}
|
||||
onChange={(v) => handleToneChange('cyanRed', v)}
|
||||
/>
|
||||
|
||||
<BalanceSlider
|
||||
leftLabel="Magenta"
|
||||
rightLabel="Green"
|
||||
leftColor="#e91e63"
|
||||
rightColor="#4caf50"
|
||||
value={currentTone.magentaGreen}
|
||||
onChange={(v) => handleToneChange('magentaGreen', v)}
|
||||
/>
|
||||
|
||||
<BalanceSlider
|
||||
leftLabel="Yellow"
|
||||
rightLabel="Blue"
|
||||
leftColor="#ffeb3b"
|
||||
rightColor="#2196f3"
|
||||
value={currentTone.yellowBlue}
|
||||
onChange={(v) => handleToneChange('yellowBlue', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 pt-2 border-t border-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={colorBalance.preserveLuminosity}
|
||||
onChange={(e) => handlePreserveLuminosityChange(e.target.checked)}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">Preserve Luminosity</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { getAllHarmonies, type HarmonyType } from '../../../utils/color-harmony';
|
||||
import { Palette, Copy, Check } from 'lucide-react';
|
||||
import { ColorPalettes, QuickColorSwatches } from '../../ui/ColorPalettes';
|
||||
import { SavedColorsSection } from '../../ui/SavedColorsSection';
|
||||
import { useColorStore } from '../../../stores/color-store';
|
||||
|
||||
interface Props {
|
||||
baseColor: string;
|
||||
onColorSelect?: (color: string) => void;
|
||||
}
|
||||
|
||||
export function ColorHarmonySection({ baseColor, onColorSelect }: Props) {
|
||||
const [copiedColor, setCopiedColor] = useState<string | null>(null);
|
||||
const [selectedHarmony, setSelectedHarmony] = useState<HarmonyType>('complementary');
|
||||
const { addRecentColor } = useColorStore();
|
||||
|
||||
const isValidHex = /^#[0-9A-Fa-f]{6}$/.test(baseColor);
|
||||
if (!isValidHex) return null;
|
||||
|
||||
const harmonies = getAllHarmonies(baseColor);
|
||||
const activeHarmony = harmonies.find((h) => h.type === selectedHarmony) ?? harmonies[0];
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
addRecentColor(color);
|
||||
onColorSelect?.(color);
|
||||
};
|
||||
|
||||
const handleCopyColor = async (color: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(color);
|
||||
setCopiedColor(color);
|
||||
setTimeout(() => setCopiedColor(null), 1500);
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-muted-foreground" />
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Color Harmony
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{harmonies.map((harmony) => (
|
||||
<button
|
||||
key={harmony.type}
|
||||
onClick={() => setSelectedHarmony(harmony.type)}
|
||||
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
|
||||
selectedHarmony === harmony.type
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-secondary-foreground hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{harmony.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-secondary/50 rounded-lg space-y-2">
|
||||
<div className="flex gap-1.5">
|
||||
{activeHarmony.colors.map((color, index) => (
|
||||
<div key={index} className="flex-1 flex flex-col items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleColorSelect(color)}
|
||||
className="w-full aspect-square rounded-lg border border-border hover:ring-2 hover:ring-primary/50 transition-all cursor-pointer"
|
||||
style={{ backgroundColor: color }}
|
||||
title={`Click to apply ${color}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopyColor(color)}
|
||||
className="flex items-center gap-1 text-[9px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copiedColor === color ? (
|
||||
<Check size={10} className="text-green-500" />
|
||||
) : (
|
||||
<Copy size={10} />
|
||||
)}
|
||||
<span className="font-mono">{color.toUpperCase()}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[9px] text-muted-foreground text-center">
|
||||
Click a color to apply, or copy its hex code
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{onColorSelect && (
|
||||
<>
|
||||
<SavedColorsSection
|
||||
onColorSelect={handleColorSelect}
|
||||
selectedColor={baseColor}
|
||||
currentColor={baseColor}
|
||||
/>
|
||||
<QuickColorSwatches onColorSelect={handleColorSelect} selectedColor={baseColor} />
|
||||
<ColorPalettes onColorSelect={handleColorSelect} selectedColor={baseColor} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { useUIStore, CropAspectRatio } from '../../../stores/ui-store';
|
||||
import type { ImageLayer } from '../../../types/project';
|
||||
import { Crop, Check, X, RotateCcw, Lock, Unlock } from 'lucide-react';
|
||||
|
||||
const imageCache = new Map<string, HTMLImageElement>();
|
||||
function getCachedImage(src: string): HTMLImageElement | null {
|
||||
if (!src) return null;
|
||||
if (imageCache.has(src)) return imageCache.get(src)!;
|
||||
const img = new Image();
|
||||
img.src = src;
|
||||
imageCache.set(src, img);
|
||||
return img;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
const ASPECT_RATIOS: { value: CropAspectRatio; label: string; ratio?: number }[] = [
|
||||
{ value: 'free', label: 'Free' },
|
||||
{ value: 'original', label: 'Original' },
|
||||
{ value: '1:1', label: '1:1', ratio: 1 },
|
||||
{ value: '4:3', label: '4:3', ratio: 4 / 3 },
|
||||
{ value: '3:4', label: '3:4', ratio: 3 / 4 },
|
||||
{ value: '16:9', label: '16:9', ratio: 16 / 9 },
|
||||
{ value: '9:16', label: '9:16', ratio: 9 / 16 },
|
||||
{ value: '3:2', label: '3:2', ratio: 3 / 2 },
|
||||
{ value: '2:3', label: '2:3', ratio: 2 / 3 },
|
||||
];
|
||||
|
||||
export function CropSection({ layer }: Props) {
|
||||
const { updateLayer, project } = useProjectStore();
|
||||
const { crop, startCrop, cancelCrop, applyCrop, setCropAspectRatio, updateCropRect, setCropLockAspect } = useUIStore();
|
||||
|
||||
const lockAspect = crop.lockAspect;
|
||||
const setLockAspect = setCropLockAspect;
|
||||
|
||||
const isCropping = crop.isActive && crop.layerId === layer.id;
|
||||
|
||||
const imageDimensions = useMemo(() => {
|
||||
if (!project) return null;
|
||||
const asset = project.assets[layer.sourceId];
|
||||
if (!asset) return null;
|
||||
const src = asset.blobUrl ?? asset.dataUrl;
|
||||
if (!src) return null;
|
||||
const img = getCachedImage(src);
|
||||
if (img && img.complete && img.naturalWidth > 0) {
|
||||
return { width: img.naturalWidth, height: img.naturalHeight };
|
||||
}
|
||||
return asset.width && asset.height ? { width: asset.width, height: asset.height } : null;
|
||||
}, [project, layer.sourceId]);
|
||||
|
||||
const handleStartCrop = useCallback(() => {
|
||||
const initialRect = layer.cropRect ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: layer.transform.width,
|
||||
height: layer.transform.height,
|
||||
};
|
||||
startCrop(layer.id, initialRect);
|
||||
}, [layer, startCrop]);
|
||||
|
||||
const handleApplyCrop = useCallback(() => {
|
||||
const result = applyCrop();
|
||||
if (result && result.cropRect) {
|
||||
const existingCropRect = layer.cropRect;
|
||||
let finalCropRect: { x: number; y: number; width: number; height: number };
|
||||
|
||||
if (existingCropRect) {
|
||||
const scaleX = existingCropRect.width / layer.transform.width;
|
||||
const scaleY = existingCropRect.height / layer.transform.height;
|
||||
finalCropRect = {
|
||||
x: existingCropRect.x + result.cropRect.x * scaleX,
|
||||
y: existingCropRect.y + result.cropRect.y * scaleY,
|
||||
width: result.cropRect.width * scaleX,
|
||||
height: result.cropRect.height * scaleY,
|
||||
};
|
||||
} else if (imageDimensions) {
|
||||
const scaleX = imageDimensions.width / layer.transform.width;
|
||||
const scaleY = imageDimensions.height / layer.transform.height;
|
||||
finalCropRect = {
|
||||
x: result.cropRect.x * scaleX,
|
||||
y: result.cropRect.y * scaleY,
|
||||
width: result.cropRect.width * scaleX,
|
||||
height: result.cropRect.height * scaleY,
|
||||
};
|
||||
} else {
|
||||
finalCropRect = result.cropRect;
|
||||
}
|
||||
|
||||
updateLayer<ImageLayer>(result.layerId, {
|
||||
cropRect: finalCropRect,
|
||||
transform: {
|
||||
...layer.transform,
|
||||
x: layer.transform.x + result.cropRect.x,
|
||||
y: layer.transform.y + result.cropRect.y,
|
||||
width: result.cropRect.width,
|
||||
height: result.cropRect.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [applyCrop, updateLayer, layer, imageDimensions]);
|
||||
|
||||
const handleResetCrop = useCallback(() => {
|
||||
if (isCropping) {
|
||||
updateCropRect({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: layer.transform.width,
|
||||
height: layer.transform.height,
|
||||
});
|
||||
} else {
|
||||
updateLayer<ImageLayer>(layer.id, { cropRect: null });
|
||||
}
|
||||
}, [isCropping, layer, updateCropRect, updateLayer]);
|
||||
|
||||
const handleAspectRatioChange = useCallback(
|
||||
(ratio: CropAspectRatio) => {
|
||||
setCropAspectRatio(ratio);
|
||||
|
||||
if (!crop.cropRect) return;
|
||||
|
||||
const aspectConfig = ASPECT_RATIOS.find((r) => r.value === ratio);
|
||||
if (!aspectConfig?.ratio) return;
|
||||
|
||||
const currentWidth = crop.cropRect.width;
|
||||
const currentHeight = crop.cropRect.height;
|
||||
const currentCenterX = crop.cropRect.x + currentWidth / 2;
|
||||
const currentCenterY = crop.cropRect.y + currentHeight / 2;
|
||||
|
||||
let newWidth = currentWidth;
|
||||
let newHeight = currentWidth / aspectConfig.ratio;
|
||||
|
||||
if (newHeight > layer.transform.height) {
|
||||
newHeight = layer.transform.height;
|
||||
newWidth = newHeight * aspectConfig.ratio;
|
||||
}
|
||||
|
||||
if (newWidth > layer.transform.width) {
|
||||
newWidth = layer.transform.width;
|
||||
newHeight = newWidth / aspectConfig.ratio;
|
||||
}
|
||||
|
||||
let newX = currentCenterX - newWidth / 2;
|
||||
let newY = currentCenterY - newHeight / 2;
|
||||
|
||||
newX = Math.max(0, Math.min(newX, layer.transform.width - newWidth));
|
||||
newY = Math.max(0, Math.min(newY, layer.transform.height - newHeight));
|
||||
|
||||
updateCropRect({
|
||||
x: Math.round(newX),
|
||||
y: Math.round(newY),
|
||||
width: Math.round(newWidth),
|
||||
height: Math.round(newHeight),
|
||||
});
|
||||
},
|
||||
[crop.cropRect, layer.transform, setCropAspectRatio, updateCropRect]
|
||||
);
|
||||
|
||||
const hasCrop = layer.cropRect !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Crop</h4>
|
||||
{hasCrop && !isCropping && (
|
||||
<button
|
||||
onClick={handleResetCrop}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isCropping ? (
|
||||
<button
|
||||
onClick={handleStartCrop}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Crop size={16} />
|
||||
{hasCrop ? 'Adjust Crop' : 'Crop Image'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3 p-3 bg-secondary/30 rounded-lg border border-border/50">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] font-medium text-foreground">Aspect Ratio</label>
|
||||
<button
|
||||
onClick={() => setLockAspect(!lockAspect)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
lockAspect ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{lockAspect ? <Lock size={12} /> : <Unlock size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{ASPECT_RATIOS.map((ar) => (
|
||||
<button
|
||||
key={ar.value}
|
||||
onClick={() => handleAspectRatioChange(ar.value)}
|
||||
className={`px-2 py-1.5 text-[10px] font-medium rounded transition-colors ${
|
||||
crop.aspectRatio === ar.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/80'
|
||||
}`}
|
||||
>
|
||||
{ar.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{crop.cropRect && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] font-medium text-foreground">Crop Area</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] text-muted-foreground">X</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(crop.cropRect.x)}
|
||||
onChange={(e) =>
|
||||
updateCropRect({
|
||||
...crop.cropRect!,
|
||||
x: Math.max(0, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] text-muted-foreground">Y</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(crop.cropRect.y)}
|
||||
onChange={(e) =>
|
||||
updateCropRect({
|
||||
...crop.cropRect!,
|
||||
y: Math.max(0, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] text-muted-foreground">Width</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(crop.cropRect.width)}
|
||||
onChange={(e) =>
|
||||
updateCropRect({
|
||||
...crop.cropRect!,
|
||||
width: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] text-muted-foreground">Height</label>
|
||||
<input
|
||||
type="number"
|
||||
value={Math.round(crop.cropRect.height)}
|
||||
onChange={(e) =>
|
||||
updateCropRect({
|
||||
...crop.cropRect!,
|
||||
height: Math.max(1, Number(e.target.value)),
|
||||
})
|
||||
}
|
||||
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleResetCrop}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelCrop}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApplyCrop}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg font-medium text-[11px] hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Check size={12} />
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { CurvePoint } from '../../../types/adjustments';
|
||||
import { DEFAULT_CURVES } from '../../../types/adjustments';
|
||||
import { TrendingUp, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type ChannelType = 'master' | 'red' | 'green' | 'blue';
|
||||
|
||||
interface CurveEditorProps {
|
||||
points: CurvePoint[];
|
||||
onChange: (points: CurvePoint[]) => void;
|
||||
channel: ChannelType;
|
||||
}
|
||||
|
||||
function CurveEditor({ points, onChange, channel }: CurveEditorProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
|
||||
const channelColors: Record<ChannelType, string> = {
|
||||
master: 'hsl(var(--foreground))',
|
||||
red: '#ef4444',
|
||||
green: '#22c55e',
|
||||
blue: '#3b82f6',
|
||||
};
|
||||
|
||||
const sortedPoints = [...points].sort((a, b) => a.input - b.input);
|
||||
|
||||
const getPathD = useCallback(() => {
|
||||
if (sortedPoints.length < 2) return '';
|
||||
|
||||
const pathPoints = sortedPoints.map((p) => ({
|
||||
x: (p.input / 255) * 100,
|
||||
y: 100 - (p.output / 255) * 100,
|
||||
}));
|
||||
|
||||
let d = `M ${pathPoints[0].x} ${pathPoints[0].y}`;
|
||||
|
||||
for (let i = 1; i < pathPoints.length; i++) {
|
||||
const prev = pathPoints[i - 1];
|
||||
const curr = pathPoints[i];
|
||||
const cpx = (prev.x + curr.x) / 2;
|
||||
d += ` C ${cpx} ${prev.y}, ${cpx} ${curr.y}, ${curr.x} ${curr.y}`;
|
||||
}
|
||||
|
||||
return d;
|
||||
}, [sortedPoints]);
|
||||
|
||||
const handleMouseDown = (index: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (index === 0 || index === sortedPoints.length - 1) return;
|
||||
setDraggingIndex(index);
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (draggingIndex === null || !svgRef.current) return;
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 255;
|
||||
const y = (1 - (e.clientY - rect.top) / rect.height) * 255;
|
||||
|
||||
const newPoints = [...sortedPoints];
|
||||
newPoints[draggingIndex] = {
|
||||
input: Math.max(1, Math.min(254, Math.round(x))),
|
||||
output: Math.max(0, Math.min(255, Math.round(y))),
|
||||
};
|
||||
onChange(newPoints);
|
||||
},
|
||||
[draggingIndex, sortedPoints, onChange]
|
||||
);
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDraggingIndex(null);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (draggingIndex !== null || !svgRef.current) return;
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 255;
|
||||
const y = (1 - (e.clientY - rect.top) / rect.height) * 255;
|
||||
|
||||
if (sortedPoints.length >= 14) return;
|
||||
|
||||
const newPoint: CurvePoint = {
|
||||
input: Math.round(x),
|
||||
output: Math.round(y),
|
||||
};
|
||||
onChange([...sortedPoints, newPoint]);
|
||||
};
|
||||
|
||||
const handleDoubleClick = (index: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (index === 0 || index === sortedPoints.length - 1) return;
|
||||
const newPoints = sortedPoints.filter((_, i) => i !== index);
|
||||
onChange(newPoints);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
viewBox="0 0 100 100"
|
||||
className="w-full h-32 bg-secondary/50 rounded border border-border cursor-crosshair"
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<defs>
|
||||
<pattern id="grid" width="25" height="25" patternUnits="userSpaceOnUse">
|
||||
<path d="M 25 0 L 0 0 0 25" fill="none" stroke="hsl(var(--border))" strokeWidth="0.5" opacity="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="100" height="100" fill="url(#grid)" />
|
||||
|
||||
<line x1="0" y1="100" x2="100" y2="0" stroke="hsl(var(--muted-foreground))" strokeWidth="0.5" strokeDasharray="2 2" opacity="0.3" />
|
||||
|
||||
<path d={getPathD()} fill="none" stroke={channelColors[channel]} strokeWidth="2" />
|
||||
|
||||
{sortedPoints.map((point, index) => {
|
||||
const x = (point.input / 255) * 100;
|
||||
const y = 100 - (point.output / 255) * 100;
|
||||
const isEndpoint = index === 0 || index === sortedPoints.length - 1;
|
||||
const isHovered = hoverIndex === index;
|
||||
const isDragging = draggingIndex === index;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r={isDragging || isHovered ? 4 : 3}
|
||||
fill={isEndpoint ? 'hsl(var(--muted-foreground))' : channelColors[channel]}
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="1"
|
||||
className={isEndpoint ? 'cursor-not-allowed' : 'cursor-move'}
|
||||
onMouseDown={(e) => handleMouseDown(index, e)}
|
||||
onDoubleClick={(e) => handleDoubleClick(index, e)}
|
||||
onMouseEnter={() => setHoverIndex(index)}
|
||||
onMouseLeave={() => setHoverIndex(null)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex justify-between mt-1 text-[9px] text-muted-foreground">
|
||||
<span>0</span>
|
||||
<span>Input</span>
|
||||
<span>255</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CurvesSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelType>('master');
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const curves = layer.curves;
|
||||
|
||||
const handlePointsChange = (points: CurvePoint[]) => {
|
||||
updateLayer(layer.id, {
|
||||
curves: {
|
||||
...curves,
|
||||
[activeChannel]: { points },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
curves: {
|
||||
...curves,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetCurves = () => {
|
||||
updateLayer(layer.id, {
|
||||
curves: { ...DEFAULT_CURVES },
|
||||
});
|
||||
};
|
||||
|
||||
const channelColors: Record<ChannelType, string> = {
|
||||
master: 'bg-foreground',
|
||||
red: 'bg-red-500',
|
||||
green: 'bg-green-500',
|
||||
blue: 'bg-blue-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Curves</span>
|
||||
{curves.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={curves.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => (
|
||||
<button
|
||||
key={channel}
|
||||
onClick={() => setActiveChannel(channel)}
|
||||
className={`px-2 py-1 text-[10px] rounded transition-colors ${
|
||||
activeChannel === channel
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${channelColors[channel]}`} />
|
||||
{channel.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={resetCurves}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset Curves"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CurveEditor
|
||||
points={curves[activeChannel].points}
|
||||
onChange={handlePointsChange}
|
||||
channel={activeChannel}
|
||||
/>
|
||||
|
||||
<p className="text-[9px] text-muted-foreground text-center">
|
||||
Click to add point • Double-click to remove • Drag to adjust
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{value.toFixed(step < 1 ? 0 : 0)}{unit}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DodgeBurnToolPanel() {
|
||||
const { activeTool, dodgeBurnSettings, setDodgeBurnSettings } = useUIStore();
|
||||
|
||||
if (activeTool !== 'dodge' && activeTool !== 'burn') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toolTypes = [
|
||||
{ id: 'dodge' as const, icon: Sun, label: 'Dodge' },
|
||||
{ id: 'burn' as const, icon: Moon, label: 'Burn' },
|
||||
];
|
||||
|
||||
const ranges = [
|
||||
{ id: 'shadows' as const, label: 'Shadows' },
|
||||
{ id: 'midtones' as const, label: 'Midtones' },
|
||||
{ id: 'highlights' as const, label: 'Highlights' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
{dodgeBurnSettings.type === 'dodge' ? (
|
||||
<Sun size={14} className="text-muted-foreground" />
|
||||
) : (
|
||||
<Moon size={14} className="text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium">
|
||||
{dodgeBurnSettings.type === 'dodge' ? 'Dodge Tool' : 'Burn Tool'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Tool</span>
|
||||
<div className="flex gap-1">
|
||||
{toolTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setDodgeBurnSettings({ type: type.id })}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
|
||||
dodgeBurnSettings.type === type.id
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<type.icon size={12} />
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Range</span>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((range) => (
|
||||
<button
|
||||
key={range.id}
|
||||
onClick={() => setDodgeBurnSettings({ range: range.id })}
|
||||
className={`flex-1 px-2 py-1.5 text-[10px] rounded transition-colors ${
|
||||
dodgeBurnSettings.range === range.id
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
label="Exposure"
|
||||
value={dodgeBurnSettings.exposure}
|
||||
min={1}
|
||||
max={100}
|
||||
unit="%"
|
||||
onChange={(v) => setDodgeBurnSettings({ exposure: v })}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
label="Size"
|
||||
value={dodgeBurnSettings.size}
|
||||
min={1}
|
||||
max={500}
|
||||
unit="px"
|
||||
onChange={(v) => setDodgeBurnSettings({ size: v })}
|
||||
/>
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
|
||||
<div
|
||||
className="rounded-full transition-all"
|
||||
style={{
|
||||
width: Math.min(dodgeBurnSettings.size, 80),
|
||||
height: Math.min(dodgeBurnSettings.size, 80),
|
||||
background:
|
||||
dodgeBurnSettings.type === 'dodge'
|
||||
? `radial-gradient(circle, rgba(255,255,255,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`
|
||||
: `radial-gradient(circle, rgba(0,0,0,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
|
||||
{dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} {dodgeBurnSettings.range}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
|
||||
<ul className="text-[9px] text-muted-foreground space-y-0.5">
|
||||
<li>• {dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} selected tonal range</li>
|
||||
<li>• Lower exposure for subtle adjustments</li>
|
||||
<li>• Build up effect with multiple strokes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer, Shadow, InnerShadow, Stroke, Glow } from '../../../types/project';
|
||||
import { Slider } from '@openreel/ui';
|
||||
import { ChevronDown, Droplets, Pencil, Sparkles, CircleDot } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type EffectSection = 'shadow' | 'innerShadow' | 'stroke' | 'glow' | null;
|
||||
|
||||
interface EffectHeaderProps {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function EffectHeader({ icon: Icon, label, enabled, isOpen, onToggle, onEnabledChange }: EffectHeaderProps) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 flex-1 text-left"
|
||||
>
|
||||
<Icon size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => onEnabledChange(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-8 h-4 bg-muted rounded-full peer peer-checked:bg-primary transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:after:translate-x-4" />
|
||||
</label>
|
||||
<button onClick={onToggle} className="p-0.5">
|
||||
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EffectsSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [openSection, setOpenSection] = useState<EffectSection>('shadow');
|
||||
|
||||
const handleShadowChange = (updates: Partial<Shadow>) => {
|
||||
updateLayer(layer.id, {
|
||||
shadow: { ...layer.shadow, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
const handleInnerShadowChange = (updates: Partial<InnerShadow>) => {
|
||||
updateLayer(layer.id, {
|
||||
innerShadow: { ...(layer.innerShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 2, offsetY: 2 }), ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrokeChange = (updates: Partial<Stroke>) => {
|
||||
updateLayer(layer.id, {
|
||||
stroke: { ...layer.stroke, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
const handleGlowChange = (updates: Partial<Glow>) => {
|
||||
updateLayer(layer.id, {
|
||||
glow: { ...layer.glow, ...updates },
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSection = (section: EffectSection) => {
|
||||
setOpenSection(openSection === section ? null : section);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 space-y-2">
|
||||
|
||||
<div>
|
||||
<EffectHeader
|
||||
icon={Droplets}
|
||||
label="Drop Shadow"
|
||||
enabled={layer.shadow.enabled}
|
||||
isOpen={openSection === 'shadow'}
|
||||
onToggle={() => toggleSection('shadow')}
|
||||
onEnabledChange={(enabled) => handleShadowChange({ enabled })}
|
||||
/>
|
||||
{openSection === 'shadow' && (
|
||||
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={layer.shadow.color.startsWith('rgba') ? '#000000' : layer.shadow.color}
|
||||
onChange={(e) => handleShadowChange({ color: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-input cursor-pointer"
|
||||
disabled={!layer.shadow.enabled}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={layer.shadow.color}
|
||||
onChange={(e) => handleShadowChange({ color: e.target.value })}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
|
||||
disabled={!layer.shadow.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Blur</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.shadow.blur}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.shadow.blur]}
|
||||
onValueChange={([blur]) => handleShadowChange({ blur })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!layer.shadow.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Offset X</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.shadow.offsetX}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.shadow.offsetX]}
|
||||
onValueChange={([offsetX]) => handleShadowChange({ offsetX })}
|
||||
min={-50}
|
||||
max={50}
|
||||
step={1}
|
||||
disabled={!layer.shadow.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Offset Y</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.shadow.offsetY}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.shadow.offsetY]}
|
||||
onValueChange={([offsetY]) => handleShadowChange({ offsetY })}
|
||||
min={-50}
|
||||
max={50}
|
||||
step={1}
|
||||
disabled={!layer.shadow.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EffectHeader
|
||||
icon={CircleDot}
|
||||
label="Inner Shadow"
|
||||
enabled={layer.innerShadow?.enabled ?? false}
|
||||
isOpen={openSection === 'innerShadow'}
|
||||
onToggle={() => toggleSection('innerShadow')}
|
||||
onEnabledChange={(enabled) => handleInnerShadowChange({ enabled })}
|
||||
/>
|
||||
{openSection === 'innerShadow' && (
|
||||
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={(layer.innerShadow?.color ?? 'rgba(0, 0, 0, 0.5)').startsWith('rgba') ? '#000000' : layer.innerShadow?.color ?? '#000000'}
|
||||
onChange={(e) => handleInnerShadowChange({ color: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-input cursor-pointer"
|
||||
disabled={!layer.innerShadow?.enabled}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={layer.innerShadow?.color ?? 'rgba(0, 0, 0, 0.5)'}
|
||||
onChange={(e) => handleInnerShadowChange({ color: e.target.value })}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
|
||||
disabled={!layer.innerShadow?.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Blur</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.blur ?? 10}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.innerShadow?.blur ?? 10]}
|
||||
onValueChange={([blur]) => handleInnerShadowChange({ blur })}
|
||||
min={0}
|
||||
max={50}
|
||||
step={1}
|
||||
disabled={!layer.innerShadow?.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Offset X</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.offsetX ?? 2}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.innerShadow?.offsetX ?? 2]}
|
||||
onValueChange={([offsetX]) => handleInnerShadowChange({ offsetX })}
|
||||
min={-30}
|
||||
max={30}
|
||||
step={1}
|
||||
disabled={!layer.innerShadow?.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Offset Y</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.offsetY ?? 2}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.innerShadow?.offsetY ?? 2]}
|
||||
onValueChange={([offsetY]) => handleInnerShadowChange({ offsetY })}
|
||||
min={-30}
|
||||
max={30}
|
||||
step={1}
|
||||
disabled={!layer.innerShadow?.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EffectHeader
|
||||
icon={Pencil}
|
||||
label="Stroke"
|
||||
enabled={layer.stroke.enabled}
|
||||
isOpen={openSection === 'stroke'}
|
||||
onToggle={() => toggleSection('stroke')}
|
||||
onEnabledChange={(enabled) => handleStrokeChange({ enabled })}
|
||||
/>
|
||||
{openSection === 'stroke' && (
|
||||
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={layer.stroke.color}
|
||||
onChange={(e) => handleStrokeChange({ color: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-input cursor-pointer"
|
||||
disabled={!layer.stroke.enabled}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={layer.stroke.color}
|
||||
onChange={(e) => handleStrokeChange({ color: e.target.value })}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
|
||||
disabled={!layer.stroke.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Width</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.stroke.width}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.stroke.width]}
|
||||
onValueChange={([width]) => handleStrokeChange({ width })}
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
disabled={!layer.stroke.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1.5">Style</label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['solid', 'dashed', 'dotted'] as const).map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleStrokeChange({ style })}
|
||||
disabled={!layer.stroke.enabled}
|
||||
className={`px-2 py-1.5 text-[10px] rounded capitalize transition-colors ${
|
||||
layer.stroke.style === style
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-secondary-foreground hover:bg-accent disabled:opacity-50'
|
||||
}`}
|
||||
>
|
||||
{style}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<EffectHeader
|
||||
icon={Sparkles}
|
||||
label="Outer Glow"
|
||||
enabled={layer.glow.enabled}
|
||||
isOpen={openSection === 'glow'}
|
||||
onToggle={() => toggleSection('glow')}
|
||||
onEnabledChange={(enabled) => handleGlowChange({ enabled })}
|
||||
/>
|
||||
{openSection === 'glow' && (
|
||||
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
|
||||
<div>
|
||||
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={layer.glow.color}
|
||||
onChange={(e) => handleGlowChange({ color: e.target.value })}
|
||||
className="w-8 h-8 rounded border border-input cursor-pointer"
|
||||
disabled={!layer.glow.enabled}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={layer.glow.color}
|
||||
onChange={(e) => handleGlowChange({ color: e.target.value })}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary font-mono disabled:opacity-50"
|
||||
disabled={!layer.glow.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Blur</label>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.glow.blur}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.glow.blur]}
|
||||
onValueChange={([blur]) => handleGlowChange({ blur })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!layer.glow.enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-[10px] text-muted-foreground">Intensity</label>
|
||||
<span className="text-[10px] text-muted-foreground">{Math.round(layer.glow.intensity * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[layer.glow.intensity]}
|
||||
onValueChange={([intensity]) => handleGlowChange({ intensity })}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
disabled={!layer.glow.enabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Eraser, Square, Pencil, Circle } from 'lucide-react';
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{value.toFixed(step < 1 ? 0 : 0)}{unit}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EraserToolPanel() {
|
||||
const { activeTool, eraserSettings, setEraserSettings } = useUIStore();
|
||||
|
||||
if (activeTool !== 'eraser') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eraserModes = [
|
||||
{ id: 'brush' as const, icon: Circle, label: 'Brush' },
|
||||
{ id: 'pencil' as const, icon: Pencil, label: 'Pencil' },
|
||||
{ id: 'block' as const, icon: Square, label: 'Block' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-3 py-2 flex items-center gap-2">
|
||||
<Eraser size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Eraser Tool</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Mode</span>
|
||||
<div className="flex gap-1">
|
||||
{eraserModes.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => setEraserSettings({ mode: mode.id })}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
|
||||
eraserSettings.mode === mode.id
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
>
|
||||
<mode.icon size={12} />
|
||||
{mode.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
label="Size"
|
||||
value={eraserSettings.size}
|
||||
min={1}
|
||||
max={500}
|
||||
unit="px"
|
||||
onChange={(v) => setEraserSettings({ size: v })}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
label="Hardness"
|
||||
value={eraserSettings.hardness}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="%"
|
||||
onChange={(v) => setEraserSettings({ hardness: v })}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
label="Opacity"
|
||||
value={Math.round(eraserSettings.opacity * 100)}
|
||||
min={1}
|
||||
max={100}
|
||||
unit="%"
|
||||
onChange={(v) => setEraserSettings({ opacity: v / 100 })}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
label="Flow"
|
||||
value={Math.round(eraserSettings.flow * 100)}
|
||||
min={1}
|
||||
max={100}
|
||||
unit="%"
|
||||
onChange={(v) => setEraserSettings({ flow: v / 100 })}
|
||||
/>
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
|
||||
<div
|
||||
className="rounded-full bg-foreground transition-all"
|
||||
style={{
|
||||
width: Math.min(eraserSettings.size, 100),
|
||||
height: Math.min(eraserSettings.size, 100),
|
||||
opacity: eraserSettings.opacity,
|
||||
filter: `blur(${(100 - eraserSettings.hardness) / 20}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
|
||||
Brush preview
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
|
||||
<ul className="text-[9px] text-muted-foreground space-y-0.5">
|
||||
<li>• Hold Shift for straight lines</li>
|
||||
<li>• [ and ] to adjust size</li>
|
||||
<li>• Shift+[ and ] for hardness</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { ImageLayer, Filter } from '../../../types/project';
|
||||
import { Sparkles, Check } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
interface FilterPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'basic' | 'vintage' | 'cinematic' | 'mood';
|
||||
filters: Filter;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
const FILTER_PRESETS: FilterPreset[] = [
|
||||
{
|
||||
id: 'original',
|
||||
name: 'Original',
|
||||
category: 'basic',
|
||||
filters: { brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'vivid',
|
||||
name: 'Vivid',
|
||||
category: 'basic',
|
||||
filters: { brightness: 105, contrast: 115, saturation: 130, hue: 0, exposure: 0, vibrance: 30, highlights: 0, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'warm',
|
||||
name: 'Warm',
|
||||
category: 'mood',
|
||||
filters: { brightness: 105, contrast: 105, saturation: 110, hue: 15, exposure: 5, vibrance: 15, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'cool',
|
||||
name: 'Cool',
|
||||
category: 'mood',
|
||||
filters: { brightness: 100, contrast: 105, saturation: 95, hue: -15, exposure: 0, vibrance: 0, highlights: 5, shadows: 0, clarity: 5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'bw',
|
||||
name: 'B&W',
|
||||
category: 'basic',
|
||||
filters: { brightness: 105, contrast: 115, saturation: 0, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 20, grain: 5, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'vintage',
|
||||
name: 'Vintage',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 95, contrast: 90, saturation: 75, hue: 20, exposure: -5, vibrance: -10, highlights: -10, shadows: 15, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 30, grain: 15, sepia: 20, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'fade',
|
||||
name: 'Fade',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 110, contrast: 85, saturation: 80, hue: 0, exposure: 5, vibrance: -5, highlights: 10, shadows: 20, clarity: -10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 15, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'dramatic',
|
||||
name: 'Dramatic',
|
||||
category: 'cinematic',
|
||||
filters: { brightness: 95, contrast: 130, saturation: 90, hue: 0, exposure: -5, vibrance: 10, highlights: -15, shadows: -10, clarity: 25, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 15, vignette: 25, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'moody',
|
||||
name: 'Moody',
|
||||
category: 'mood',
|
||||
filters: { brightness: 90, contrast: 110, saturation: 85, hue: -10, exposure: -10, vibrance: 0, highlights: -20, shadows: 5, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 35, grain: 5, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'bright',
|
||||
name: 'Bright',
|
||||
category: 'basic',
|
||||
filters: { brightness: 120, contrast: 105, saturation: 105, hue: 0, exposure: 15, vibrance: 10, highlights: 10, shadows: 20, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'sepia',
|
||||
name: 'Sepia',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 105, contrast: 95, saturation: 40, hue: 35, exposure: 0, vibrance: -20, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 20, grain: 10, sepia: 50, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'cinematic',
|
||||
name: 'Cinematic',
|
||||
category: 'cinematic',
|
||||
filters: { brightness: 95, contrast: 115, saturation: 95, hue: -5, exposure: 0, vibrance: 5, highlights: -10, shadows: 5, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 20, grain: 3, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'pop',
|
||||
name: 'Pop',
|
||||
category: 'mood',
|
||||
filters: { brightness: 110, contrast: 120, saturation: 140, hue: 5, exposure: 5, vibrance: 40, highlights: 5, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'matte',
|
||||
name: 'Matte',
|
||||
category: 'cinematic',
|
||||
filters: { brightness: 105, contrast: 85, saturation: 90, hue: 0, exposure: 0, vibrance: -5, highlights: 5, shadows: 15, clarity: -5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 10, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'retro',
|
||||
name: 'Retro',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 100, contrast: 95, saturation: 70, hue: 25, exposure: -5, vibrance: -15, highlights: -5, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 25, grain: 20, sepia: 15, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'punch',
|
||||
name: 'Punch',
|
||||
category: 'basic',
|
||||
filters: { brightness: 100, contrast: 125, saturation: 115, hue: 0, exposure: 0, vibrance: 20, highlights: 0, shadows: -10, clarity: 20, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 20, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
function filtersMatch(a: Filter, b: Filter): boolean {
|
||||
return (
|
||||
a.brightness === b.brightness &&
|
||||
a.contrast === b.contrast &&
|
||||
a.saturation === b.saturation &&
|
||||
a.hue === b.hue &&
|
||||
a.exposure === b.exposure &&
|
||||
a.vibrance === b.vibrance &&
|
||||
a.highlights === b.highlights &&
|
||||
a.shadows === b.shadows &&
|
||||
a.clarity === b.clarity &&
|
||||
a.blur === b.blur &&
|
||||
a.blurType === b.blurType &&
|
||||
a.blurAngle === b.blurAngle &&
|
||||
a.sharpen === b.sharpen &&
|
||||
a.vignette === b.vignette &&
|
||||
a.grain === b.grain &&
|
||||
a.sepia === b.sepia &&
|
||||
a.invert === b.invert
|
||||
);
|
||||
}
|
||||
|
||||
function interpolateFilters(target: Filter, intensity: number): Filter {
|
||||
const lerp = (defaultVal: number, targetVal: number) => defaultVal + (targetVal - defaultVal) * (intensity / 100);
|
||||
return {
|
||||
brightness: Math.round(lerp(100, target.brightness)),
|
||||
contrast: Math.round(lerp(100, target.contrast)),
|
||||
saturation: Math.round(lerp(100, target.saturation)),
|
||||
hue: Math.round(lerp(0, target.hue)),
|
||||
exposure: Math.round(lerp(0, target.exposure)),
|
||||
vibrance: Math.round(lerp(0, target.vibrance)),
|
||||
highlights: Math.round(lerp(0, target.highlights)),
|
||||
shadows: Math.round(lerp(0, target.shadows)),
|
||||
clarity: Math.round(lerp(0, target.clarity)),
|
||||
blur: Math.round(lerp(0, target.blur)),
|
||||
blurType: target.blurType,
|
||||
blurAngle: Math.round(lerp(0, target.blurAngle)),
|
||||
sharpen: Math.round(lerp(0, target.sharpen)),
|
||||
vignette: Math.round(lerp(0, target.vignette)),
|
||||
grain: Math.round(lerp(0, target.grain)),
|
||||
sepia: Math.round(lerp(0, target.sepia)),
|
||||
invert: Math.round(lerp(0, target.invert)),
|
||||
};
|
||||
}
|
||||
|
||||
export function FilterPresetsSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [intensity, setIntensity] = useState(100);
|
||||
const [activePresetId, setActivePresetId] = useState<string | null>(() => {
|
||||
const match = FILTER_PRESETS.find((p) => filtersMatch(layer.filters, p.filters));
|
||||
return match?.id ?? null;
|
||||
});
|
||||
|
||||
const currentPreset = useMemo(
|
||||
() => FILTER_PRESETS.find((p) => p.id === activePresetId),
|
||||
[activePresetId]
|
||||
);
|
||||
|
||||
const handlePresetSelect = (preset: FilterPreset) => {
|
||||
setActivePresetId(preset.id);
|
||||
const filters = intensity === 100 ? preset.filters : interpolateFilters(preset.filters, intensity);
|
||||
updateLayer<ImageLayer>(layer.id, { filters });
|
||||
};
|
||||
|
||||
const handleIntensityChange = (newIntensity: number) => {
|
||||
setIntensity(newIntensity);
|
||||
if (currentPreset) {
|
||||
const filters = interpolateFilters(currentPreset.filters, newIntensity);
|
||||
updateLayer<ImageLayer>(layer.id, { filters });
|
||||
}
|
||||
};
|
||||
|
||||
const isOriginal = activePresetId === 'original' || filtersMatch(layer.filters, FILTER_PRESETS[0].filters);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Filters
|
||||
</h4>
|
||||
{!isOriginal && (
|
||||
<button
|
||||
onClick={() => handlePresetSelect(FILTER_PRESETS[0])}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activePresetId && activePresetId !== 'original' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] font-medium text-foreground">Intensity</label>
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{intensity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={intensity}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleIntensityChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{FILTER_PRESETS.map((preset) => {
|
||||
const isActive = activePresetId === preset.id;
|
||||
const previewStyle = getFilterPreviewStyle(preset.filters);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handlePresetSelect(preset)}
|
||||
className={`relative group flex flex-col items-center gap-1 p-2 rounded-lg transition-all ${
|
||||
isActive
|
||||
? 'bg-primary/20 ring-2 ring-primary'
|
||||
: 'bg-secondary/50 hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-10 h-10 rounded-md bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center overflow-hidden"
|
||||
style={previewStyle}
|
||||
>
|
||||
{preset.id === 'original' ? (
|
||||
<Sparkles size={16} className="text-white/80" />
|
||||
) : isActive ? (
|
||||
<Check size={14} className="text-primary" />
|
||||
) : null}
|
||||
</div>
|
||||
<span className={`text-[9px] font-medium truncate w-full text-center ${
|
||||
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
|
||||
}`}>
|
||||
{preset.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getFilterPreviewStyle(filters: Filter): React.CSSProperties {
|
||||
const filterParts: string[] = [];
|
||||
|
||||
if (filters.brightness !== 100) {
|
||||
filterParts.push(`brightness(${filters.brightness}%)`);
|
||||
}
|
||||
if (filters.contrast !== 100) {
|
||||
filterParts.push(`contrast(${filters.contrast}%)`);
|
||||
}
|
||||
if (filters.saturation !== 100) {
|
||||
filterParts.push(`saturate(${filters.saturation}%)`);
|
||||
}
|
||||
if (filters.hue !== 0) {
|
||||
filterParts.push(`hue-rotate(${filters.hue}deg)`);
|
||||
}
|
||||
|
||||
return {
|
||||
filter: filterParts.length > 0 ? filterParts.join(' ') : undefined,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { GradientMapStop } from '../../../types/adjustments';
|
||||
import { DEFAULT_GRADIENT_MAP } from '../../../types/adjustments';
|
||||
import { Paintbrush, RotateCcw, Plus, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const GRADIENT_PRESETS = [
|
||||
{ name: 'B&W', stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }] },
|
||||
{ name: 'Sepia', stops: [{ position: 0, color: '#2b1810' }, { position: 0.5, color: '#8b5a2b' }, { position: 1, color: '#f5deb3' }] },
|
||||
{ name: 'Duotone Blue', stops: [{ position: 0, color: '#001133' }, { position: 1, color: '#66ccff' }] },
|
||||
{ name: 'Duotone Orange', stops: [{ position: 0, color: '#331100' }, { position: 1, color: '#ff9900' }] },
|
||||
{ name: 'Sunset', stops: [{ position: 0, color: '#1a0533' }, { position: 0.5, color: '#ff6b35' }, { position: 1, color: '#f7c59f' }] },
|
||||
];
|
||||
|
||||
export function GradientMapSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const gradientMap = layer.gradientMap;
|
||||
|
||||
const handleStopChange = (index: number, updates: Partial<GradientMapStop>) => {
|
||||
const newStops = [...gradientMap.stops];
|
||||
newStops[index] = { ...newStops[index], ...updates };
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...gradientMap, stops: newStops },
|
||||
});
|
||||
};
|
||||
|
||||
const addStop = () => {
|
||||
const newStops = [...gradientMap.stops, { position: 0.5, color: '#808080' }];
|
||||
newStops.sort((a, b) => a.position - b.position);
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...gradientMap, stops: newStops },
|
||||
});
|
||||
};
|
||||
|
||||
const removeStop = (index: number) => {
|
||||
if (gradientMap.stops.length <= 2) return;
|
||||
const newStops = gradientMap.stops.filter((_, i) => i !== index);
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...gradientMap, stops: newStops },
|
||||
});
|
||||
};
|
||||
|
||||
const handleReverseChange = (reverse: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...gradientMap, reverse },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDitherChange = (dither: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...gradientMap, dither },
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...gradientMap, enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreset = (preset: typeof GRADIENT_PRESETS[0]) => {
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...gradientMap, stops: preset.stops },
|
||||
});
|
||||
};
|
||||
|
||||
const resetGradientMap = () => {
|
||||
updateLayer(layer.id, {
|
||||
gradientMap: { ...DEFAULT_GRADIENT_MAP },
|
||||
});
|
||||
};
|
||||
|
||||
const gradientStyle = `linear-gradient(to right, ${gradientMap.stops
|
||||
.map((s) => `${s.color} ${s.position * 100}%`)
|
||||
.join(', ')})`;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paintbrush size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Gradient Map</span>
|
||||
{gradientMap.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gradientMap.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{GRADIENT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.name}
|
||||
onClick={() => applyPreset(preset)}
|
||||
className="px-2 py-1 text-[9px] bg-secondary/50 hover:bg-secondary rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{preset.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={resetGradientMap}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-6 rounded border border-border"
|
||||
style={{ background: gradientStyle }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{gradientMap.stops.map((stop, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={stop.color}
|
||||
onChange={(e) => handleStopChange(index, { color: e.target.value })}
|
||||
className="w-6 h-6 rounded border-none cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
value={stop.position * 100}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleStopChange(index, { position: Number(e.target.value) / 100 })}
|
||||
className="flex-1 h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-8">{Math.round(stop.position * 100)}%</span>
|
||||
{gradientMap.stops.length > 2 && (
|
||||
<button
|
||||
onClick={() => removeStop(index)}
|
||||
className="p-0.5 text-muted-foreground hover:text-destructive rounded hover:bg-secondary transition-colors"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addStop}
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus size={10} /> Add Stop
|
||||
</button>
|
||||
|
||||
<div className="flex gap-4 pt-1 border-t border-border/50">
|
||||
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gradientMap.reverse}
|
||||
onChange={(e) => handleReverseChange(e.target.checked)}
|
||||
className="w-3 h-3 rounded border-border"
|
||||
/>
|
||||
Reverse
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gradientMap.dither}
|
||||
onChange={(e) => handleDitherChange(e.target.checked)}
|
||||
className="w-3 h-3 rounded border-border"
|
||||
/>
|
||||
Dither
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { SquareStack, RotateCcw, X, Plus } from 'lucide-react';
|
||||
|
||||
const gradientTypes = [
|
||||
{ id: 'linear', label: 'Linear' },
|
||||
{ id: 'radial', label: 'Radial' },
|
||||
{ id: 'angle', label: 'Angle' },
|
||||
{ id: 'reflected', label: 'Reflected' },
|
||||
{ id: 'diamond', label: 'Diamond' },
|
||||
] as const;
|
||||
|
||||
export function GradientToolPanel() {
|
||||
const { gradientSettings, setGradientSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setGradientSettings({
|
||||
type: 'linear',
|
||||
colors: ['#000000', '#ffffff'],
|
||||
opacity: 1,
|
||||
reverse: false,
|
||||
dither: true,
|
||||
});
|
||||
};
|
||||
|
||||
const updateColor = (index: number, color: string) => {
|
||||
const newColors = [...gradientSettings.colors];
|
||||
newColors[index] = color;
|
||||
setGradientSettings({ colors: newColors });
|
||||
};
|
||||
|
||||
const addColor = () => {
|
||||
if (gradientSettings.colors.length >= 5) return;
|
||||
const newColors = [...gradientSettings.colors, '#808080'];
|
||||
setGradientSettings({ colors: newColors });
|
||||
};
|
||||
|
||||
const removeColor = (index: number) => {
|
||||
if (gradientSettings.colors.length <= 2) return;
|
||||
const newColors = gradientSettings.colors.filter((_, i) => i !== index);
|
||||
setGradientSettings({ colors: newColors });
|
||||
};
|
||||
|
||||
const gradientStyle = `linear-gradient(to right, ${gradientSettings.colors.join(', ')})`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SquareStack size={16} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Gradient</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click and drag on canvas to create gradient.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Type</span>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{gradientTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => setGradientSettings({ type: type.id })}
|
||||
className={`px-1 py-1.5 text-[10px] rounded transition-colors ${
|
||||
gradientSettings.type === type.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Preview</span>
|
||||
<div
|
||||
className="h-6 rounded border border-border"
|
||||
style={{ background: gradientStyle }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Colors</span>
|
||||
{gradientSettings.colors.length < 5 && (
|
||||
<button
|
||||
onClick={addColor}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{gradientSettings.colors.map((color, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => updateColor(index, e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(e) => updateColor(index, e.target.value)}
|
||||
className="flex-1 px-2 py-1 text-xs font-mono bg-secondary/50 border border-border rounded"
|
||||
/>
|
||||
{gradientSettings.colors.length > 2 && (
|
||||
<button
|
||||
onClick={() => removeColor(index)}
|
||||
className="p-1 text-muted-foreground hover:text-destructive rounded hover:bg-secondary transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Opacity</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{Math.round(gradientSettings.opacity * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={gradientSettings.opacity * 100}
|
||||
onChange={(e) => setGradientSettings({ opacity: Number(e.target.value) / 100 })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 pt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gradientSettings.reverse}
|
||||
onChange={(e) => setGradientSettings({ reverse: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Reverse
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={gradientSettings.dither}
|
||||
onChange={(e) => setGradientSettings({ dither: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Dither
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Bandage, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function HealingBrushToolPanel() {
|
||||
const { healingBrushSettings, setHealingBrushSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setHealingBrushSettings({
|
||||
size: 30,
|
||||
hardness: 50,
|
||||
mode: 'normal',
|
||||
sourcePoint: null,
|
||||
aligned: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bandage size={16} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Healing Brush</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hold Alt/Option and click to set source, then paint to heal while matching texture and lighting.
|
||||
</p>
|
||||
|
||||
{healingBrushSettings.sourcePoint && (
|
||||
<div className="text-xs text-muted-foreground bg-secondary/50 p-2 rounded">
|
||||
Source: ({Math.round(healingBrushSettings.sourcePoint.x)}, {Math.round(healingBrushSettings.sourcePoint.y)})
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Size</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{healingBrushSettings.size}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={500}
|
||||
value={healingBrushSettings.size}
|
||||
onChange={(e) => setHealingBrushSettings({ size: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Hardness</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{healingBrushSettings.hardness}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={healingBrushSettings.hardness}
|
||||
onChange={(e) => setHealingBrushSettings({ hardness: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{(['normal', 'replace', 'multiply', 'screen'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setHealingBrushSettings({ mode })}
|
||||
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
|
||||
healingBrushSettings.mode === mode
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={healingBrushSettings.aligned}
|
||||
onChange={(e) => setHealingBrushSettings({ aligned: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Aligned
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { ImageLayer, Filter, BlurType } from '../../../types/project';
|
||||
import { Sun, Contrast, Palette, Thermometer, Focus, Sparkles, CircleDot, Scan, Film, Minus, Move, Target, SunMedium, Vibrate, Sunrise, SunDim, Aperture } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
interface AdjustmentSliderProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
defaultValue: number;
|
||||
onChange: (value: number) => void;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
function AdjustmentSlider({ icon, label, value, min, max, defaultValue, onChange, unit = '' }: AdjustmentSliderProps) {
|
||||
const isModified = value !== defaultValue;
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
<label className="text-[11px] text-foreground font-medium">{label}</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[11px] font-mono ${isModified ? 'text-primary' : 'text-muted-foreground'}`}>
|
||||
{value}{unit}
|
||||
</span>
|
||||
{isModified && (
|
||||
<button
|
||||
onClick={() => onChange(defaultValue)}
|
||||
className="text-[9px] text-muted-foreground hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-webkit-slider-thumb]:transition-transform
|
||||
[&::-webkit-slider-thumb]:hover:scale-110"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageAdjustmentsSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
|
||||
const handleFilterChange = (key: keyof Filter, value: number | BlurType) => {
|
||||
updateLayer<ImageLayer>(layer.id, {
|
||||
filters: { ...layer.filters, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlurTypeChange = (type: BlurType) => {
|
||||
updateLayer<ImageLayer>(layer.id, {
|
||||
filters: { ...layer.filters, blurType: type },
|
||||
});
|
||||
};
|
||||
|
||||
const resetAllFilters = () => {
|
||||
updateLayer<ImageLayer>(layer.id, {
|
||||
filters: {
|
||||
brightness: 100,
|
||||
contrast: 100,
|
||||
saturation: 100,
|
||||
hue: 0,
|
||||
exposure: 0,
|
||||
vibrance: 0,
|
||||
highlights: 0,
|
||||
shadows: 0,
|
||||
clarity: 0,
|
||||
blur: 0,
|
||||
blurType: 'gaussian',
|
||||
blurAngle: 0,
|
||||
sharpen: 0,
|
||||
vignette: 0,
|
||||
grain: 0,
|
||||
sepia: 0,
|
||||
invert: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hasModifications =
|
||||
layer.filters.brightness !== 100 ||
|
||||
layer.filters.contrast !== 100 ||
|
||||
layer.filters.saturation !== 100 ||
|
||||
layer.filters.hue !== 0 ||
|
||||
layer.filters.exposure !== 0 ||
|
||||
layer.filters.vibrance !== 0 ||
|
||||
layer.filters.highlights !== 0 ||
|
||||
layer.filters.shadows !== 0 ||
|
||||
layer.filters.clarity !== 0 ||
|
||||
layer.filters.blur !== 0 ||
|
||||
layer.filters.sharpen !== 0 ||
|
||||
layer.filters.vignette !== 0 ||
|
||||
layer.filters.grain !== 0 ||
|
||||
layer.filters.sepia !== 0 ||
|
||||
layer.filters.invert !== 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Adjustments
|
||||
</h4>
|
||||
{hasModifications && (
|
||||
<button
|
||||
onClick={resetAllFilters}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-3 bg-secondary/30 rounded-lg border border-border/50">
|
||||
<AdjustmentSlider
|
||||
icon={<Sun size={12} />}
|
||||
label="Brightness"
|
||||
value={layer.filters.brightness}
|
||||
min={0}
|
||||
max={200}
|
||||
defaultValue={100}
|
||||
onChange={(v) => handleFilterChange('brightness', v)}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Contrast size={12} />}
|
||||
label="Contrast"
|
||||
value={layer.filters.contrast}
|
||||
min={0}
|
||||
max={200}
|
||||
defaultValue={100}
|
||||
onChange={(v) => handleFilterChange('contrast', v)}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Palette size={12} />}
|
||||
label="Saturation"
|
||||
value={layer.filters.saturation}
|
||||
min={0}
|
||||
max={200}
|
||||
defaultValue={100}
|
||||
onChange={(v) => handleFilterChange('saturation', v)}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Thermometer size={12} />}
|
||||
label="Temperature"
|
||||
value={layer.filters.hue}
|
||||
min={-180}
|
||||
max={180}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('hue', v)}
|
||||
unit="°"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<SunMedium size={12} />}
|
||||
label="Exposure"
|
||||
value={layer.filters.exposure}
|
||||
min={-100}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('exposure', v)}
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Vibrate size={12} />}
|
||||
label="Vibrance"
|
||||
value={layer.filters.vibrance}
|
||||
min={-100}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('vibrance', v)}
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Sunrise size={12} />}
|
||||
label="Highlights"
|
||||
value={layer.filters.highlights}
|
||||
min={-100}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('highlights', v)}
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<SunDim size={12} />}
|
||||
label="Shadows"
|
||||
value={layer.filters.shadows}
|
||||
min={-100}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('shadows', v)}
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Aperture size={12} />}
|
||||
label="Clarity"
|
||||
value={layer.filters.clarity}
|
||||
min={-100}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('clarity', v)}
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Focus size={12} />}
|
||||
label="Blur"
|
||||
value={layer.filters.blur}
|
||||
min={0}
|
||||
max={50}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('blur', v)}
|
||||
unit="px"
|
||||
/>
|
||||
|
||||
{layer.filters.blur > 0 && (
|
||||
<div className="space-y-2 pl-5 border-l-2 border-primary/30">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-foreground font-medium">Blur Type</label>
|
||||
<div className="flex gap-1">
|
||||
{([
|
||||
{ type: 'gaussian' as BlurType, icon: <Focus size={12} />, label: 'Gaussian' },
|
||||
{ type: 'motion' as BlurType, icon: <Move size={12} />, label: 'Motion' },
|
||||
{ type: 'radial' as BlurType, icon: <Target size={12} />, label: 'Radial' },
|
||||
]).map(({ type, icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleBlurTypeChange(type)}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-[10px] font-medium transition-all ${
|
||||
layer.filters.blurType === type
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layer.filters.blurType === 'motion' && (
|
||||
<AdjustmentSlider
|
||||
icon={<Move size={12} />}
|
||||
label="Angle"
|
||||
value={layer.filters.blurAngle}
|
||||
min={0}
|
||||
max={360}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('blurAngle', v)}
|
||||
unit="°"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Sparkles size={12} />}
|
||||
label="Sharpen"
|
||||
value={layer.filters.sharpen}
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('sharpen', v)}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<CircleDot size={12} />}
|
||||
label="Vignette"
|
||||
value={layer.filters.vignette}
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('vignette', v)}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Scan size={12} />}
|
||||
label="Grain"
|
||||
value={layer.filters.grain}
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('grain', v)}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Film size={12} />}
|
||||
label="Sepia"
|
||||
value={layer.filters.sepia}
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('sepia', v)}
|
||||
unit="%"
|
||||
/>
|
||||
|
||||
<AdjustmentSlider
|
||||
icon={<Minus size={12} />}
|
||||
label="Invert"
|
||||
value={layer.filters.invert}
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={0}
|
||||
onChange={(v) => handleFilterChange('invert', v)}
|
||||
unit="%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Crop, ImageIcon } from 'lucide-react';
|
||||
import type { ImageLayer } from '../../../types/project';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
export function ImageControlsSection({ layer }: Props) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Image
|
||||
</h4>
|
||||
|
||||
<div className="p-3 bg-secondary/30 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<ImageIcon size={14} />
|
||||
<span className="text-[11px]">Source: {layer.sourceId ? 'Linked' : 'None'}</span>
|
||||
</div>
|
||||
{layer.cropRect && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground mt-2">
|
||||
<Crop size={14} />
|
||||
<span className="text-[11px]">
|
||||
Cropped: {Math.round(layer.cropRect.width)} × {Math.round(layer.cropRect.height)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,467 +0,0 @@
|
|||
import { memo, lazy, Suspense, useState, createContext, useContext, ReactNode, JSX } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { TransformSection } from './TransformSection';
|
||||
import { AlignmentSection } from './AlignmentSection';
|
||||
import { AppearanceSection } from './AppearanceSection';
|
||||
import { EffectsSection } from './EffectsSection';
|
||||
import { ArtboardSection } from './ArtboardSection';
|
||||
import { PenSettingsSection } from './PenSettingsSection';
|
||||
import { ColorHarmonySection } from './ColorHarmonySection';
|
||||
import { ChevronRight, Sliders, Palette, Wand2, Sparkles, Image as ImageIcon, Layers } from 'lucide-react';
|
||||
import { ScrollArea } from '@openreel/ui';
|
||||
import type { Layer, ImageLayer, TextLayer, ShapeLayer } from '../../../types/project';
|
||||
import type { Tool } from '../../../stores/ui-store';
|
||||
|
||||
const TOOL_FOCUSED_TOOLS = new Set<Tool>([
|
||||
'pen', 'brush', 'eraser', 'gradient', 'paint-bucket',
|
||||
'dodge', 'burn', 'sponge', 'blur', 'sharpen', 'smudge',
|
||||
'clone-stamp', 'healing-brush', 'spot-healing', 'liquify',
|
||||
'marquee-rect', 'marquee-ellipse', 'lasso', 'lasso-polygon', 'magic-wand',
|
||||
'free-transform', 'warp', 'perspective', 'crop'
|
||||
]);
|
||||
|
||||
const ImageAdjustmentsSection = lazy(() => import('./ImageAdjustmentsSection').then(m => ({ default: m.ImageAdjustmentsSection })));
|
||||
const FilterPresetsSection = lazy(() => import('./FilterPresetsSection').then(m => ({ default: m.FilterPresetsSection })));
|
||||
const CropSection = lazy(() => import('./CropSection').then(m => ({ default: m.CropSection })));
|
||||
const ImageControlsSection = lazy(() => import('./ImageControlsSection').then(m => ({ default: m.ImageControlsSection })));
|
||||
const BackgroundRemovalSection = lazy(() => import('./BackgroundRemovalSection').then(m => ({ default: m.BackgroundRemovalSection })));
|
||||
const TextSection = lazy(() => import('./TextSection').then(m => ({ default: m.TextSection })));
|
||||
const ShapeSection = lazy(() => import('./ShapeSection').then(m => ({ default: m.ShapeSection })));
|
||||
const LevelsSection = lazy(() => import('./LevelsSection').then(m => ({ default: m.LevelsSection })));
|
||||
const CurvesSection = lazy(() => import('./CurvesSection').then(m => ({ default: m.CurvesSection })));
|
||||
const ColorBalanceSection = lazy(() => import('./ColorBalanceSection').then(m => ({ default: m.ColorBalanceSection })));
|
||||
const SelectiveColorSection = lazy(() => import('./SelectiveColorSection').then(m => ({ default: m.SelectiveColorSection })));
|
||||
const BlackWhiteSection = lazy(() => import('./BlackWhiteSection').then(m => ({ default: m.BlackWhiteSection })));
|
||||
const PhotoFilterSection = lazy(() => import('./PhotoFilterSection').then(m => ({ default: m.PhotoFilterSection })));
|
||||
const ChannelMixerSection = lazy(() => import('./ChannelMixerSection').then(m => ({ default: m.ChannelMixerSection })));
|
||||
const GradientMapSection = lazy(() => import('./GradientMapSection').then(m => ({ default: m.GradientMapSection })));
|
||||
const PosterizeSection = lazy(() => import('./PosterizeSection').then(m => ({ default: m.PosterizeSection })));
|
||||
const ThresholdSection = lazy(() => import('./ThresholdSection').then(m => ({ default: m.ThresholdSection })));
|
||||
const MaskSection = lazy(() => import('./MaskSection').then(m => ({ default: m.MaskSection })));
|
||||
const SelectionToolsPanel = lazy(() => import('./SelectionToolsPanel').then(m => ({ default: m.SelectionToolsPanel })));
|
||||
const EraserToolPanel = lazy(() => import('./EraserToolPanel').then(m => ({ default: m.EraserToolPanel })));
|
||||
const DodgeBurnToolPanel = lazy(() => import('./DodgeBurnToolPanel').then(m => ({ default: m.DodgeBurnToolPanel })));
|
||||
const CloneStampToolPanel = lazy(() => import('./CloneStampToolPanel').then(m => ({ default: m.CloneStampToolPanel })));
|
||||
const HealingBrushToolPanel = lazy(() => import('./HealingBrushToolPanel').then(m => ({ default: m.HealingBrushToolPanel })));
|
||||
const SpotHealingToolPanel = lazy(() => import('./SpotHealingToolPanel').then(m => ({ default: m.SpotHealingToolPanel })));
|
||||
const SpongeToolPanel = lazy(() => import('./SpongeToolPanel').then(m => ({ default: m.SpongeToolPanel })));
|
||||
const LiquifyToolPanel = lazy(() => import('./LiquifyToolPanel').then(m => ({ default: m.LiquifyToolPanel })));
|
||||
const TransformToolPanel = lazy(() => import('./TransformToolPanel').then(m => ({ default: m.TransformToolPanel })));
|
||||
const BrushToolPanel = lazy(() => import('./BrushToolPanel').then(m => ({ default: m.BrushToolPanel })));
|
||||
const BlurSharpenToolPanel = lazy(() => import('./BlurSharpenToolPanel').then(m => ({ default: m.BlurSharpenToolPanel })));
|
||||
const SmudgeToolPanel = lazy(() => import('./SmudgeToolPanel').then(m => ({ default: m.SmudgeToolPanel })));
|
||||
const GradientToolPanel = lazy(() => import('./GradientToolPanel').then(m => ({ default: m.GradientToolPanel })));
|
||||
const PaintBucketToolPanel = lazy(() => import('./PaintBucketToolPanel').then(m => ({ default: m.PaintBucketToolPanel })));
|
||||
|
||||
function SectionLoader() {
|
||||
return (
|
||||
<div className="px-4 py-3">
|
||||
<div className="h-4 w-24 animate-pulse bg-muted/40 rounded mb-3" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 animate-pulse bg-muted/30 rounded" />
|
||||
<div className="h-8 animate-pulse bg-muted/30 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AccordionContextType = {
|
||||
openItems: string[];
|
||||
toggle: (id: string) => void;
|
||||
};
|
||||
|
||||
const AccordionContext = createContext<AccordionContextType | null>(null);
|
||||
|
||||
interface AccordionProps {
|
||||
children: ReactNode;
|
||||
defaultOpen?: string[];
|
||||
}
|
||||
|
||||
function Accordion({ children, defaultOpen = [] }: AccordionProps) {
|
||||
const [openItems, setOpenItems] = useState<string[]>(defaultOpen);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setOpenItems(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(item => item !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={{ openItems, toggle }}>
|
||||
<div className="divide-y divide-border">{children}</div>
|
||||
</AccordionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface AccordionItemProps {
|
||||
id: string;
|
||||
icon?: React.ElementType;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
function AccordionItem({ id, icon: Icon, title, children, badge }: AccordionItemProps) {
|
||||
const context = useContext(AccordionContext);
|
||||
if (!context) return null;
|
||||
|
||||
const { openItems, toggle } = context;
|
||||
const isOpen = openItems.includes(id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggle(id)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`text-muted-foreground shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
{Icon && <Icon size={16} className="text-muted-foreground shrink-0" />}
|
||||
<span className="text-sm font-medium text-foreground flex-1">{title}</span>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="pb-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderToolPanel(tool: Tool, imageLayer?: ImageLayer): JSX.Element | null {
|
||||
const SELECTION_TOOLS = ['marquee-rect', 'marquee-ellipse', 'lasso', 'lasso-polygon', 'magic-wand'];
|
||||
const TRANSFORM_TOOLS = ['free-transform', 'warp', 'perspective'];
|
||||
|
||||
if (SELECTION_TOOLS.includes(tool)) {
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<SelectionToolsPanel />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (TRANSFORM_TOOLS.includes(tool)) {
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<TransformToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
switch (tool) {
|
||||
case 'pen':
|
||||
return <PenSettingsSection />;
|
||||
case 'brush':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<BrushToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'eraser':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<EraserToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'gradient':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<GradientToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'dodge':
|
||||
case 'burn':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<DodgeBurnToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'sponge':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<SpongeToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'blur':
|
||||
case 'sharpen':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<BlurSharpenToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'smudge':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<SmudgeToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'clone-stamp':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<CloneStampToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'healing-brush':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<HealingBrushToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'spot-healing':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<SpotHealingToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'liquify':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<LiquifyToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'paint-bucket':
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<PaintBucketToolPanel />
|
||||
</Suspense>
|
||||
);
|
||||
case 'crop':
|
||||
if (imageLayer) {
|
||||
return (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<CropSection layer={imageLayer} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InspectorContent() {
|
||||
const { project, selectedLayerIds, selectedArtboardId } = useProjectStore();
|
||||
const { activeTool } = useUIStore();
|
||||
|
||||
const selectedLayers = selectedLayerIds
|
||||
.map((id) => project?.layers[id])
|
||||
.filter((layer): layer is Layer => layer !== undefined);
|
||||
|
||||
const singleLayer = selectedLayers.length === 1 ? selectedLayers[0] : null;
|
||||
const imageLayer = singleLayer?.type === 'image' ? (singleLayer as ImageLayer) : undefined;
|
||||
|
||||
if (TOOL_FOCUSED_TOOLS.has(activeTool)) {
|
||||
const toolPanel = renderToolPanel(activeTool, imageLayer);
|
||||
if (toolPanel) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{toolPanel}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLayers.length > 1) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Layers size={16} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{selectedLayers.length} layers
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">Multiple selection</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlignmentSection layers={selectedLayers} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
if (!singleLayer) {
|
||||
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
|
||||
if (artboard) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-4">Artboard</h3>
|
||||
<ArtboardSection artboard={artboard} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-muted/50 flex items-center justify-center">
|
||||
<Layers size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a layer to view<br />and edit its properties
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getLayerIcon = () => {
|
||||
switch (singleLayer.type) {
|
||||
case 'image': return ImageIcon;
|
||||
case 'text': return () => <span className="text-sm font-bold">T</span>;
|
||||
case 'shape': return () => <span className="text-sm">◆</span>;
|
||||
default: return Layers;
|
||||
}
|
||||
};
|
||||
|
||||
const LayerIcon = getLayerIcon();
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="pb-8">
|
||||
<div className="px-4 py-4 border-b border-border bg-card/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted/50 flex items-center justify-center shrink-0">
|
||||
<LayerIcon size={18} className="text-muted-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">
|
||||
{singleLayer.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground capitalize">{singleLayer.type} layer</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Accordion defaultOpen={['transform', 'appearance', 'quick-filters', 'basic-adjustments']}>
|
||||
<AccordionItem id="transform" icon={Sliders} title="Transform & Position">
|
||||
<div className="px-4 space-y-4">
|
||||
<TransformSection layer={singleLayer} />
|
||||
<div className="pt-2">
|
||||
<AlignmentSection layers={[singleLayer]} />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id="appearance" icon={Palette} title="Appearance">
|
||||
<div className="px-4">
|
||||
<AppearanceSection layer={singleLayer} />
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id="effects" icon={Sparkles} title="Effects">
|
||||
<EffectsSection layer={singleLayer} />
|
||||
</AccordionItem>
|
||||
|
||||
{singleLayer.type === 'image' && (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<AccordionItem id="image-controls" icon={ImageIcon} title="Image Controls">
|
||||
<div className="px-4 space-y-4">
|
||||
<ImageControlsSection layer={singleLayer as ImageLayer} />
|
||||
<CropSection layer={singleLayer as ImageLayer} />
|
||||
<BackgroundRemovalSection layer={singleLayer as ImageLayer} />
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id="quick-filters" icon={Wand2} title="Quick Filters">
|
||||
<FilterPresetsSection layer={singleLayer as ImageLayer} />
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id="basic-adjustments" icon={Sliders} title="Basic Adjustments">
|
||||
<ImageAdjustmentsSection layer={singleLayer as ImageLayer} />
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id="tonal" icon={Sliders} title="Tonal Adjustments">
|
||||
<div className="space-y-0">
|
||||
<LevelsSection layer={singleLayer} />
|
||||
<CurvesSection layer={singleLayer} />
|
||||
<PosterizeSection layer={singleLayer} />
|
||||
<ThresholdSection layer={singleLayer} />
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id="color" icon={Palette} title="Color Adjustments">
|
||||
<div className="space-y-0">
|
||||
<ColorBalanceSection layer={singleLayer} />
|
||||
<SelectiveColorSection layer={singleLayer} />
|
||||
<PhotoFilterSection layer={singleLayer} />
|
||||
<ChannelMixerSection layer={singleLayer} />
|
||||
<GradientMapSection layer={singleLayer} />
|
||||
<BlackWhiteSection layer={singleLayer} />
|
||||
</div>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem id="mask" icon={Layers} title="Mask">
|
||||
<MaskSection layer={singleLayer} />
|
||||
</AccordionItem>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{singleLayer.type === 'text' && (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<AccordionItem id="text-settings" title="Text Settings">
|
||||
<div className="px-4">
|
||||
<TextSection layer={singleLayer as TextLayer} />
|
||||
</div>
|
||||
</AccordionItem>
|
||||
<AccordionItem id="color-harmony" icon={Palette} title="Color Harmony">
|
||||
<div className="px-4">
|
||||
<ColorHarmonySection
|
||||
baseColor={(singleLayer as TextLayer).style.color}
|
||||
onColorSelect={(color) => {
|
||||
useProjectStore.getState().updateLayer<TextLayer>(singleLayer.id, {
|
||||
style: { ...(singleLayer as TextLayer).style, color },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{singleLayer.type === 'shape' && (
|
||||
<Suspense fallback={<SectionLoader />}>
|
||||
<AccordionItem id="shape-settings" title="Shape Settings">
|
||||
<div className="px-4">
|
||||
<ShapeSection layer={singleLayer as ShapeLayer} />
|
||||
</div>
|
||||
</AccordionItem>
|
||||
{(singleLayer as ShapeLayer).shapeStyle.fill && (
|
||||
<AccordionItem id="color-harmony" icon={Palette} title="Color Harmony">
|
||||
<div className="px-4">
|
||||
<ColorHarmonySection
|
||||
baseColor={(singleLayer as ShapeLayer).shapeStyle.fill!}
|
||||
onColorSelect={(color) => {
|
||||
useProjectStore.getState().updateLayer<ShapeLayer>(singleLayer.id, {
|
||||
shapeStyle: { ...(singleLayer as ShapeLayer).shapeStyle, fill: color },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AccordionItem>
|
||||
)}
|
||||
</Suspense>
|
||||
)}
|
||||
</Accordion>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export const Inspector = memo(InspectorContent);
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { LevelsChannel } from '../../../types/adjustments';
|
||||
import { DEFAULT_LEVELS } from '../../../types/adjustments';
|
||||
import { Activity, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type ChannelType = 'master' | 'red' | 'green' | 'blue';
|
||||
|
||||
interface LevelsSliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function LevelsSlider({ label, value, min, max, step = 1, onChange }: LevelsSliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{value.toFixed(step < 1 ? 2 : 0)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LevelsSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelType>('master');
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const levels = layer.levels;
|
||||
const currentChannel = levels[activeChannel];
|
||||
|
||||
const handleChannelChange = (key: keyof LevelsChannel, value: number) => {
|
||||
updateLayer(layer.id, {
|
||||
levels: {
|
||||
...levels,
|
||||
[activeChannel]: {
|
||||
...currentChannel,
|
||||
[key]: value,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
levels: {
|
||||
...levels,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetLevels = () => {
|
||||
updateLayer(layer.id, {
|
||||
levels: { ...DEFAULT_LEVELS },
|
||||
});
|
||||
};
|
||||
|
||||
const channelColors: Record<ChannelType, string> = {
|
||||
master: 'bg-foreground',
|
||||
red: 'bg-red-500',
|
||||
green: 'bg-green-500',
|
||||
blue: 'bg-blue-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Levels</span>
|
||||
{levels.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={levels.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
{(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => (
|
||||
<button
|
||||
key={channel}
|
||||
onClick={() => setActiveChannel(channel)}
|
||||
className={`px-2 py-1 text-[10px] rounded transition-colors ${
|
||||
activeChannel === channel
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${channelColors[channel]}`} />
|
||||
{channel.charAt(0).toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={resetLevels}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset Levels"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Input Levels</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<LevelsSlider
|
||||
label="Black"
|
||||
value={currentChannel.inputBlack}
|
||||
min={0}
|
||||
max={255}
|
||||
onChange={(v) => handleChannelChange('inputBlack', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<LevelsSlider
|
||||
label="White"
|
||||
value={currentChannel.inputWhite}
|
||||
min={0}
|
||||
max={255}
|
||||
onChange={(v) => handleChannelChange('inputWhite', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LevelsSlider
|
||||
label="Gamma"
|
||||
value={currentChannel.gamma}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.01}
|
||||
onChange={(v) => handleChannelChange('gamma', v)}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">Output Levels</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<LevelsSlider
|
||||
label="Black"
|
||||
value={currentChannel.outputBlack}
|
||||
min={0}
|
||||
max={255}
|
||||
onChange={(v) => handleChannelChange('outputBlack', v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<LevelsSlider
|
||||
label="White"
|
||||
value={currentChannel.outputWhite}
|
||||
min={0}
|
||||
max={255}
|
||||
onChange={(v) => handleChannelChange('outputWhite', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Waves, RotateCcw, ArrowRight, Undo2, Sparkles, RotateCw, RotateCcw as Counterclockwise, Minus, Plus, ArrowLeft, Snowflake, Flame } from 'lucide-react';
|
||||
|
||||
const liquifyTools = [
|
||||
{ id: 'forward-warp', label: 'Forward Warp', icon: ArrowRight },
|
||||
{ id: 'reconstruct', label: 'Reconstruct', icon: Undo2 },
|
||||
{ id: 'smooth', label: 'Smooth', icon: Sparkles },
|
||||
{ id: 'twirl-clockwise', label: 'Twirl CW', icon: RotateCw },
|
||||
{ id: 'twirl-counterclockwise', label: 'Twirl CCW', icon: Counterclockwise },
|
||||
{ id: 'pucker', label: 'Pucker', icon: Minus },
|
||||
{ id: 'bloat', label: 'Bloat', icon: Plus },
|
||||
{ id: 'push-left', label: 'Push Left', icon: ArrowLeft },
|
||||
{ id: 'freeze', label: 'Freeze', icon: Snowflake },
|
||||
{ id: 'thaw', label: 'Thaw', icon: Flame },
|
||||
] as const;
|
||||
|
||||
export function LiquifyToolPanel() {
|
||||
const { liquifySettings, setLiquifySettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setLiquifySettings({
|
||||
brushSize: 100,
|
||||
brushDensity: 50,
|
||||
brushPressure: 100,
|
||||
brushRate: 80,
|
||||
tool: 'forward-warp',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Waves size={16} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Liquify</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Tool</span>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{liquifyTools.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => setLiquifySettings({ tool: tool.id })}
|
||||
className={`p-2 rounded transition-colors ${
|
||||
liquifySettings.tool === tool.id
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
|
||||
}`}
|
||||
title={tool.label}
|
||||
>
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Brush Size</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushSize}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={1500}
|
||||
value={liquifySettings.brushSize}
|
||||
onChange={(e) => setLiquifySettings({ brushSize: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Density</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushDensity}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={liquifySettings.brushDensity}
|
||||
onChange={(e) => setLiquifySettings({ brushDensity: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Pressure</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushPressure}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={liquifySettings.brushPressure}
|
||||
onChange={(e) => setLiquifySettings({ brushPressure: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Rate</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushRate}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={liquifySettings.brushRate}
|
||||
onChange={(e) => setLiquifySettings({ brushRate: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { useSelectionStore } from '../../../stores/selection-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { LayerMask } from '../../../types/mask';
|
||||
import {
|
||||
Circle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Link,
|
||||
Unlink,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{value.toFixed(step < 1 ? 0 : 0)}
|
||||
{label === 'Density' || label === 'Feather' ? '%' : 'px'}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MaskSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const { active: selection, clearSelection } = useSelectionStore();
|
||||
|
||||
const mask = layer.mask;
|
||||
const hasMask = mask !== null;
|
||||
const hasSelection = selection !== null;
|
||||
|
||||
const handleAddMask = (reveal: boolean) => {
|
||||
const baseMask: LayerMask = {
|
||||
id: `mask-${Date.now()}`,
|
||||
type: 'pixel',
|
||||
enabled: true,
|
||||
linked: true,
|
||||
density: 100,
|
||||
feather: 0,
|
||||
invert: !reveal,
|
||||
data: null,
|
||||
vectorPath: selection ? [...selection.path] : null,
|
||||
};
|
||||
|
||||
updateLayer(layer.id, { mask: baseMask });
|
||||
|
||||
if (selection) {
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMask = () => {
|
||||
updateLayer(layer.id, { mask: null });
|
||||
};
|
||||
|
||||
const handleToggleMaskEnabled = () => {
|
||||
if (!mask) return;
|
||||
updateLayer(layer.id, {
|
||||
mask: { ...mask, enabled: !mask.enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleMaskLinked = () => {
|
||||
if (!mask) return;
|
||||
updateLayer(layer.id, {
|
||||
mask: { ...mask, linked: !mask.linked },
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleMaskInvert = () => {
|
||||
if (!mask) return;
|
||||
updateLayer(layer.id, {
|
||||
mask: { ...mask, invert: !mask.invert },
|
||||
});
|
||||
};
|
||||
|
||||
const handleDensityChange = (density: number) => {
|
||||
if (!mask) return;
|
||||
updateLayer(layer.id, {
|
||||
mask: { ...mask, density },
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeatherChange = (feather: number) => {
|
||||
if (!mask) return;
|
||||
updateLayer(layer.id, {
|
||||
mask: { ...mask, feather },
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleClippingMask = () => {
|
||||
updateLayer(layer.id, { clippingMask: !layer.clippingMask });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="px-3 py-2">
|
||||
<span className="text-xs font-medium">Masks</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
{!hasMask ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{hasSelection
|
||||
? 'Create mask from current selection'
|
||||
: 'Add a mask to control layer visibility'}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => handleAddMask(true)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
<Plus size={10} />
|
||||
Reveal All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAddMask(false)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
<Plus size={10} />
|
||||
Hide All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2 rounded bg-secondary/50">
|
||||
<div className="w-8 h-8 rounded bg-gradient-to-br from-white to-black border border-border" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] font-medium truncate">
|
||||
{mask.type === 'pixel' ? 'Pixel Mask' : 'Vector Mask'}
|
||||
</p>
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
{mask.enabled ? 'Enabled' : 'Disabled'}
|
||||
{mask.invert ? ' • Inverted' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={handleToggleMaskEnabled}
|
||||
className={`flex-1 p-1.5 rounded transition-colors ${
|
||||
mask.enabled
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
title={mask.enabled ? 'Disable Mask' : 'Enable Mask'}
|
||||
>
|
||||
{mask.enabled ? <Eye size={12} /> : <EyeOff size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMaskLinked}
|
||||
className={`flex-1 p-1.5 rounded transition-colors ${
|
||||
mask.linked
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
title={mask.linked ? 'Unlink Mask' : 'Link Mask'}
|
||||
>
|
||||
{mask.linked ? <Link size={12} /> : <Unlink size={12} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleToggleMaskInvert}
|
||||
className={`flex-1 p-1.5 rounded transition-colors ${
|
||||
mask.invert
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
|
||||
}`}
|
||||
title={mask.invert ? 'Remove Invert' : 'Invert Mask'}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteMask}
|
||||
className="flex-1 p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
title="Delete Mask"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Slider
|
||||
label="Density"
|
||||
value={mask.density}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={handleDensityChange}
|
||||
/>
|
||||
|
||||
<Slider
|
||||
label="Feather"
|
||||
value={mask.feather}
|
||||
min={0}
|
||||
max={250}
|
||||
onChange={handleFeatherChange}
|
||||
/>
|
||||
|
||||
{hasSelection && (
|
||||
<div className="pt-2 border-t border-border space-y-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-medium">
|
||||
Apply Selection
|
||||
</span>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Add to Mask
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
|
||||
>
|
||||
Subtract
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-border">
|
||||
<button
|
||||
onClick={handleToggleClippingMask}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 text-[10px] rounded transition-colors ${
|
||||
layer.clippingMask
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-secondary hover:bg-secondary/80'
|
||||
}`}
|
||||
>
|
||||
<Circle size={10} className={layer.clippingMask ? 'fill-primary' : ''} />
|
||||
<span>{layer.clippingMask ? 'Release Clipping Mask' : 'Create Clipping Mask'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() => {}}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
|
||||
title="Load mask from selection"
|
||||
>
|
||||
<Download size={10} />
|
||||
Load Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { PaintBucket, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function PaintBucketToolPanel() {
|
||||
const { paintBucketSettings, setPaintBucketSettings, brushSettings, setBrushSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setPaintBucketSettings({
|
||||
color: '#000000',
|
||||
tolerance: 32,
|
||||
contiguous: true,
|
||||
antiAlias: true,
|
||||
opacity: 1,
|
||||
fillType: 'foreground',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<PaintBucket size={16} className="text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Paint Bucket</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click on canvas to fill area with color.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground mb-1.5 block">Fill Color</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={brushSettings.color}
|
||||
onChange={(e) => setBrushSettings({ color: e.target.value })}
|
||||
className="w-10 h-10 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={brushSettings.color}
|
||||
onChange={(e) => setBrushSettings({ color: e.target.value })}
|
||||
className="flex-1 px-2 py-1.5 text-xs font-mono bg-secondary/50 border border-border rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Tolerance</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{paintBucketSettings.tolerance}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={255}
|
||||
value={paintBucketSettings.tolerance}
|
||||
onChange={(e) => setPaintBucketSettings({ tolerance: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs text-muted-foreground">Opacity</span>
|
||||
<span className="text-xs font-mono text-muted-foreground">{Math.round(paintBucketSettings.opacity * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={paintBucketSettings.opacity * 100}
|
||||
onChange={(e) => setPaintBucketSettings({ opacity: Number(e.target.value) / 100 })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={paintBucketSettings.contiguous}
|
||||
onChange={(e) => setPaintBucketSettings({ contiguous: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Contiguous
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={paintBucketSettings.antiAlias}
|
||||
onChange={(e) => setPaintBucketSettings({ antiAlias: e.target.checked })}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
Anti-alias
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
||||
export function PenSettingsSection() {
|
||||
const { penSettings, setPenSettings } = useUIStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil size={16} className="text-primary" />
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Pen Settings
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-3 bg-secondary/30 rounded-lg border border-border/50">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] text-foreground font-medium">Color</label>
|
||||
<input
|
||||
type="color"
|
||||
value={penSettings.color}
|
||||
onChange={(e) => setPenSettings({ color: e.target.value })}
|
||||
className="w-8 h-6 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] text-foreground font-medium">Width</label>
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{penSettings.width}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={penSettings.width}
|
||||
min={1}
|
||||
max={50}
|
||||
onChange={(e) => setPenSettings({ width: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[11px] text-foreground font-medium">Opacity</label>
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{Math.round(penSettings.opacity * 100)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={penSettings.opacity}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onChange={(e) => setPenSettings({ opacity: Number(e.target.value) })}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Click and drag on the canvas to draw
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { PhotoFilterAdjustment } from '../../../types/adjustments';
|
||||
import { DEFAULT_PHOTO_FILTER } from '../../../types/adjustments';
|
||||
import { PHOTO_FILTER_COLORS } from '../../../adjustments/photo-filter';
|
||||
import { SunDim, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ id: 'warming-85', label: 'Warming (85)', group: 'Warming' },
|
||||
{ id: 'warming-81', label: 'Warming (81)', group: 'Warming' },
|
||||
{ id: 'cooling-80', label: 'Cooling (80)', group: 'Cooling' },
|
||||
{ id: 'cooling-82', label: 'Cooling (82)', group: 'Cooling' },
|
||||
{ id: 'custom', label: 'Custom Color', group: 'Custom' },
|
||||
] as const;
|
||||
|
||||
type FilterType = typeof FILTER_OPTIONS[number]['id'];
|
||||
|
||||
export function PhotoFilterSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const photoFilter = layer.photoFilter;
|
||||
|
||||
const handleFilterChange = (filter: FilterType) => {
|
||||
const color = filter === 'custom' ? photoFilter.color : (PHOTO_FILTER_COLORS[filter as keyof typeof PHOTO_FILTER_COLORS] ?? photoFilter.color);
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
filter,
|
||||
color,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDensityChange = (density: number) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
density,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
filter: 'custom',
|
||||
color,
|
||||
} as PhotoFilterAdjustment,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
preserveLuminosity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetPhotoFilter = () => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: { ...DEFAULT_PHOTO_FILTER },
|
||||
});
|
||||
};
|
||||
|
||||
const densityPercentage = photoFilter.density;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SunDim size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Photo Filter</span>
|
||||
{photoFilter.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={photoFilter.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={photoFilter.filter}
|
||||
onChange={(e) => handleFilterChange(e.target.value as FilterType)}
|
||||
className="text-[10px] bg-secondary border-none rounded px-2 py-1 text-foreground flex-1"
|
||||
>
|
||||
{FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={resetPhotoFilter}
|
||||
className="p-1 ml-2 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">Color</span>
|
||||
<input
|
||||
type="color"
|
||||
value={photoFilter.color}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
className="w-6 h-6 rounded border-none cursor-pointer"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{photoFilter.color}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">Density</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{photoFilter.density}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={photoFilter.density}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => handleDensityChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${densityPercentage}%, hsl(var(--secondary)) ${densityPercentage}%, hsl(var(--secondary)) 100%)`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={photoFilter.preserveLuminosity}
|
||||
onChange={(e) => handlePreserveLuminosityChange(e.target.checked)}
|
||||
className="w-3 h-3 rounded border-border"
|
||||
/>
|
||||
Preserve Luminosity
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import { DEFAULT_POSTERIZE } from '../../../types/adjustments';
|
||||
import { Layers, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
export function PosterizeSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const posterize = layer.posterize;
|
||||
|
||||
const handleLevelsChange = (levels: number) => {
|
||||
updateLayer(layer.id, {
|
||||
posterize: { ...posterize, levels },
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
posterize: { ...posterize, enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const resetPosterize = () => {
|
||||
updateLayer(layer.id, {
|
||||
posterize: { ...DEFAULT_POSTERIZE },
|
||||
});
|
||||
};
|
||||
|
||||
const percentage = ((posterize.levels - 2) / 253) * 100;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs font-medium">Posterize</span>
|
||||
{posterize.enabled && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={posterize.enabled}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(e.target.checked);
|
||||
}}
|
||||
className="w-3.5 h-3.5 rounded border-border"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">Levels</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-mono text-muted-foreground">{posterize.levels}</span>
|
||||
<button
|
||||
onClick={resetPosterize}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
|
||||
title="Reset"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
value={posterize.levels}
|
||||
min={2}
|
||||
max={255}
|
||||
onChange={(e) => handleLevelsChange(Number(e.target.value))}
|
||||
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-2.5
|
||||
[&::-webkit-slider-thumb]:h-2.5
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-primary
|
||||
[&::-webkit-slider-thumb]:shadow-sm
|
||||
[&::-webkit-slider-thumb]:cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex justify-between text-[9px] text-muted-foreground">
|
||||
<span>2</span>
|
||||
<span>255</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue