not sure where I landed with this commit; I didn't test it. I think I was refactoring enums or something. need to test

This commit is contained in:
Hanashi 2026-08-06 14:54:19 -04:00
parent bce25f1025
commit 60e15a8ec1
1003 changed files with 281 additions and 127334 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

View File

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 376.31 65.68">
<defs>
<style>
.cls-1 {
fill: #2d81ff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path d="M92.52,55.99l13.32-45.94h22.71l13.95,45.94h-17.18l-1.82-8.81h-12.48l-2.03,8.81h-16.48ZM112.5,38.97h9.32l-4.56-20.1-4.77,20.1Z"/>
<path d="M145.94,55.99V22.54h14.58v8.35c.93-3.03,2.39-5.25,4.38-6.68,1.99-1.42,4.36-2.14,7.12-2.14l1.75,11.82c-1.12-.13-2.22-.2-3.29-.2-6.5,0-9.75,2.54-9.75,7.61v14.69h-14.79Z"/>
<path d="M193.68,56.53c-3.32,0-6.02-.53-8.1-1.6-2.08-1.07-3.59-2.51-4.52-4.34-.93-1.82-1.4-3.87-1.4-6.14v-13.62h-4.49v-8.28h5.54l6.52-10.55h7.22v10.55h8.13v8.28h-8.13v11.68c0,1.02.35,1.81,1.05,2.37.7.56,1.61.83,2.73.83s2.24-.27,3.36-.8l1.05,9.28c-1.12.76-2.55,1.33-4.28,1.74-1.73.4-3.29.6-4.7.6Z"/>
<path d="M231.12,57.06c-5.05,0-9.45-.97-13.21-2.9-3.76-1.94-6.68-4.66-8.76-8.18-2.08-3.52-3.12-7.61-3.12-12.29s1.09-9.06,3.26-12.75c2.17-3.69,5.16-6.6,8.97-8.71,3.81-2.11,8.19-3.17,13.14-3.17,5.51,0,9.84,1.02,12.97,3.07l-1.89,10.62c-1.36-.62-2.77-1.08-4.24-1.37-1.47-.29-2.86-.43-4.17-.43-3.83,0-6.85,1.09-9.04,3.27-2.2,2.18-3.29,5.21-3.29,9.08,0,3.65,1.06,6.51,3.19,8.58,2.13,2.07,5.08,3.1,8.87,3.1,1.5,0,2.86-.13,4.1-.4,1.24-.27,2.77-.76,4.59-1.47l1.96,10.55c-4.07,2.27-8.51,3.41-13.32,3.41Z"/>
<path d="M249.14,55.99V22.54h14.58v8.35c.93-3.03,2.39-5.25,4.38-6.68,1.99-1.42,4.36-2.14,7.12-2.14l1.75,11.82c-1.12-.13-2.22-.2-3.29-.2-6.5,0-9.75,2.54-9.75,7.61v14.69h-14.79Z"/>
<path d="M294.91,56.53c-3.22,0-6.15-.75-8.76-2.24-2.62-1.49-4.69-3.55-6.21-6.18-1.52-2.62-2.28-5.63-2.28-9.01s.76-6.34,2.28-8.88c1.52-2.54,3.59-4.53,6.21-5.98,2.62-1.45,5.54-2.17,8.76-2.17,2.29,0,4.51.6,6.66,1.8,2.15,1.2,3.81,3.18,4.98,5.94v-7.28h14.58v33.45h-14.72v-6.21c-1.12,2.18-2.62,3.85-4.49,5.01-1.87,1.16-4.21,1.74-7.01,1.74ZM299.47,45.98c1.92,0,3.55-.66,4.91-1.97,1.36-1.31,2.03-2.93,2.03-4.84s-.68-3.48-2.03-4.71c-1.36-1.22-2.99-1.84-4.91-1.84-2.01,0-3.69.65-5.05,1.94-1.36,1.29-2.03,2.87-2.03,4.74s.68,3.45,2.03,4.74,3.04,1.94,5.05,1.94Z"/>
<path d="M329.06,55.99v-25.17h-4.42v-8.28h4.42v-1.94c0-2.45.63-4.55,1.89-6.31,1.26-1.76,3.01-3.12,5.26-4.07,2.24-.96,4.84-1.44,7.78-1.44,3.74,0,6.82.49,9.25,1.47l-.98,8.48c-1.03-.22-2.03-.39-3.01-.5-.98-.11-1.94-.17-2.87-.17-3.13,0-4.7.91-4.7,2.74,0,.58.16,1.16.49,1.74h9.88v8.28h-8.2v25.17h-14.79Z"/>
<path d="M367.33,56.53c-3.32,0-6.02-.53-8.1-1.6s-3.59-2.51-4.52-4.34c-.94-1.82-1.4-3.87-1.4-6.14v-13.62h-4.49v-8.28h5.54l6.52-10.55h7.22v10.55h8.13v8.28h-8.13v11.68c0,1.02.35,1.81,1.05,2.37.7.56,1.61.83,2.73.83s2.24-.27,3.37-.8l1.05,9.28c-1.12.76-2.55,1.33-4.28,1.74-1.73.4-3.29.6-4.7.6Z"/>
</g>
<path class="cls-1" d="M69.38,45.46c-3.18-6.24-6.58-12.49-9.86-18.53-3.31-6.08-6.72-12.37-9.91-18.63l-.05-.09C46.44,3.07,41.24,0,35.66,0s-10.34,3-13.13,8.23L.97,48.96l-.03.07c-1.51,3.8-1.2,8.03.82,11.31,1.9,3.08,5.01,4.96,8.76,5.28.38.03.75.05,1.1.05,2.94,0,4.98-1.12,7.33-2.41,7.13-3.91,19.45-11.21,27.61-16.04,2.6-1.54,4.95-2.93,6.31-3.73l10.71,20.43c.2.39.54.7.95.86.46.19.98.2,1.43,0,1.39-.66,3.05-2.47,3.07-2.49,5.22-5.9,3.03-11.55.34-16.83ZM34.53,14.8c.31-.46.77-.99,1.32-.99.58,0,1.29.57,1.85,1.49,1.57,2.56,2.99,5.44,4.35,8.22,1.34,2.73,2.72,5.55,4.29,8.15l.02.05-29.6,16.99L34.53,14.8Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 376.31 65.68">
<defs>
<style>
.cls-1 {
fill: #2d81ff;
}
.cls-2 {
fill: #fff;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-2" d="M92.52,55.99l13.32-45.94h22.71l13.95,45.94h-17.18l-1.82-8.81h-12.48l-2.03,8.81h-16.48ZM112.5,38.97h9.32l-4.56-20.1-4.77,20.1Z"/>
<path class="cls-2" d="M145.94,55.99V22.54h14.58v8.35c.93-3.03,2.39-5.25,4.38-6.68,1.99-1.42,4.36-2.14,7.12-2.14l1.75,11.82c-1.12-.13-2.22-.2-3.29-.2-6.5,0-9.75,2.54-9.75,7.61v14.69h-14.79Z"/>
<path class="cls-2" d="M193.68,56.53c-3.32,0-6.02-.53-8.1-1.6-2.08-1.07-3.59-2.51-4.52-4.34-.93-1.82-1.4-3.87-1.4-6.14v-13.62h-4.49v-8.28h5.54l6.52-10.55h7.22v10.55h8.13v8.28h-8.13v11.68c0,1.02.35,1.81,1.05,2.37.7.56,1.61.83,2.73.83s2.24-.27,3.36-.8l1.05,9.28c-1.12.76-2.55,1.33-4.28,1.74-1.73.4-3.29.6-4.7.6Z"/>
<path class="cls-2" d="M231.12,57.06c-5.05,0-9.45-.97-13.21-2.9-3.76-1.94-6.68-4.66-8.76-8.18-2.08-3.52-3.12-7.61-3.12-12.29s1.09-9.06,3.26-12.75c2.17-3.69,5.16-6.6,8.97-8.71,3.81-2.11,8.19-3.17,13.14-3.17,5.51,0,9.84,1.02,12.97,3.07l-1.89,10.62c-1.36-.62-2.77-1.08-4.24-1.37-1.47-.29-2.86-.43-4.17-.43-3.83,0-6.85,1.09-9.04,3.27-2.2,2.18-3.29,5.21-3.29,9.08,0,3.65,1.06,6.51,3.19,8.58,2.13,2.07,5.08,3.1,8.87,3.1,1.5,0,2.86-.13,4.1-.4,1.24-.27,2.77-.76,4.59-1.47l1.96,10.55c-4.07,2.27-8.51,3.41-13.32,3.41Z"/>
<path class="cls-2" d="M249.14,55.99V22.54h14.58v8.35c.93-3.03,2.39-5.25,4.38-6.68,1.99-1.42,4.36-2.14,7.12-2.14l1.75,11.82c-1.12-.13-2.22-.2-3.29-.2-6.5,0-9.75,2.54-9.75,7.61v14.69h-14.79Z"/>
<path class="cls-2" d="M294.91,56.53c-3.22,0-6.15-.75-8.76-2.24-2.62-1.49-4.69-3.55-6.21-6.18-1.52-2.62-2.28-5.63-2.28-9.01s.76-6.34,2.28-8.88c1.52-2.54,3.59-4.53,6.21-5.98,2.62-1.45,5.54-2.17,8.76-2.17,2.29,0,4.51.6,6.66,1.8,2.15,1.2,3.81,3.18,4.98,5.94v-7.28h14.58v33.45h-14.72v-6.21c-1.12,2.18-2.62,3.85-4.49,5.01-1.87,1.16-4.21,1.74-7.01,1.74ZM299.47,45.98c1.92,0,3.55-.66,4.91-1.97,1.36-1.31,2.03-2.93,2.03-4.84s-.68-3.48-2.03-4.71c-1.36-1.22-2.99-1.84-4.91-1.84-2.01,0-3.69.65-5.05,1.94-1.36,1.29-2.03,2.87-2.03,4.74s.68,3.45,2.03,4.74,3.04,1.94,5.05,1.94Z"/>
<path class="cls-2" d="M329.06,55.99v-25.17h-4.42v-8.28h4.42v-1.94c0-2.45.63-4.55,1.89-6.31,1.26-1.76,3.01-3.12,5.26-4.07,2.24-.96,4.84-1.44,7.78-1.44,3.74,0,6.82.49,9.25,1.47l-.98,8.48c-1.03-.22-2.03-.39-3.01-.5-.98-.11-1.94-.17-2.87-.17-3.13,0-4.7.91-4.7,2.74,0,.58.16,1.16.49,1.74h9.88v8.28h-8.2v25.17h-14.79Z"/>
<path class="cls-2" d="M367.33,56.53c-3.32,0-6.02-.53-8.1-1.6s-3.59-2.51-4.52-4.34c-.94-1.82-1.4-3.87-1.4-6.14v-13.62h-4.49v-8.28h5.54l6.52-10.55h7.22v10.55h8.13v8.28h-8.13v11.68c0,1.02.35,1.81,1.05,2.37.7.56,1.61.83,2.73.83s2.24-.27,3.37-.8l1.05,9.28c-1.12.76-2.55,1.33-4.28,1.74-1.73.4-3.29.6-4.7.6Z"/>
</g>
<path class="cls-1" d="M69.38,45.46c-3.18-6.24-6.58-12.49-9.86-18.53-3.31-6.08-6.72-12.37-9.91-18.63l-.05-.09C46.44,3.07,41.24,0,35.66,0s-10.34,3-13.13,8.23L.97,48.96l-.03.07c-1.51,3.8-1.2,8.03.82,11.31,1.9,3.08,5.01,4.96,8.76,5.28.38.03.75.05,1.1.05,2.94,0,4.98-1.12,7.33-2.41,7.13-3.91,19.45-11.21,27.61-16.04,2.6-1.54,4.95-2.93,6.31-3.73l10.71,20.43c.2.39.54.7.95.86.46.19.98.2,1.43,0,1.39-.66,3.05-2.47,3.07-2.49,5.22-5.9,3.03-11.55.34-16.83ZM34.53,14.8c.31-.46.77-.99,1.32-.99.58,0,1.29.57,1.85,1.49,1.57,2.56,2.99,5.44,4.35,8.22,1.34,2.73,2.72,5.55,4.29,8.15l.02.05-29.6,16.99L34.53,14.8Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -1,305 +0,0 @@
Vocaloid Pose Data file
木の人形.osm; // 親ファイル名
60; // 総ポーズボーン数
Bone0{全ての親
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone1{センター
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone2{上半身
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone3{首
0.000000,0.000000,0.000000; // trans x,y,z
-0.000286,0.002004,-0.028579,0.999590; // Quaternion x,y,z,w
}
Bone4{頭
0.000000,0.000000,0.000000; // trans x,y,z
0.003903,-0.047796,-0.030391,0.998387; // Quaternion x,y,z,w
}
Bone5{下半身
0.000000,0.000000,0.000000; // trans x,y,z
-0.000950,0.006664,-0.095038,0.995451; // Quaternion x,y,z,w
}
Bone6{左肩P
-0.000000,0.000000,0.000000; // trans x,y,z
0.000000,-0.000000,-0.000000,1.000000; // Quaternion x,y,z,w
}
Bone7{左肩
-0.000000,0.000000,0.000000; // trans x,y,z
0.003159,-0.000000,0.105260,0.994440; // Quaternion x,y,z,w
}
Bone8{左腕
-0.000000,0.000000,0.000000; // trans x,y,z
0.034463,0.034156,-0.396750,0.916644; // Quaternion x,y,z,w
}
Bone9{左腕捩
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone10{左ひじ
-0.000000,0.000000,0.000000; // trans x,y,z
0.014712,-0.013995,0.076793,0.996840; // Quaternion x,y,z,w
}
Bone11{左手捩
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone12{左手首
-0.000000,0.000000,0.000000; // trans x,y,z
0.028156,-0.026783,0.146962,0.988378; // Quaternion x,y,z,w
}
Bone13{左親指1
0.000000,0.000000,0.000000; // trans x,y,z
0.002261,-0.000573,-0.306446,0.951885; // Quaternion x,y,z,w
}
Bone14{左親指2
0.000000,0.000000,0.000000; // trans x,y,z
0.047385,-0.118953,0.062146,0.989819; // Quaternion x,y,z,w
}
Bone15{左人指1
0.000000,0.000000,0.000000; // trans x,y,z
0.020755,0.017947,0.064537,0.997538; // Quaternion x,y,z,w
}
Bone16{左人指2
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.024997,0.999687; // Quaternion x,y,z,w
}
Bone17{左人指3
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.029995,0.999550; // Quaternion x,y,z,w
}
Bone18{左中指1
0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,-0.000000,-0.064954,0.997888; // Quaternion x,y,z,w
}
Bone19{左中指2
0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,-0.000000,-0.054972,0.998488; // Quaternion x,y,z,w
}
Bone20{左中指3
0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,0.000000,0.104807,0.994492; // Quaternion x,y,z,w
}
Bone21{左薬指1
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,-0.000000,-0.232843,0.972514; // Quaternion x,y,z,w
}
Bone22{左薬指2
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,-0.000000,-0.188859,0.982004; // Quaternion x,y,z,w
}
Bone23{左薬指3
0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,0.000000,0.124675,0.992197; // Quaternion x,y,z,w
}
Bone24{左小指1
0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,-0.000000,-0.094857,0.995490; // Quaternion x,y,z,w
}
Bone25{左小指2
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,-0.000000,-0.496880,0.867819; // Quaternion x,y,z,w
}
Bone26{左小指3
0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,-0.000000,0.010000,0.999949; // Quaternion x,y,z,w
}
Bone27{右肩P
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone28{右肩
0.000000,0.000000,0.000000; // trans x,y,z
0.003159,0.000000,-0.105260,0.994440; // Quaternion x,y,z,w
}
Bone29{右腕
0.000000,0.000000,0.000000; // trans x,y,z
0.034463,-0.034156,0.396750,0.916644; // Quaternion x,y,z,w
}
Bone30{右腕捩
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone31{右ひじ
0.000000,0.000000,0.000000; // trans x,y,z
0.014712,0.013995,-0.076793,0.996840; // Quaternion x,y,z,w
}
Bone32{右手捩
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone33{右手首
0.000000,0.000000,0.000000; // trans x,y,z
0.028156,0.026783,-0.146962,0.988378; // Quaternion x,y,z,w
}
Bone34{右親指1
-0.000000,0.000000,0.000000; // trans x,y,z
0.002261,0.000573,0.306446,0.951885; // Quaternion x,y,z,w
}
Bone35{右親指2
-0.000000,0.000000,0.000000; // trans x,y,z
0.047385,0.118953,-0.062146,0.989819; // Quaternion x,y,z,w
}
Bone36{右人指1
-0.000000,0.000000,0.000000; // trans x,y,z
0.020755,-0.017947,-0.064537,0.997538; // Quaternion x,y,z,w
}
Bone37{右人指2
-0.000000,0.000000,0.000000; // trans x,y,z
0.000000,-0.000000,-0.024997,0.999687; // Quaternion x,y,z,w
}
Bone38{右人指3
-0.000000,0.000000,0.000000; // trans x,y,z
0.000000,-0.000000,-0.029995,0.999550; // Quaternion x,y,z,w
}
Bone39{右中指1
-0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,0.000000,0.064954,0.997888; // Quaternion x,y,z,w
}
Bone40{右中指2
-0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,0.000000,0.054972,0.998488; // Quaternion x,y,z,w
}
Bone41{右中指3
-0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,-0.000000,-0.104807,0.994492; // Quaternion x,y,z,w
}
Bone42{右薬指1
-0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.232843,0.972514; // Quaternion x,y,z,w
}
Bone43{右薬指2
-0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.188859,0.982004; // Quaternion x,y,z,w
}
Bone44{右薬指3
-0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,-0.000000,-0.124675,0.992197; // Quaternion x,y,z,w
}
Bone45{右小指1
-0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,0.000000,0.094857,0.995490; // Quaternion x,y,z,w
}
Bone46{右小指2
-0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.496880,0.867819; // Quaternion x,y,z,w
}
Bone47{右小指3
-0.000000,0.000000,0.000000; // trans x,y,z
-0.000000,0.000000,-0.010000,0.999949; // Quaternion x,y,z,w
}
Bone48{左足
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone49{左ひざ
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone50{左足首
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone51{右足
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone52{右ひざ
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone53{右足首
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone54{左つま先
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone55{右つま先
0.000000,0.000000,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone56{左足IK
-0.132786,0.336300,0.033801; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone57{右足IK
0.148176,0.000000,0.001482; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone58{左つま先IK
-0.419457,-0.807707,-0.002797; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}
Bone59{右つま先IK
0.069849,-0.366887,0.000000; // trans x,y,z
0.000000,0.000000,0.000000,1.000000; // Quaternion x,y,z,w
}

View File

@ -1,6 +1,6 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { BetaKey } from "~/models";
import type { Pagination } from "@storyteller/ui-pagescene";
import type { Pagination } from "~/types/artcraft-web";
export class BetaKeysApi extends ApiManager {
public CreateBetaKey({

View File

@ -3,7 +3,7 @@ import type {
MediaInfo,
Pagination,
PaginationInfinite,
} from "@storyteller/ui-pagescene";
} from "~/types/artcraft-web";
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import type { Property } from "csstype";

View File

@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import type { Prompts } from "@storyteller/ui-pagescene";
import type { Prompts } from "~/types/artcraft-web";
export class PromptsApi extends ApiManager {
public GetPromptsByToken({

View File

@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { GenerateTtsAudioErrorType } from "@storyteller/ui-pagescene";
import { GenerateTtsAudioErrorType } from "~/types/artcraft-web";
import { Visibility } from "~/enums";
export interface GenerateTtsAudioRequest {

View File

@ -1,5 +1,5 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import type { UserBookmarkBatch } from "@storyteller/ui-pagescene";
import type { UserBookmarkBatch } from "~/types/artcraft-web";
export class UserBookmarksApi extends ApiManager {
public CreateUserBookmark({

View File

@ -1,7 +1,7 @@
import { ApiManager, ApiResponse } from "./ApiManager";
import { authentication } from "~/signals";
import { Weight } from "~/models";
import type { Pagination, PaginationInfinite } from "@storyteller/ui-pagescene";
import type { Pagination, PaginationInfinite } from "~/types/artcraft-web";
import {
FilterEngineCategories,
FilterMediaClasses,

View File

@ -1,42 +0,0 @@
import { faTriangleExclamation } from "@fortawesome/pro-solid-svg-icons";
import { useShallow } from "zustand/shallow";
import { Modal } from "@storyteller/ui-modal";
import { Button } from "@storyteller/ui-button";
import { usePageSceneStore } from "@storyteller/ui-pagescene";
export function ErrorDialog() {
const { showErrorDialog, errorDialogTitle, errorDialogMessage, setShowErrorDialog } =
usePageSceneStore(
useShallow((s) => ({
showErrorDialog: s.showErrorDialog,
errorDialogTitle: s.errorDialogTitle,
errorDialogMessage: s.errorDialogMessage,
setShowErrorDialog: s.setShowErrorDialog,
})),
);
return (
<Modal
title={errorDialogTitle}
titleIcon={faTriangleExclamation}
titleIconClassName="text-brand-primary"
isOpen={showErrorDialog}
onClose={() => setShowErrorDialog(false)}
showClose={false}
>
<div>
{errorDialogMessage}
<div className="flex justify-end">
<Button
type="button"
onClick={() => setShowErrorDialog(false)}
variant="secondary"
>
Close
</Button>
</div>
</div>
</Modal>
);
}

View File

@ -1,3 +1,2 @@
export * from "./ErrorDialog";
export * from "./TopBar";

View File

@ -6,24 +6,14 @@ import {
faImage,
faMusic,
} from "@fortawesome/pro-solid-svg-icons";
import { useExperimentalStore } from "@storyteller/ui-settings-modal";
import { useTabStore, TabId } from "~/pages/Stores/TabState";
export type AppId =
| "IMAGE"
| "VIDEO"
| "AUDIO"
| "EDIT"
| "VIDEO_FRAME_EXTRACTOR"
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR"
| "MOODBOARD"
| "SETTINGS";
export interface AppDescriptor {
@ -35,8 +25,7 @@ export interface AppDescriptor {
large?: boolean;
}
// The five modalities. Everything else stays reachable in code (goToApp)
// but is deliberately absent from the nav.
// The five modalities.
export const APP_DESCRIPTORS: AppDescriptor[] = [
{
id: "IMAGE",
@ -72,24 +61,11 @@ export const goToApp = (action?: string) => {
"IMAGE",
"VIDEO",
"AUDIO",
"VIDEO_FRAME_EXTRACTOR",
"VIDEO_WATERMARK_REMOVAL",
"IMAGE_WATERMARK_REMOVAL",
"IMAGE_TO_3D_OBJECT",
"IMAGE_TO_3D_WORLD",
"ANGLES",
"STORYBOARD",
"BACKGROUND_CHANGE",
"VIDEO_EDITOR",
"MOODBOARD",
"SETTINGS",
].includes(action)
) {
if (action === "STORYBOARD") {
const { enabled, storyboardPageEnabled } =
useExperimentalStore.getState();
if (!enabled || !storyboardPageEnabled) return;
}
useTabStore.getState().setActiveTab(action as TabId);
}
};

View File

@ -1 +1 @@
export { AssetFilterOption } from "@storyteller/ui-pagescene";
export { AssetFilterOption } from "~/types/artcraft-web";

View File

@ -1,4 +1 @@
// Single source of truth lives in @storyteller/ui-pagescene; this
// re-export keeps the artcraft `~/enums` surface stable while the
// 3D scene lib owns the canonical enum identity.
export { AssetType } from "@storyteller/ui-pagescene";
export { AssetType } from "~/types/artcraft-web";

View File

@ -1 +1 @@
export { ClipGroup } from "@storyteller/ui-pagescene";
export { ClipGroup } from "~/types/artcraft-web";

View File

@ -1,7 +1,5 @@
// Single source of truth lives in @storyteller/ui-pagescene; this
// re-export keeps the artcraft `~/enums` surface stable.
export {
FilterEngineCategories,
FilterMediaClasses,
FilterMediaType,
} from "@storyteller/ui-pagescene";
} from "~/types/artcraft-web";

View File

@ -1,2 +1 @@
// Single source of truth lives in @storyteller/ui-pagescene.
export { ToastTypes } from "@storyteller/ui-pagescene";
export { ToastTypes } from "~/types/artcraft-web";

View File

@ -8,7 +8,6 @@ import * as gpu from "detect-gpu";
import { useSignals } from "@preact/signals-react/runtime";
import { TopBar } from "~/components";
import { ErrorDialog } from "~/components";
import { toast, Toaster } from "@storyteller/ui-toaster";
import {
PricingModal,
@ -29,7 +28,6 @@ import {
useGenerationFailedEvent,
useTextToImageGenerationCompleteEvent,
} from "@storyteller/tauri-events";
import { useStoryboardPageEnabled } from "@storyteller/ui-settings-modal";
import { useActiveJobs } from "~/hooks/useActiveJobs";
import { useBackgroundLoadingMedia } from "~/hooks/useBackgroundLoadingMedia";
@ -40,16 +38,8 @@ import { useTextToImageStore } from "./PageImage/TextToImageStore";
import TextToImage from "./PageImage/TextToImage";
import ImageToVideo from "./PageVideo/ImageToVideo";
import CreateAudio from "./PageAudio/CreateAudio";
import { VideoFrameExtractor } from "./PageVideoFrameExtractor";
import { VideoWatermarkRemover } from "./PageVideoWatermarkRemover";
import { ImageWatermarkRemover } from "./PageImageWatermarkRemover";
import { ImageTo3DObject } from "./PageImageTo3DObject";
import { ImageTo3DWorld } from "./PageImageTo3DWorld";
import { Angles } from "./PageAngles";
import { Storyboard } from "./PageStoryboard";
import { PageBackgroundChange } from "./PageBackgroundChange";
import { PageVideoEditor } from "./PageVideoEditor";
import { PageMoodboard } from "./PageMoodboard";
import { PageSettings } from "./PageSettings";
import {
topNavMediaId,
@ -115,7 +105,6 @@ export const MainApp = () => {
<TabBody />
<ErrorDialog />
<CredentialErrorModal />
<Toaster offsetTop={52} offsetRight={12} zIndex={9999} />
{currentReminderModalProps && (
@ -145,7 +134,6 @@ export const MainApp = () => {
const TabBody = () => {
const tabStore = useTabStore();
const storyboardPageEnabled = useStoryboardPageEnabled();
// Every page is a fragment whose top-level children may use
// position: fixed; the wrapping <div> scopes them so they don't
@ -172,24 +160,6 @@ const TabBody = () => {
<CreateAudio />
</div>
);
case "VIDEO_FRAME_EXTRACTOR":
return (
<div>
<VideoFrameExtractor />
</div>
);
case "VIDEO_WATERMARK_REMOVAL":
return (
<div>
<VideoWatermarkRemover />
</div>
);
case "IMAGE_WATERMARK_REMOVAL":
return (
<div>
<ImageWatermarkRemover />
</div>
);
case "IMAGE_TO_3D_OBJECT":
return (
<div>
@ -202,46 +172,12 @@ const TabBody = () => {
<ImageTo3DWorld />
</div>
);
case "ANGLES":
return (
<div>
<Angles />
</div>
);
case "STORYBOARD":
return storyboardPageEnabled ? (
<div>
<Storyboard />
</div>
) : null;
case "BACKGROUND_CHANGE":
return (
<div>
<PageBackgroundChange />
</div>
);
case "VIDEO_EDITOR":
return (
<div className="h-[calc(100vh-40px)] w-full">
<PageVideoEditor />
</div>
);
case "SETTINGS":
return (
<div>
<PageSettings />
</div>
);
case "MOODBOARD":
// The TopBar is fixed and 40px tall, so the board sits below it and fills
// the rest of the viewport. overflow-hidden keeps sub-pixel 100vh
// rounding from spawning page scrollbars (which w-screen ancestors would
// turn into a horizontal scrollbar via the Windows scrollbar gutter).
return (
<div className="h-[calc(100vh-40px)] w-full overflow-hidden">
<PageMoodboard />
</div>
);
default:
return null;
}

View File

@ -1,775 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faImages,
faDownload,
faCrosshairs,
faChevronUp,
faChevronDown,
faChevronLeft,
faChevronRight,
} from "@fortawesome/pro-solid-svg-icons";
import { Button, GenerateButton } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
MediaUploadApi,
MediaFilesApi,
downloadFileFromUrl,
} from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import {
useAnglesStore,
ROTATION_VALUES,
TILT_VALUES,
ZOOM_VALUES,
rotationToDisplay,
displayToRotation,
DISPLAY_ROTATION_VALUES,
} from "./AnglesStore";
import { OrbitSphere, snapToNearest } from "./OrbitSphere";
import { twMerge } from "tailwind-merge";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import { SliderV2 } from "@storyteller/ui-sliderv2";
// import { Switch } from "@headlessui/react";
import { GenerateImage } from "@storyteller/tauri-api";
import {
ClassyModelSelector,
useAnglesPageModelList,
ModelPage,
useSelectedModel,
useSelectedProviderForModel,
} from "@storyteller/ui-model-selector";
import {
CostCalculatorButton,
useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu";
import {
HistoryStack,
type ImageBundle,
type BaseSelectorImage,
} from "@storyteller/ui-pagedraw";
const ANGLES_MODEL_PAGE = ModelPage.Angles;
// ─── Main Angles Component ─────────────────────────────────────────────────────
export const Angles = () => {
const anglesModelList = useAnglesPageModelList();
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
const fileInputRef = useRef<HTMLInputElement>(null);
// Model selector
const selectedModel = useSelectedModel(ANGLES_MODEL_PAGE);
const selectedProvider = useSelectedProviderForModel(
ANGLES_MODEL_PAGE,
selectedModel?.id,
);
// Cost estimation
const anglesCredits = useCostBreakdownModalStore(
(s) => s.estimatedCreditsByPage[ANGLES_MODEL_PAGE],
);
// State selectors (only re-render when specific values change)
const sourceImageUrl = useAnglesStore((s) => s.sourceImageUrl);
const sourceMediaToken = useAnglesStore((s) => s.sourceMediaToken);
const sourceThumbnailUrlTemplate = useAnglesStore(
(s) => s.sourceThumbnailUrlTemplate,
);
const imageDimensions = useAnglesStore((s) => s.imageDimensions);
const angleConfig = useAnglesStore((s) => s.angleConfig);
// const generateFromBestAngles = useAnglesStore(
// (s) => s.generateFromBestAngles,
// );
const generatedAngles = useAnglesStore((s) => s.generatedAngles);
const activeAngleId = useAnglesStore((s) => s.activeAngleId);
const isProcessing = useAnglesStore((s) => s.isProcessing);
const isLoadingImage = useAnglesStore((s) => s.isLoadingImage);
// Actions (stable references — never cause re-renders)
const setSourceImage = useAnglesStore((s) => s.setSourceImage);
const setImageDimensions = useAnglesStore((s) => s.setImageDimensions);
const setRotation = useAnglesStore((s) => s.setRotation);
const setTilt = useAnglesStore((s) => s.setTilt);
const setZoom = useAnglesStore((s) => s.setZoom);
// const setGenerateFromBestAngles = useAnglesStore(
// (s) => s.setGenerateFromBestAngles,
// );
const setActiveAngle = useAnglesStore((s) => s.setActiveAngle);
const setIsLoadingImage = useAnglesStore((s) => s.setIsLoadingImage);
const resetSource = useAnglesStore((s) => s.resetSource);
// Currently displayed image (active generated angle, or source)
const activeAngle = useMemo(
() => generatedAngles.find((a) => a.id === activeAngleId) ?? null,
[generatedAngles, activeAngleId],
);
// The token of whatever is currently selected (for HistoryStack highlight)
const selectedImageToken = activeAngleId ?? sourceMediaToken ?? undefined;
// Event listener is at module level in AnglesStore.ts — persists across navigations.
// Derive history bundles from store (no local state needed)
const historyBundles = useMemo<ImageBundle[]>(
() =>
generatedAngles.map((angle) => ({
images: [
{
url: angle.imageUrl,
mediaToken: angle.id,
thumbnailUrlTemplate: angle.thumbnailUrlTemplate,
fullImageUrl: angle.imageUrl,
},
],
})),
[generatedAngles],
);
// Derive pending placeholders from store
const pendingSubscriberIds = useAnglesStore((s) => s.pendingSubscriberIds);
const pendingGenerations = useMemo(
() => pendingSubscriberIds.map((id) => ({ id, count: 1 })),
[pendingSubscriberIds],
);
// Window resize handler (debounced to avoid excessive re-renders)
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
const handleResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
}, 150);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
clearTimeout(timeoutId);
};
}, []);
// ─── Local file upload → MediaUploadApi → media token ───────────────────────
const handleLocalImageSelect = useCallback(
async (files: FileList) => {
const file = files[0];
if (!file || !file.type.startsWith("image/")) return;
setIsLoadingImage(true);
try {
const objectUrl = URL.createObjectURL(file);
// Load dimensions
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = objectUrl;
});
// Upload to backend for media token
const mediaUploadApi = new MediaUploadApi();
const uploadResult = await mediaUploadApi.UploadImage({
blob: file,
fileName: file.name,
uuid: uuidv4(),
});
if (!uploadResult?.success || !uploadResult.data) {
throw new Error("Upload failed — no media token returned");
}
const mediaToken = uploadResult.data;
// Fetch CDN URL + thumbnail template from backend
let cdnUrl = objectUrl;
let thumbnailUrlTemplate: string | undefined;
try {
const api = new MediaFilesApi();
const mediaFile = await api.GetMediaFileByToken({
mediaFileToken: mediaToken,
});
if (mediaFile.success && mediaFile.data) {
const links = mediaFile.data.media_links as
| {
cdn_url?: string;
thumbnail_template?: string;
maybe_thumbnail_template?: string;
}
| undefined;
cdnUrl =
links?.cdn_url ||
(mediaFile.data as any).public_bucket_url ||
objectUrl;
thumbnailUrlTemplate =
links?.thumbnail_template || links?.maybe_thumbnail_template;
}
} catch {
// Fall back to blob URL if fetching CDN info fails
}
setSourceImage(cdnUrl, mediaToken, thumbnailUrlTemplate);
setIsLoadingImage(false);
} catch (error) {
console.error("Error processing image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
}
},
[setSourceImage, setImageDimensions, setIsLoadingImage],
);
const handleImageSelect = useCallback((id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
return [id];
});
}, []);
// ─── Gallery select → media token from item.id ──────────────────────────────
const handleGallerySelect = useCallback(
async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
const mediaToken = item.id; // item.id is a media token
const imageUrl = item.fullImage;
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
setIsLoadingImage(true);
try {
await new Promise<void>((resolve, reject) => {
const img = new Image();
img.onload = () => {
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
resolve();
};
img.onerror = () => reject(new Error("Failed to load image"));
img.src = imageUrl;
});
setSourceImage(imageUrl, mediaToken);
setIsLoadingImage(false);
} catch (error) {
console.error("Error processing gallery image:", error);
toast.error("Failed to process image");
setIsLoadingImage(false);
}
},
[setSourceImage, setImageDimensions, setIsLoadingImage],
);
// ─── Generate with subscriber pattern ───────────────────────────────────────
const handleGenerate = useCallback(async () => {
const state = useAnglesStore.getState();
if (!state.sourceMediaToken) return;
const subscriberId = uuidv4();
state.startGeneration(subscriberId);
window.__storeTaskEnqueueMeta?.({
prompt: "",
modelType: (selectedModel as any)?.tauriId || "flux_2_lora_angles",
timestamp: Date.now(),
});
try {
await GenerateImage({
model: selectedModel ?? "flux_2_lora_angles",
provider: selectedProvider ?? undefined,
image_media_tokens: [state.sourceMediaToken],
prompt: "",
adjust_horizontal_angle: state.angleConfig.rotation,
adjust_vertical_angle: state.angleConfig.tilt,
adjust_zoom: state.angleConfig.zoom,
frontend_subscriber_id: subscriberId,
});
} catch (error) {
console.error("Error generating angle:", error);
toast.error("Failed to generate angle");
useAnglesStore.getState().removePendingSubscriber(subscriberId);
}
}, [selectedModel, selectedProvider]);
const handleDownload = useCallback(async () => {
if (!activeAngle) {
toast.error("No image to download");
return;
}
try {
await downloadFileFromUrl(activeAngle.imageUrl);
toast.success("Image saved to Downloads folder");
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download image");
}
}, [activeAngle]);
// Called when user releases the sphere drag — values are already snapped
const handleSphereDragEnd = useCallback(
(snappedRotation: number, snappedTilt: number) => {
setRotation(snappedRotation);
setTilt(snappedTilt);
},
[setRotation, setTilt],
);
const showUploadScreen = !sourceImageUrl && !isLoadingImage;
const imageContainerStyle = useMemo(() => {
if (!imageDimensions) {
return { width: "600px", height: "450px" };
}
const horizontalPadding = 128 + 80;
const verticalPadding = 56 + 64 + 160;
const availableWidth = windowSize.width - horizontalPadding;
const availableHeight = windowSize.height - verticalPadding;
const maxWidth = Math.min(availableWidth, 1200);
const maxHeight = Math.max(availableHeight, 200);
const imageAspect = imageDimensions.width / imageDimensions.height;
let width = maxWidth;
let height = width / imageAspect;
if (height > maxHeight) {
height = maxHeight;
width = height * imageAspect;
}
width = Math.max(width, 200);
height = Math.max(height, 150);
return { width: `${width}px`, height: `${height}px` };
}, [imageDimensions, windowSize.width, windowSize.height]);
const handleFileInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
handleLocalImageSelect(e.target.files);
e.target.value = "";
}
},
[handleLocalImageSelect],
);
const handleOpenGallery = useCallback(() => {
setIsGalleryModalOpen(true);
}, []);
const handleCloseGallery = useCallback(() => {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
}, []);
const handleChangeImage = useCallback(() => {
resetSource();
useAnglesStore.getState().clearGeneratedAngles();
}, [resetSource]);
// Slider handlers that snap to allowed values
const handleRotationSlider = useCallback(
(value: number) => {
const snapped = snapToNearest(value, DISPLAY_ROTATION_VALUES);
setRotation(displayToRotation(snapped));
},
[setRotation],
);
const handleTiltSlider = useCallback(
(value: number) => {
setTilt(snapToNearest(value, TILT_VALUES));
},
[setTilt],
);
const handleZoomSlider = useCallback(
(value: number) => {
setZoom(snapToNearest(value, ZOOM_VALUES));
},
[setZoom],
);
// Arrow step handlers for orbit sphere
const handleRotationStep = useCallback(
(direction: 1 | -1) => {
const idx = ROTATION_VALUES.indexOf(angleConfig.rotation);
const next =
(idx + direction + ROTATION_VALUES.length) % ROTATION_VALUES.length;
setRotation(ROTATION_VALUES[next]);
},
[angleConfig.rotation, setRotation],
);
const handleTiltStep = useCallback(
(direction: 1 | -1) => {
const idx = TILT_VALUES.indexOf(angleConfig.tilt);
const next = Math.max(
0,
Math.min(TILT_VALUES.length - 1, idx + direction),
);
setTilt(TILT_VALUES[next]);
},
[angleConfig.tilt, setTilt],
);
// ─── HistoryStack data ──────────────────────────────────────────────────────
// Source image as the first entry so user can return to original
const allBundles = useMemo(() => {
if (!sourceImageUrl || !sourceMediaToken) return historyBundles;
const sourceBundle: ImageBundle = {
images: [
{
url: sourceImageUrl,
mediaToken: sourceMediaToken,
thumbnailUrlTemplate: sourceThumbnailUrlTemplate ?? undefined,
fullImageUrl: sourceImageUrl,
},
],
};
return [sourceBundle, ...historyBundles];
}, [
sourceImageUrl,
sourceMediaToken,
sourceThumbnailUrlTemplate,
historyBundles,
]);
const handleHistoryImageSelect = useCallback(
(image: BaseSelectorImage) => {
// If selecting the source image, clear active angle to show source
if (image.mediaToken === useAnglesStore.getState().sourceMediaToken) {
setActiveAngle(null);
} else {
setActiveAngle(image.mediaToken);
}
},
[setActiveAngle],
);
const handleHistoryImageRemove = useCallback((image: BaseSelectorImage) => {
useAnglesStore.getState().removeGeneratedAngle(image.mediaToken);
}, []);
const handleHistoryClear = useCallback(() => {
useAnglesStore.getState().clearGeneratedAngles();
}, []);
return (
<>
<div className="flex h-[calc(100vh-56px)] w-full flex-col overflow-hidden bg-ui-background text-base-fg">
{showUploadScreen ? (
/* ──── Upload screen ──── */
<div className="flex flex-1 items-center justify-center p-8 lg:p-16">
<div className="w-full max-w-5xl">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-panel shadow-lg">
<UploadEntryCard
icon={faCrosshairs}
title="Angles"
description="Generate new camera angles from a single photo. Upload an image to get started."
accentBackgroundClass="bg-lime-500/20"
accentBorderClass="border-lime-500/40"
accept="image/*"
onFilesSelected={handleLocalImageSelect}
primaryLabel="Upload media"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={handleOpenGallery}
disabled={isLoadingImage}
/>
{isLoadingImage && (
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
<LoadingSpinner className="h-12 w-12" />
</div>
)}
</div>
</div>
</div>
) : (
/* ──── Editor layout — floating panels over full-bleed canvas ──── */
<div className="relative h-full w-full">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileInputChange}
/>
{/* Full-bleed image display */}
<div className="flex h-full w-full items-center justify-center px-16 pb-56 pt-16">
<div
className="relative overflow-hidden rounded-xl shadow-lg"
style={imageContainerStyle}
>
{activeAngle ? (
<img
src={activeAngle.imageUrl}
alt="Generated Angle"
className="absolute inset-0 h-full w-full object-contain"
/>
) : sourceImageUrl ? (
<img
src={sourceImageUrl}
alt="Source"
className="absolute inset-0 h-full w-full object-contain"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center bg-ui-background">
<LoadingSpinner className="h-12 w-12" />
</div>
)}
</div>
</div>
{/* ── Floating top toolbar ── */}
<div className="absolute left-1/2 top-4 z-10 -translate-x-1/2">
<div className="flex items-center gap-2">
<Button
variant="action"
onClick={handleChangeImage}
className="select-none px-4 py-1.5 text-sm font-medium transition-all"
>
Change Image
</Button>
{activeAngle && (
<Button
variant="primary"
icon={faDownload}
onClick={handleDownload}
disabled={isProcessing}
className={twMerge(
"select-none px-4 py-1.5 text-sm font-medium transition-all",
isProcessing && "cursor-not-allowed opacity-50",
)}
>
Download
</Button>
)}
</div>
</div>
{/* ── History stack (right side, like PageEdit) ── */}
<div className="absolute right-4 top-1/2 z-10 -translate-y-1/2">
<HistoryStack
onClear={handleHistoryClear}
imageBundles={allBundles}
pendingPlaceholders={pendingGenerations}
blurredBackgroundUrl={sourceImageUrl ?? undefined}
selectedImageToken={selectedImageToken}
onImageSelect={handleHistoryImageSelect}
onImageRemove={handleHistoryImageRemove}
/>
</div>
{/* ── Floating bottom angle controls ── */}
<div className="absolute bottom-4 left-1/2 z-10 w-[calc(100%-32px)] max-w-[860px] -translate-x-1/2">
<div className="glass flex items-center gap-4 rounded-xl px-4 lg:gap-5 lg:px-5">
{/* Orbit sphere */}
<div className="relative shrink-0 px-5 py-5">
<button
onClick={() => handleTiltStep(1)}
className="absolute left-1/2 top-0 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<FontAwesomeIcon
icon={faChevronUp}
className="text-[10px]"
/>
</button>
<button
onClick={() => handleTiltStep(-1)}
className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<FontAwesomeIcon
icon={faChevronDown}
className="text-[10px]"
/>
</button>
<button
onClick={() => handleRotationStep(-1)}
className="absolute left-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<FontAwesomeIcon
icon={faChevronLeft}
className="text-[10px]"
/>
</button>
<button
onClick={() => handleRotationStep(1)}
className="absolute right-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
>
<FontAwesomeIcon
icon={faChevronRight}
className="text-[10px]"
/>
</button>
<OrbitSphere
rotation={angleConfig.rotation}
tilt={angleConfig.tilt}
zoom={angleConfig.zoom}
onDragEnd={handleSphereDragEnd}
/>
</div>
{/* Divider */}
<div className="h-20 w-px shrink-0 bg-base-fg/10" />
{/* Sliders group */}
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-3">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
Rotation
</span>
<div className="min-w-0 flex-1">
<SliderV2
min={-180}
max={180}
step={45}
value={rotationToDisplay(angleConfig.rotation)}
onChange={handleRotationSlider}
suffix="°"
/>
</div>
<span className="w-10 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
{rotationToDisplay(angleConfig.rotation)}°
</span>
</div>
<div className="flex items-center gap-3">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
Tilt
</span>
<div className="min-w-0 flex-1">
<SliderV2
min={-60}
max={60}
step={30}
value={angleConfig.tilt}
onChange={handleTiltSlider}
suffix="°"
/>
</div>
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
{angleConfig.tilt}°
</span>
</div>
<div className="flex items-center gap-3">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg">
Zoom
</span>
<div className="min-w-0 flex-1">
<SliderV2
min={0}
max={10}
step={5}
value={angleConfig.zoom}
onChange={handleZoomSlider}
/>
</div>
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70">
{angleConfig.zoom}
</span>
</div>
</div>
{/* Divider */}
<div className="h-20 w-px shrink-0 bg-base-fg/10" />
{/* Toggle + Generate */}
<div className="flex shrink-0 flex-col items-end gap-2.5">
{/* <Switch.Group>
<div className="flex items-center gap-2">
<Switch.Label className="cursor-pointer whitespace-nowrap text-xs text-base-fg/50">
Best 12 angles
</Switch.Label>
<Switch
checked={generateFromBestAngles}
onChange={setGenerateFromBestAngles}
className={twMerge(
"group inline-flex h-5 w-9 items-center rounded-full transition-colors",
generateFromBestAngles ? "bg-primary" : "bg-action",
)}
>
<span
className={twMerge(
"size-3.5 rounded-full bg-white transition",
generateFromBestAngles
? "translate-x-[18px]"
: "translate-x-1",
)}
/>
</Switch>
</div>
</Switch.Group> */}
<ClassyModelSelector
variant="embedded"
items={anglesModelList}
page={ANGLES_MODEL_PAGE}
/>
<GenerateButton
variant="primary"
onClick={handleGenerate}
disabled={!sourceMediaToken}
credits={anglesCredits}
className="whitespace-nowrap px-5 py-1.5 text-sm"
>
Generate
</GenerateButton>
</div>
</div>
</div>
{/* ── Cost calculator + Help (bottom-right) ── */}
<div className="absolute bottom-4 right-4 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={ANGLES_MODEL_PAGE} />
<HelpMenuButton />
</div>
</div>
)}
</div>
<GalleryModal
isOpen={isGalleryModalOpen}
onClose={handleCloseGallery}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
};

View File

@ -1,288 +0,0 @@
import { create } from "zustand";
import { listen } from "@tauri-apps/api/event";
export interface GeneratedAngle {
id: string;
imageUrl: string;
thumbnailUrlTemplate?: string;
rotation: number;
tilt: number;
zoom: number;
timestamp: number;
}
export interface ImageDimensions {
width: number;
height: number;
}
export interface AngleConfig {
rotation: number; // 0, 45, 90, 135, 180, 225, 270, 315
tilt: number; // -30, 0, 30, 60
zoom: number; // 0, 5, 10
}
// Snapped value options (backend values)
export const ROTATION_VALUES = [0, 45, 90, 135, 180, 225, 270, 315];
export const TILT_VALUES = [-30, 0, 30, 60];
export const ZOOM_VALUES = [0, 5, 10];
// ─── Display conversion helpers ──────────────────────────────────────────────
// Backend uses 0–315 for rotation; UI displays −180 to +180 centered at 0.
/** Convert backend rotation (0–315) to display value (−180 to +180). */
export const rotationToDisplay = (backend: number): number =>
backend > 180 ? backend - 360 : backend;
/** Convert display rotation (−180 to +180) to backend value (0–315). */
export const displayToRotation = (display: number): number =>
display < 0 ? display + 360 : display;
/** Snap targets for the display-range rotation slider. */
export const DISPLAY_ROTATION_VALUES = [-180, -135, -90, -45, 0, 45, 90, 135, 180];
interface AnglesState {
// Source image
sourceImageUrl: string | null;
sourceMediaToken: string | null;
sourceThumbnailUrlTemplate: string | null;
imageDimensions: ImageDimensions | null;
// Angle config
angleConfig: AngleConfig;
generateFromBestAngles: boolean;
// Generated results
generatedAngles: GeneratedAngle[];
activeAngleId: string | null;
// Processing
isProcessing: boolean;
pendingSubscriberIds: string[];
isLoadingImage: boolean;
// Actions
setSourceImage: (url: string, mediaToken: string | null, thumbnailUrlTemplate?: string | null) => void;
setImageDimensions: (dims: ImageDimensions | null) => void;
setRotation: (value: number) => void;
setTilt: (value: number) => void;
setZoom: (value: number) => void;
setGenerateFromBestAngles: (value: boolean) => void;
addGeneratedAngle: (angle: GeneratedAngle) => void;
setActiveAngle: (id: string | null) => void;
getActiveAngle: () => GeneratedAngle | null;
removeGeneratedAngle: (id: string) => void;
clearGeneratedAngles: () => void;
removePendingSubscriber: (subscriberId: string) => void;
setIsLoadingImage: (value: boolean) => void;
startGeneration: (subscriberId: string) => void;
completeGeneration: (
subscriberId: string,
images: Array<{
cdn_url: string;
media_token: string;
maybe_thumbnail_template?: string;
}>,
) => void;
resetSource: () => void;
clearAll: () => void;
}
const DEFAULT_CONFIG: AngleConfig = {
rotation: 0,
tilt: 0,
zoom: 0,
};
export const useAnglesStore = create<AnglesState>((set, get) => ({
sourceImageUrl: null,
sourceMediaToken: null,
sourceThumbnailUrlTemplate: null,
imageDimensions: null,
angleConfig: { ...DEFAULT_CONFIG },
generateFromBestAngles: false,
generatedAngles: [],
activeAngleId: null,
isProcessing: false,
pendingSubscriberIds: [],
isLoadingImage: false,
setSourceImage: (url, mediaToken, thumbnailUrlTemplate) => {
set({ sourceImageUrl: url, sourceMediaToken: mediaToken, sourceThumbnailUrlTemplate: thumbnailUrlTemplate ?? null });
},
setImageDimensions: (dims) => {
set({ imageDimensions: dims });
},
setRotation: (value) => {
set((state) => ({
angleConfig: { ...state.angleConfig, rotation: value },
}));
},
setTilt: (value) => {
set((state) => ({
angleConfig: { ...state.angleConfig, tilt: value },
}));
},
setZoom: (value) => {
set((state) => ({
angleConfig: { ...state.angleConfig, zoom: value },
}));
},
setGenerateFromBestAngles: (value) => {
set({ generateFromBestAngles: value });
},
addGeneratedAngle: (angle) => {
set((state) => ({
generatedAngles: [...state.generatedAngles, angle],
activeAngleId: angle.id,
}));
},
setActiveAngle: (id) => {
set({ activeAngleId: id });
},
getActiveAngle: () => {
const state = get();
return (
state.generatedAngles.find((a) => a.id === state.activeAngleId) ?? null
);
},
removeGeneratedAngle: (id) => {
set((s) => {
const filtered = s.generatedAngles.filter((a) => a.id !== id);
return {
generatedAngles: filtered,
activeAngleId: s.activeAngleId === id ? null : s.activeAngleId,
};
});
},
clearGeneratedAngles: () => {
set({
generatedAngles: [],
activeAngleId: null,
isProcessing: false,
pendingSubscriberIds: [],
});
},
removePendingSubscriber: (subscriberId) => {
set((s) => {
const remaining = s.pendingSubscriberIds.filter(
(id) => id !== subscriberId,
);
return {
pendingSubscriberIds: remaining,
isProcessing: remaining.length > 0,
};
});
},
setIsLoadingImage: (value) => {
set({ isLoadingImage: value });
},
startGeneration: (subscriberId) => {
set((s) => ({
isProcessing: true,
pendingSubscriberIds: [...s.pendingSubscriberIds, subscriberId],
}));
},
completeGeneration: (subscriberId, images) => {
const state = get();
if (!state.pendingSubscriberIds.includes(subscriberId)) return;
const newAngles: GeneratedAngle[] = images.map((img) => ({
id: img.media_token,
imageUrl: img.cdn_url,
thumbnailUrlTemplate: img.maybe_thumbnail_template,
rotation: state.angleConfig.rotation,
tilt: state.angleConfig.tilt,
zoom: state.angleConfig.zoom,
timestamp: Date.now(),
}));
const remaining = state.pendingSubscriberIds.filter(
(id) => id !== subscriberId,
);
set((s) => ({
generatedAngles: [...s.generatedAngles, ...newAngles],
activeAngleId: newAngles[0]?.id ?? s.activeAngleId,
isProcessing: remaining.length > 0,
pendingSubscriberIds: remaining,
}));
},
resetSource: () => {
set({
sourceImageUrl: null,
sourceMediaToken: null,
sourceThumbnailUrlTemplate: null,
imageDimensions: null,
angleConfig: { ...DEFAULT_CONFIG },
generateFromBestAngles: false,
isProcessing: false,
pendingSubscriberIds: [],
isLoadingImage: false,
});
},
clearAll: () => {
set({
sourceImageUrl: null,
sourceMediaToken: null,
sourceThumbnailUrlTemplate: null,
imageDimensions: null,
angleConfig: { ...DEFAULT_CONFIG },
generateFromBestAngles: false,
generatedAngles: [],
activeAngleId: null,
isProcessing: false,
pendingSubscriberIds: [],
isLoadingImage: false,
});
},
}));
// ─── Module-level event listener ────────────────────────────────────────────────
// Persists across page navigations so generation results are never lost.
// Angle models route through ImageGeneration task type, which fires
// text_to_image_generation_complete_event (not image_edit_complete_event).
listen<{
status: string;
data: {
generated_images: Array<{
media_token: string;
cdn_url: string;
maybe_thumbnail_template?: string;
}>;
maybe_frontend_subscriber_id?: string;
};
}>("text_to_image_generation_complete_event", (wrappedEvent) => {
const event = wrappedEvent.payload.data;
const state = useAnglesStore.getState();
if (state.pendingSubscriberIds.length === 0) return;
const subscriberId = event.maybe_frontend_subscriber_id;
if (subscriberId && !state.pendingSubscriberIds.includes(subscriberId))
return;
const resolvedId = subscriberId ?? state.pendingSubscriberIds[0];
const images = event.generated_images.map((img) => ({
cdn_url: img.cdn_url,
media_token: img.media_token,
maybe_thumbnail_template: img.maybe_thumbnail_template,
}));
state.completeGeneration(resolvedId, images);
});

View File

@ -1,729 +0,0 @@
import { useRef, useEffect, useCallback, memo } from "react";
import { ROTATION_VALUES, TILT_VALUES } from "./AnglesStore";
// ─── Types ────────────────────────────────────────────────────────────────────
export interface OrbitSphereProps {
rotation: number;
tilt: number;
zoom: number;
onDragEnd: (rotation: number, tilt: number) => void;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Snap a raw value to the nearest element in an array of allowed values. */
export const snapToNearest = (
value: number,
allowedValues: number[],
): number => {
let closest = allowedValues[0];
let minDist = Math.abs(value - closest);
for (const v of allowedValues) {
const dist = Math.abs(value - v);
if (dist < minDist) {
minDist = dist;
closest = v;
}
}
return closest;
};
// ─── Face descriptor used by the painter's-algorithm renderer ────────────────
interface Face {
pts: { x: number; y: number; depth: number }[];
fillColor: string;
edgeColor: string;
edgeWidth: number;
/** Lens front cap — draws concentric glass rings. */
isFront: boolean;
/** Camera body back face — draws the LCD screen inset. */
isBack: boolean;
/** Camera body front face — draws the red recording dot. */
isBodyFront: boolean;
avgDepth: number;
/** Targeting-laser line from lens tip to sphere centre. */
isLine?: boolean;
}
// ─── OrbitSphere Component ───────────────────────────────────────────────────
export const OrbitSphere = memo(
({ rotation, tilt, onDragEnd }: OrbitSphereProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const isDragging = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
const liveRotation = useRef(rotation);
const liveTilt = useRef(tilt);
const rafId = useRef<number | null>(null);
// Keep live refs in sync when props change (from sliders / snap)
useEffect(() => {
if (!isDragging.current) {
liveRotation.current = rotation;
liveTilt.current = tilt;
}
}, [rotation, tilt]);
// ─── Core draw function ──────────────────────────────────────────────────
const drawSphere = useCallback(
(renderRotation: number, renderTilt: number, dragging = false) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// HiDPI setup
const displayW = canvas.clientWidth;
const displayH = canvas.clientHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = displayW * dpr;
canvas.height = displayH * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = displayW;
const h = displayH;
const cx = w / 2;
const cy = h / 2;
const radius = Math.min(w, h) * 0.4;
ctx.clearRect(0, 0, w, h);
// Subtle radial outer glow
const outerGlow = ctx.createRadialGradient(
cx,
cy,
radius * 0.8,
cx,
cy,
radius * 1.3,
);
outerGlow.addColorStop(0, "rgba(255, 255, 255, 0.02)");
outerGlow.addColorStop(1, "rgba(255, 255, 255, 0)");
ctx.fillStyle = outerGlow;
ctx.fillRect(0, 0, w, h);
const rotRad = (renderRotation * Math.PI) / 180;
const tiltRad = (renderTilt * Math.PI) / 180;
// ── Perspective projection ──────────────────────────────────────────
const project = (
x3d: number,
y3d: number,
z3d: number,
): { x: number; y: number; depth: number } => {
const perspective = 6;
const scale = perspective / (perspective - z3d);
return {
x: cx + x3d * radius * scale,
y: cy + y3d * radius * scale,
depth: z3d,
};
};
// ── Wireframe sphere ────────────────────────────────────────────────
const wireAlpha = dragging ? 0.12 : 0.06;
ctx.strokeStyle = `rgba(255, 255, 255, ${wireAlpha})`;
ctx.lineWidth = 0.7;
// Meridians
for (let i = 0; i < 12; i++) {
const angle = (i * Math.PI) / 6 + rotRad;
ctx.beginPath();
for (let j = 0; j <= 40; j++) {
const phi = (j / 40) * Math.PI * 2;
const p = project(
Math.cos(angle) * Math.sin(phi),
Math.cos(phi),
Math.sin(angle) * Math.sin(phi),
);
if (j === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
}
// Parallels
for (let i = 1; i < 6; i++) {
const phi = (i * Math.PI) / 6;
ctx.beginPath();
for (let j = 0; j <= 40; j++) {
const angle = (j / 40) * Math.PI * 2 + rotRad;
const p = project(
Math.cos(angle) * Math.sin(phi),
Math.cos(phi),
Math.sin(angle) * Math.sin(phi),
);
if (j === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
}
// ── 3D camera position & orientation ───────────────────────────────
const camPosX = Math.sin(rotRad) * Math.cos(tiltRad);
const camPosY = -Math.sin(tiltRad);
const camPosZ = Math.cos(rotRad) * Math.cos(tiltRad);
const centerScreen = project(0, 0, 0);
// Direction vector pointing from camera towards origin
const dirX = -camPosX;
const dirY = -camPosY;
const dirZ = -camPosZ;
// Camera-local right axis (world-up crossed with dir)
let rightX = dirZ;
const rightY = 0;
let rightZ = -dirX;
const rightLen = Math.sqrt(rightX * rightX + rightZ * rightZ) || 1;
rightX /= rightLen;
rightZ /= rightLen;
// Camera-local up axis
let upX = rightY * dirZ - rightZ * dirY;
let upY = rightZ * dirX - rightX * dirZ;
let upZ = rightX * dirY - rightY * dirX;
const upLen = Math.sqrt(upX * upX + upY * upY + upZ * upZ) || 1;
upX /= upLen;
upY /= upLen;
upZ /= upLen;
// ── Camera body dimensions ─────────────────────────────────────────
const bodyW = 0.28;
const bodyH = 0.19;
const bodyD = 0.22;
// ── Geometry helpers ───────────────────────────────────────────────
/** Build 8 corners of an axis-aligned box in camera-local space. */
const makeBox = (
bx: number,
by: number,
bz: number,
hw: number,
hh: number,
hd: number,
) => [
{
x: bx + rightX * hw + upX * hh,
y: by + rightY * hw + upY * hh,
z: bz + rightZ * hw + upZ * hh,
},
{
x: bx - rightX * hw + upX * hh,
y: by - rightY * hw + upY * hh,
z: bz - rightZ * hw + upZ * hh,
},
{
x: bx - rightX * hw - upX * hh,
y: by - rightY * hw - upY * hh,
z: bz - rightZ * hw - upZ * hh,
},
{
x: bx + rightX * hw - upX * hh,
y: by + rightY * hw - upY * hh,
z: bz + rightZ * hw - upZ * hh,
},
{
x: bx + dirX * hd + rightX * hw + upX * hh,
y: by + dirY * hd + rightY * hw + upY * hh,
z: bz + dirZ * hd + rightZ * hw + upZ * hh,
},
{
x: bx + dirX * hd - rightX * hw + upX * hh,
y: by + dirY * hd - rightY * hw + upY * hh,
z: bz + dirZ * hd - rightZ * hw + upZ * hh,
},
{
x: bx + dirX * hd - rightX * hw - upX * hh,
y: by + dirY * hd - rightY * hw - upY * hh,
z: bz + dirZ * hd - rightZ * hw - upZ * hh,
},
{
x: bx + dirX * hd + rightX * hw - upX * hh,
y: by + dirY * hd + rightY * hw - upY * hh,
z: bz + dirZ * hd + rightZ * hw - upZ * hh,
},
];
// ── Build camera geometry ──────────────────────────────────────────
const bodyCorners3D = makeBox(
camPosX,
camPosY,
camPosZ,
bodyW,
bodyH,
bodyD,
);
const lensLen = 0.14;
const lensR = 0.1;
const lensCx = camPosX + dirX * bodyD;
const lensCy = camPosY + dirY * bodyD;
const lensCz = camPosZ + dirZ * bodyD;
const vfCorners3D = makeBox(
camPosX + dirX * -0.03 + upX * (bodyH + 0.05),
camPosY + dirY * -0.03 + upY * (bodyH + 0.05),
camPosZ + dirZ * -0.03 + upZ * (bodyH + 0.05),
0.09,
0.05,
0.1,
);
const gripCorners3D = makeBox(
camPosX + rightX * (bodyW + 0.03),
camPosY + rightY * (bodyW + 0.03),
camPosZ + rightZ * (bodyW + 0.03),
0.04,
bodyH * 0.85,
bodyD * 0.7,
);
// Project box corners to screen
const bodyCorners = bodyCorners3D.map((c) => project(c.x, c.y, c.z));
const vfCorners = vfCorners3D.map((c) => project(c.x, c.y, c.z));
const gripCorners = gripCorners3D.map((c) => project(c.x, c.y, c.z));
// Box face winding: [back, front, top, bottom, left, right]
const BOX_FACES: [number, number, number, number][] = [
[0, 1, 2, 3],
[4, 5, 6, 7],
[0, 1, 5, 4],
[3, 2, 6, 7],
[1, 2, 6, 5],
[0, 3, 7, 4],
];
// Edge colours
const edgeGlow = "rgba(180, 200, 255, 0.35)";
const edgeGlowBright = "rgba(180, 200, 255, 0.55)";
// ── Face collector ─────────────────────────────────────────────────
const allFaces: Face[] = [];
const addBoxFaces = (
corners: { x: number; y: number; depth: number }[],
fillBase: string,
edge: string,
edgeW: number,
markBack = false,
markBodyFront = false,
) => {
for (let i = 0; i < 6; i++) {
const pts = BOX_FACES[i].map((idx) => corners[idx]);
allFaces.push({
pts,
fillColor: fillBase,
edgeColor: edge,
edgeWidth: edgeW,
isFront: false,
isBack: markBack && i === 0,
isBodyFront: markBodyFront && i === 1,
avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length,
});
}
};
const addCylinder = (
bx: number,
by: number,
bz: number,
r: number,
len: number,
fillBase: string,
edge: string,
edgeW: number,
markFrontCap: boolean,
) => {
const NUM_SEGS = 20;
const basePts: { x: number; y: number; z: number }[] = [];
const frontPts: { x: number; y: number; z: number }[] = [];
for (let i = 0; i < NUM_SEGS; i++) {
const a = (i / NUM_SEGS) * Math.PI * 2;
const lx = Math.cos(a) * r;
const ly = Math.sin(a) * r;
basePts.push({
x: bx + rightX * lx + upX * ly,
y: by + rightY * lx + upY * ly,
z: bz + rightZ * lx + upZ * ly,
});
frontPts.push({
x: bx + dirX * len + rightX * lx + upX * ly,
y: by + dirY * len + rightY * lx + upY * ly,
z: bz + dirZ * len + rightZ * lx + upZ * ly,
});
}
// Side quads
for (let i = 0; i < NUM_SEGS; i++) {
const ni = (i + 1) % NUM_SEGS;
const pts = [
basePts[i],
basePts[ni],
frontPts[ni],
frontPts[i],
].map((pt) => project(pt.x, pt.y, pt.z));
allFaces.push({
pts,
fillColor: fillBase,
edgeColor: edge,
edgeWidth: edgeW,
isFront: false,
isBack: false,
isBodyFront: false,
avgDepth: pts.reduce((s, p) => s + p.depth, 0) / pts.length,
});
}
// Front cap
const capPts = frontPts.map((p) => project(p.x, p.y, p.z));
allFaces.push({
pts: capPts,
fillColor: fillBase,
edgeColor: edge,
edgeWidth: edgeW,
isFront: markFrontCap,
isBack: false,
isBodyFront: false,
avgDepth: capPts.reduce((s, p) => s + p.depth, 0) / capPts.length,
});
};
// ── Add all camera parts ───────────────────────────────────────────
addBoxFaces(
bodyCorners,
"rgba(35, 38, 45, 0.92)",
edgeGlow,
1.0,
true,
true,
);
addBoxFaces(
gripCorners,
"rgba(30, 33, 40, 0.9)",
edgeGlow,
0.7,
false,
false,
);
addCylinder(
lensCx,
lensCy,
lensCz,
lensR,
lensLen,
"rgba(28, 30, 38, 0.95)",
edgeGlowBright,
0.8,
true,
);
addBoxFaces(
vfCorners,
"rgba(40, 43, 50, 0.9)",
edgeGlow,
0.8,
false,
false,
);
// Targeting laser from lens tip to sphere centre
const lensTip = project(
lensCx + dirX * lensLen,
lensCy + dirY * lensLen,
lensCz + dirZ * lensLen,
);
allFaces.push({
pts: [lensTip, centerScreen],
fillColor: "",
edgeColor: "",
edgeWidth: 0,
isFront: false,
isBack: false,
isBodyFront: false,
avgDepth: (lensTip.depth + centerScreen.depth) / 2,
isLine: true,
});
// ── Painter's sort & draw ─────────────────────────────────────────
allFaces.sort((a, b) => a.avgDepth - b.avgDepth);
ctx.lineJoin = "round";
for (const face of allFaces) {
// ── Targeting laser ───────────────────────────────────────────
if (face.isLine) {
ctx.save();
ctx.strokeStyle = "rgba(180, 210, 255, 0.7)";
ctx.lineWidth = 1.5;
ctx.shadowColor = "rgba(160, 200, 255, 0.9)";
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.moveTo(face.pts[0].x, face.pts[0].y);
ctx.lineTo(face.pts[1].x, face.pts[1].y);
ctx.stroke();
ctx.restore();
continue;
}
// ── Fill + glow edges ─────────────────────────────────────────
ctx.beginPath();
ctx.moveTo(face.pts[0].x, face.pts[0].y);
for (let i = 1; i < face.pts.length; i++)
ctx.lineTo(face.pts[i].x, face.pts[i].y);
ctx.closePath();
ctx.fillStyle = face.fillColor;
ctx.fill();
ctx.save();
ctx.shadowColor = "rgba(160, 190, 255, 0.4)";
ctx.shadowBlur = 3;
ctx.strokeStyle = face.edgeColor;
ctx.lineWidth = face.edgeWidth;
ctx.stroke();
ctx.restore();
ctx.strokeStyle = face.edgeColor;
ctx.lineWidth = face.edgeWidth * 0.5;
ctx.stroke();
// ── Back LCD screen inset ─────────────────────────────────────
if (face.isBack) {
const scx =
(face.pts[0].x + face.pts[1].x + face.pts[2].x + face.pts[3].x) /
4;
const scy =
(face.pts[0].y + face.pts[1].y + face.pts[2].y + face.pts[3].y) /
4;
const screenPts = face.pts.map((p) => ({
x: scx + (p.x - scx) * 0.82,
y: scy + (p.y - scy) * 0.75,
}));
ctx.beginPath();
ctx.moveTo(screenPts[0].x, screenPts[0].y);
for (let i = 1; i < screenPts.length; i++)
ctx.lineTo(screenPts[i].x, screenPts[i].y);
ctx.closePath();
ctx.fillStyle = "rgba(18, 20, 26, 0.98)";
ctx.fill();
ctx.strokeStyle = "rgba(255, 255, 255, 0.06)";
ctx.lineWidth = 1;
ctx.stroke();
// Subtle specular highlight on top-left corner
ctx.beginPath();
ctx.moveTo(screenPts[0].x, screenPts[0].y);
ctx.lineTo(screenPts[1].x, screenPts[1].y);
ctx.lineTo(screenPts[2].x, screenPts[2].y);
ctx.closePath();
ctx.fillStyle = "rgba(255, 255, 255, 0.02)";
ctx.fill();
}
// ── Lens glass rings & gradient ───────────────────────────────
if (face.isFront) {
// Projects a concentric ring of lens-local radius rScale onto the screen.
const getCirclePts = (rScale: number) => {
const pts = [];
for (let i = 0; i < 20; i++) {
const a = (i / 20) * Math.PI * 2;
const lx = Math.cos(a) * lensR * rScale;
const ly = Math.sin(a) * lensR * rScale;
pts.push(
project(
lensCx + dirX * lensLen + rightX * lx + upX * ly,
lensCy + dirY * lensLen + rightY * lx + upY * ly,
lensCz + dirZ * lensLen + rightZ * lx + upZ * ly,
),
);
}
return pts;
};
const drawRing = (pts: { x: number; y: number }[]) => {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++)
ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath();
};
// Mid ring
ctx.strokeStyle = "rgba(180, 200, 255, 0.45)";
ctx.lineWidth = 1.0;
drawRing(getCirclePts(0.7));
ctx.stroke();
// Inner ring
ctx.strokeStyle = "rgba(160, 190, 255, 0.3)";
ctx.lineWidth = 0.6;
drawRing(getCirclePts(0.4));
ctx.stroke();
// Glass gradient fill
const lfc = project(
lensCx + dirX * lensLen,
lensCy + dirY * lensLen,
lensCz + dirZ * lensLen,
);
const lensGrad = ctx.createRadialGradient(
lfc.x,
lfc.y,
0,
lfc.x,
lfc.y,
8,
);
lensGrad.addColorStop(0, "rgba(100, 160, 255, 0.4)");
lensGrad.addColorStop(0.6, "rgba(60, 100, 200, 0.2)");
lensGrad.addColorStop(1, "rgba(40, 70, 150, 0.05)");
ctx.fillStyle = lensGrad;
drawRing(getCirclePts(0.85));
ctx.fill();
// Outer glow ring
ctx.save();
ctx.shadowColor = "rgba(160, 190, 255, 0.6)";
ctx.shadowBlur = 6;
ctx.strokeStyle = "rgba(180, 200, 255, 0.4)";
ctx.lineWidth = 1.2;
drawRing(getCirclePts(0.95));
ctx.stroke();
ctx.restore();
}
// ── Red recording dot on body front ───────────────────────────
if (face.isBodyFront) {
const dot3D = {
x:
camPosX +
dirX * bodyD +
rightX * bodyW * 0.6 +
upX * bodyH * 0.6,
y:
camPosY +
dirY * bodyD +
rightY * bodyW * 0.6 +
upY * bodyH * 0.6,
z:
camPosZ +
dirZ * bodyD +
rightZ * bodyW * 0.6 +
upZ * bodyH * 0.6,
};
const dot = project(dot3D.x, dot3D.y, dot3D.z);
const glow = ctx.createRadialGradient(
dot.x,
dot.y,
0,
dot.x,
dot.y,
5,
);
glow.addColorStop(0, "rgba(255, 40, 40, 0.8)");
glow.addColorStop(0.5, "rgba(255, 40, 40, 0.2)");
glow.addColorStop(1, "rgba(255, 40, 40, 0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(dot.x, dot.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255, 60, 60, 1)";
ctx.beginPath();
ctx.arc(dot.x, dot.y, 1.8, 0, Math.PI * 2);
ctx.fill();
}
}
// ── Centre crosshair ─────────────────────────────────────────────
ctx.lineJoin = "miter";
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
ctx.lineWidth = 0.8;
const crossSize = 5;
ctx.beginPath();
ctx.moveTo(centerScreen.x - crossSize, centerScreen.y);
ctx.lineTo(centerScreen.x + crossSize, centerScreen.y);
ctx.moveTo(centerScreen.x, centerScreen.y - crossSize);
ctx.lineTo(centerScreen.x, centerScreen.y + crossSize);
ctx.stroke();
},
[],
);
// Redraw on prop changes (not during drag — drag has its own rAF loop)
useEffect(() => {
if (!isDragging.current) drawSphere(rotation, tilt);
}, [rotation, tilt, drawSphere]);
// Keep stable refs so mouse handlers are never stale
const drawSphereRef = useRef(drawSphere);
drawSphereRef.current = drawSphere;
const onDragEndRef = useRef(onDragEnd);
onDragEndRef.current = onDragEnd;
// Window-level mouse listeners so dragging continues outside the canvas
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!isDragging.current) return;
const dx = e.clientX - lastPos.current.x;
const dy = e.clientY - lastPos.current.y;
lastPos.current = { x: e.clientX, y: e.clientY };
liveRotation.current += dx * 0.8;
liveTilt.current = Math.max(
-30,
Math.min(60, liveTilt.current - dy * 0.8),
);
if (rafId.current !== null) cancelAnimationFrame(rafId.current);
rafId.current = requestAnimationFrame(() => {
drawSphereRef.current(liveRotation.current, liveTilt.current, true);
rafId.current = null;
});
};
const onUp = () => {
if (!isDragging.current) return;
isDragging.current = false;
let rawRot = liveRotation.current % 360;
if (rawRot < 0) rawRot += 360;
const snappedRot = snapToNearest(rawRot, ROTATION_VALUES);
const snappedTilt = snapToNearest(liveTilt.current, TILT_VALUES);
liveRotation.current = snappedRot;
liveTilt.current = snappedTilt;
drawSphereRef.current(snappedRot, snappedTilt);
onDragEndRef.current(snappedRot, snappedTilt);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, []);
const handleMouseDown = (e: React.MouseEvent) => {
isDragging.current = true;
lastPos.current = { x: e.clientX, y: e.clientY };
};
return (
<canvas
ref={canvasRef}
className="h-[160px] w-[160px] cursor-grab active:cursor-grabbing"
style={{ width: "160px", height: "160px" }}
onMouseDown={handleMouseDown}
/>
);
},
);
OrbitSphere.displayName = "OrbitSphere";

View File

@ -1,2 +0,0 @@
export { Angles } from "./Angles";
export { useAnglesStore } from "./AnglesStore";

View File

@ -1,203 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSparkles } from "@fortawesome/pro-solid-svg-icons";
import { toast } from "@storyteller/ui-toaster";
import { UploadImageMedia, UploadVideoMedia } from "@storyteller/api";
import {
PromptBoxVFX,
SubTabStrip,
TruchetPattern,
VFXResultCard,
VFXShowcaseView,
newIdempotencyToken,
submitVFXJob,
useVFXStore,
VFX_NOT_AVAILABLE_ERROR,
VFX_SHOWCASE,
} from "@storyteller/ui-vfx";
export const PageBackgroundChange = () => {
const subTab = useVFXStore((s) => s.subTab);
const setSubTab = useVFXStore((s) => s.setSubTab);
const history = useVFXStore((s) => s.history);
const startResult = useVFXStore((s) => s.startResult);
const failResult = useVFXStore((s) => s.failResult);
const dismissResult = useVFXStore((s) => s.dismissResult);
const loadFromShowcase = useVFXStore((s) => s.loadFromShowcase);
const selectedShowcaseId = useVFXStore((s) => s.selectedShowcaseId);
const source = useVFXStore((s) => s.source);
const mask = useVFXStore((s) => s.mask);
const reference = useVFXStore((s) => s.reference);
const prompt = useVFXStore((s) => s.prompt);
const resolution = useVFXStore((s) => s.resolution);
const promptBoxRef = useRef<HTMLDivElement>(null);
const [promptBoxHeight, setPromptBoxHeight] = useState(96);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const el = promptBoxRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const update = () => setPromptBoxHeight(el.offsetHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const handleSubmit = useCallback(async () => {
if (!source || !reference || isSubmitting) return;
setIsSubmitting(true);
const id = startResult();
const trimmed = prompt.trim();
const result = await submitVFXJob({
source_video_media_token: source.mediaToken,
reference_image_media_token: reference.mediaToken,
prompt: trimmed.length > 0 ? trimmed : null,
uuid_idempotency_token: newIdempotencyToken(),
});
setIsSubmitting(false);
if (!result.success) {
const isExpected = result.error_code_str === VFX_NOT_AVAILABLE_ERROR;
const message = isExpected
? "Background change backend coming soon. Your inputs are saved."
: result.error_message || "Failed to submit background change job.";
failResult(id, message);
if (isExpected) toast(message);
else toast.error(message);
}
}, [source, reference, prompt, isSubmitting, startResult, failResult]);
const handleTryShowcase = useCallback(() => {
const entry = VFX_SHOWCASE.find((e) => e.id === selectedShowcaseId);
if (!entry) return;
loadFromShowcase({
prompt: entry.prompt,
resolution: entry.resolution,
source: {
id: entry.source.mediaToken,
url: entry.source.url,
mediaToken: entry.source.mediaToken,
},
mask: entry.mask
? {
id: entry.mask.mediaToken,
url: entry.mask.url,
mediaToken: entry.mask.mediaToken,
}
: undefined,
reference: entry.reference
? {
id: entry.reference.mediaToken,
url: entry.reference.url,
mediaToken: entry.reference.mediaToken,
}
: undefined,
});
toast("Loaded showcase. Edit and Generate.");
}, [selectedShowcaseId, loadFromShowcase]);
return (
<div className="relative flex h-[calc(100vh-56px)] w-full flex-col bg-ui-background">
<SubTabStrip activeTab={subTab} onChange={setSubTab} />
{subTab === "showcase" ? (
<div
className="min-h-0 flex-1 overflow-hidden"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<VFXShowcaseView onTryThis={handleTryShowcase} />
</div>
) : history.length === 0 ? (
<div
className="relative flex flex-1 items-center justify-center px-6"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
maskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
WebkitMaskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
}}
>
<TruchetPattern
intensity={0.8}
className="absolute inset-0 h-full w-full"
/>
</div>
<div className="relative z-10">
<EmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
</div>
</div>
) : (
<div
className="flex-1 overflow-y-auto"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
>
<div className="flex flex-col gap-10 px-6 pt-6">
{history.map((r) => (
<VFXResultCard
key={r.id}
data={{
prompt: r.prompt,
resolution: r.resolution,
source: r.source,
mask: r.mask,
reference: r.reference,
outputUrl: r.outputUrl,
status: r.status,
failureReason: r.failureReason,
}}
onDismiss={() => dismissResult(r.id)}
/>
))}
</div>
</div>
)}
<div
aria-hidden
className="via-ui-background/85 pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background to-transparent"
/>
<div
ref={promptBoxRef}
className="pointer-events-none fixed bottom-4 left-1/2 z-30 -translate-x-1/2"
>
<div className="pointer-events-auto">
<PromptBoxVFX
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
uploadVideo={UploadVideoMedia}
uploadImage={UploadImageMedia}
onError={(msg) => toast.error(msg)}
/>
</div>
</div>
</div>
);
};
interface EmptyStateProps {
title: string;
subtitle: string;
}
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-base-fg/5 ring-1 ring-base-fg/10">
<FontAwesomeIcon icon={faSparkles} className="text-2xl" />
</div>
<h3 className="text-2xl font-bold text-base-fg">{title}</h3>
<p className="max-w-xs text-sm text-base-fg/60">{subtitle}</p>
</div>
);
export default PageBackgroundChange;

View File

@ -1 +0,0 @@
export { PageBackgroundChange } from "./PageBackgroundChange";

View File

@ -1,245 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faImages,
faDroplet,
faArrowRotateRight,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
export const ImageWatermarkRemover = () => {
const [imageUrl, setImageUrl] = useState<string>("");
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>(
[],
);
const [isProcessing, setIsProcessing] = useState(false);
const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
const [imageDimensions, setImageDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
return () => {
if (imageUrl && imageUrl.startsWith("blob:")) {
URL.revokeObjectURL(imageUrl);
}
};
}, [imageUrl]);
const handleLocalImageSelect = (files: FileList) => {
const file = files[0];
if (file && file.type.startsWith("image/")) {
if (imageUrl && imageUrl.startsWith("blob:")) {
URL.revokeObjectURL(imageUrl);
}
const url = URL.createObjectURL(file);
setImageUrl(url);
setImageDimensions(null);
}
};
const handleImageSelect = (id: string) => {
setSelectedGalleryImages((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
const maxSelections = 1;
if (prev.length >= maxSelections) {
return maxSelections === 1 ? [id] : prev;
}
return [...prev, id];
});
};
const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No image selected");
return;
}
if (isLoadingFromGallery) {
return;
}
setIsLoadingFromGallery(true);
if (imageUrl && imageUrl.startsWith("blob:")) {
URL.revokeObjectURL(imageUrl);
}
setImageUrl(item.fullImage);
setImageDimensions(null);
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
setIsLoadingFromGallery(false);
};
const handleRemoveWatermark = async () => {
if (!imageUrl) {
toast.error("Please select an image first");
return;
}
setIsProcessing(true);
toast.loading("Processing watermark removal...", {
id: "watermark-removal",
});
try {
await new Promise((resolve) => setTimeout(resolve, 2000));
toast.success("Watermark removal completed!", {
id: "watermark-removal",
});
} catch (error) {
toast.error("Failed to remove watermark", { id: "watermark-removal" });
console.error("Error removing watermark:", error);
} finally {
setIsProcessing(false);
}
};
return (
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
!imageUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!imageUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faDroplet}
title="Remove Image Watermark"
description="Erase watermarks from your images seamlessly. Upload your image and get a clean, watermark-free result."
accentBackgroundClass="bg-indigo-500/40"
accentBorderClass="border-indigo-400/30"
accept="image/*"
onFilesSelected={handleLocalImageSelect}
primaryLabel="Select Image"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={() => setIsGalleryModalOpen(true)}
/>
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setImageUrl("");
setImageDimensions(null);
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Image
</Button>
<div className="flex min-h-[400px] items-center justify-center p-6">
<img
ref={imageRef}
src={imageUrl}
alt="Selected pic"
className="max-h-[70vh] max-w-full rounded-lg object-contain shadow-2xl"
onLoad={(e) => {
const img = e.currentTarget;
setImageDimensions({
width: img.naturalWidth,
height: img.naturalHeight,
});
}}
/>
</div>
</div>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isProcessing ? undefined : faDroplet}
loading={isProcessing}
onClick={handleRemoveWatermark}
className="px-12 py-3 text-lg font-semibold"
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Remove Watermark"}
</Button>
</div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon
icon={faImages}
className="text-primary"
/>
Image Information
</div>
{imageDimensions ? (
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{imageDimensions.width} × {imageDimensions.height}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="font-medium text-base-fg/70">
Aspect Ratio
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{(
imageDimensions.width / imageDimensions.height
).toFixed(2)}
:1
</span>
</div>
</div>
) : (
<div className="text-sm text-base-fg/50">Loading...</div>
)}
</div>
</div>
</div>
)}
</main>
</div>
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={() => {
if (!isLoadingFromGallery) {
setIsGalleryModalOpen(false);
setSelectedGalleryImages([]);
}
}}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={handleImageSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</>
);
};

View File

@ -1,2 +0,0 @@
export { ImageWatermarkRemover } from "./ImageWatermarkRemover";

View File

@ -1,35 +0,0 @@
import { useEffect } from "react";
import {
MoodboardWorkspace,
dispatchGalleryMoodboardDrop,
} from "@storyteller/ui-moodboard";
import {
GalleryItem,
onImageDrop,
removeImageDropListener,
} from "@storyteller/ui-gallery-modal";
import { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
// Desktop moodboard tab. The shared workspace fills its parent; MainApp's
// TabBody wraps this in a sized container.
export const PageMoodboard = () => {
useGalleryDropBridge();
return <MoodboardWorkspace adapter={desktopMoodboardAdapter} />;
};
// Bridges gallery-modal's onImageDrop into the moodboard's board-drop event, so
// dragging a library image onto the grid/canvas adds it. Mounted only while the
// moodboard tab is active, so it doesn't race the 2D/3D tab drop handlers.
const useGalleryDropBridge = () => {
useEffect(() => {
const handler = onImageDrop(
(item: GalleryItem, position: { x: number; y: number }) => {
dispatchGalleryMoodboardDrop(item, position);
},
);
return () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (handler) removeImageDropListener(handler as any);
};
}, []);
};

View File

@ -1,102 +0,0 @@
import { useState } from "react";
import toast from "react-hot-toast";
import { UploaderStates } from "@storyteller/common";
import { UploadImageMedia } from "@storyteller/api";
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
import { usePromptImageStore, RefImage } from "@storyteller/ui-promptbox";
import type {
MoodboardAdapter,
MoodboardReference,
MoodboardLibraryPickerProps,
} from "@storyteller/ui-moodboard";
import { useTabStore } from "../Stores/TabState";
// Desktop (Tauri) implementation of the moodboard's platform seams.
const uploadImage = async (file: File): Promise<string | null> => {
let token: string | null = null;
try {
await UploadImageMedia({
title: file.name || "Moodboard image",
assetFile: file,
progressCallback: (state) => {
if (state.status === UploaderStates.success && state.data) {
token = String(state.data);
}
},
});
} catch (err) {
console.error("[Moodboard] upload failed", err);
}
return token;
};
const sendToGeneration = (refs: MoodboardReference[]): void => {
const newRefs: RefImage[] = refs.map((r) => ({
id: r.id,
url: r.url,
file: new File([], r.id),
mediaToken: r.mediaToken,
}));
const store = usePromptImageStore.getState();
store.setReferenceImages(dedupeByToken([...store.referenceImages, ...newRefs]));
void useTabStore.getState().setActiveTab("IMAGE");
toast.success(
`${refs.length} reference${refs.length > 1 ? "s" : ""} added to the prompt`,
);
};
const DesktopLibraryPicker = ({
open,
onClose,
onPick,
}: MoodboardLibraryPickerProps) => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const close = () => {
setSelectedIds([]);
onClose();
};
return (
<GalleryModal
isOpen={open}
onClose={close}
mode="select"
selectedItemIds={selectedIds}
onSelectItem={(id: string) =>
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
)
}
onUseSelected={(items: GalleryItem[]) => {
onPick(
items.map((it) => ({
url: it.fullImage || it.thumbnail || "",
mediaToken: it.id ?? null,
kind: it.mediaClass === "video" ? "video" : "image",
})),
);
setSelectedIds([]);
}}
forceFilter="image"
/>
);
};
export const desktopMoodboardAdapter: MoodboardAdapter = {
uploadImage,
sendToGeneration,
renderLibraryPicker: (props) => <DesktopLibraryPicker {...props} />,
};
const dedupeByToken = (refs: RefImage[]): RefImage[] => {
const seen = new Set<string>();
const out: RefImage[] = [];
for (const r of refs) {
if (r.mediaToken) {
if (seen.has(r.mediaToken)) continue;
seen.add(r.mediaToken);
}
out.push(r);
}
return out;
};

View File

@ -1,2 +0,0 @@
export { PageMoodboard } from "./PageMoodboard";
export { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";

View File

@ -1,6 +1,5 @@
import { SettingsContent } from "@storyteller/ui-settings-modal";
import { goToApp } from "~/config/appMenu";
import { useStoryboardStore } from "~/pages/PageStoryboard";
import { setLogoutStates } from "~/signals/authentication/utilities";
// Settings as a full page (opened from the titlebar gear) instead of a modal.
@ -14,10 +13,6 @@ export const PageSettings = () => {
setLogoutStates();
goToApp("IMAGE");
}}
onStoryboardPageDisable={() => {
useStoryboardStore.getState().reset();
goToApp("IMAGE");
}}
/>
</div>
);

View File

@ -1,85 +0,0 @@
import { create } from "zustand";
import { v4 as uuidv4 } from "uuid";
export interface Board {
id: string;
imageDataUrl: string | null;
title: string;
shotNumber: number;
dialogue: string;
action: string;
notes: string;
duration: number;
}
interface StoryboardState {
boards: Board[];
selectedBoardId: string | null;
addBoard: () => void;
deleteBoard: (id: string) => void;
selectBoard: (id: string) => void;
updateBoard: (id: string, patch: Partial<Omit<Board, "id" | "shotNumber">>) => void;
reorderBoards: (fromIndex: number, toIndex: number) => void;
reset: () => void;
}
const syncShotNumbers = (boards: Board[]): Board[] =>
boards.map((b, i) => ({ ...b, shotNumber: i + 1 }));
export const useStoryboardStore = create<StoryboardState>((set, get) => ({
boards: [],
selectedBoardId: null,
addBoard: () => {
const newBoard: Board = {
id: uuidv4(),
imageDataUrl: null,
title: "",
shotNumber: get().boards.length + 1,
dialogue: "",
action: "",
notes: "",
duration: 3,
};
set((state) => ({
boards: [...state.boards, newBoard],
selectedBoardId: newBoard.id,
}));
},
deleteBoard: (id) => {
set((state) => {
const idx = state.boards.findIndex((b) => b.id === id);
const remaining = syncShotNumbers(state.boards.filter((b) => b.id !== id));
const nextSelected = remaining[idx] ?? remaining[idx - 1] ?? null;
return {
boards: remaining,
selectedBoardId: nextSelected?.id ?? null,
};
});
},
selectBoard: (id) => {
set({ selectedBoardId: id });
},
updateBoard: (id, patch) => {
set((state) => ({
boards: state.boards.map((b) => (b.id === id ? { ...b, ...patch } : b)),
}));
},
reorderBoards: (fromIndex, toIndex) => {
set((state) => {
const arr = [...state.boards];
const [moved] = arr.splice(fromIndex, 1);
arr.splice(toIndex, 0, moved);
return { boards: syncShotNumbers(arr) };
});
},
reset: () => {
set({ boards: [], selectedBoardId: null });
},
}));

View File

@ -1,2 +0,0 @@
export { Storyboard } from "./Storyboard";
export { useStoryboardStore } from "./StoryboardStore";

View File

@ -1,125 +0,0 @@
import {
useEffect,
useMemo,
useRef,
useState,
type ReactElement,
} from "react";
import type {
AssetGalleryAdapter,
MediaKind,
MediaPickerSelection,
} from "@storyteller/ui-video-editor";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
// Tauri AssetGalleryAdapter — wraps @storyteller/ui-gallery-modal in a
// promise-shaped openPicker, matching the webapp's pattern verbatim.
// The gallery modal is session-agnostic — it talks to the same HTTPS
// `/v1/session` endpoint as the webapp, so it works in the Tauri
// WebView with no Tauri-specific wiring.
type Resolver = (selections: MediaPickerSelection[]) => void;
interface GalleryControlState {
open: boolean;
filter: string | undefined;
}
interface AssetGalleryHostBundle {
adapter: AssetGalleryAdapter;
// Render this alongside <VideoEditor>. The host doesn't need to wire
// any props — state is owned by the hook.
modal: ReactElement;
}
function kindsToFilter(kinds: MediaKind[]): string | undefined {
if (kinds.length === 1) return kinds[0];
return undefined;
}
function mediaClassToKind(mediaClass: string | undefined): MediaKind {
if (mediaClass === "video") return "video";
if (mediaClass === "audio") return "audio";
return "image";
}
export function useTauriAssetGalleryAdapter(): AssetGalleryHostBundle {
const [state, setState] = useState<GalleryControlState>({
open: false,
filter: undefined,
});
// GalleryModal at mode="select" only registers user clicks when an
// onSelectItem handler is wired; without selectedItemIds tracking
// the "Use selected" button hands back an empty array.
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
const resolverRef = useRef<Resolver | null>(null);
// Settle any pending resolver (resolve-empty). Used when a second
// openPicker arrives before the first one resolved, or when the
// hook unmounts mid-flow. Without this the first promise hangs
// forever and the caller's UI stays stuck in its "processing" state.
const settlePending = (selections: MediaPickerSelection[]) => {
const resolver = resolverRef.current;
resolverRef.current = null;
resolver?.(selections);
};
// Unmount cleanup — fail any in-flight picker rather than leak it.
useEffect(() => {
return () => {
settlePending([]);
};
// settlePending closes over the ref; safe to omit from deps.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const adapter = useMemo<AssetGalleryAdapter>(
() => ({
openPicker({ kinds }) {
return new Promise<MediaPickerSelection[]>((resolve) => {
settlePending([]);
setSelectedItemIds([]);
resolverRef.current = resolve;
setState({ open: true, filter: kindsToFilter(kinds) });
});
},
}),
[],
);
const handleSelectItem = (id: string) => {
setSelectedItemIds((prev) =>
prev.includes(id) ? prev.filter((existing) => existing !== id) : [...prev, id],
);
};
const handleUseSelected = (items: GalleryItem[]) => {
const selections: MediaPickerSelection[] = items.map((item) => ({
handle: { id: item.id, kind: mediaClassToKind(item.mediaClass) },
name: item.label,
}));
setState({ open: false, filter: undefined });
setSelectedItemIds([]);
settlePending(selections);
};
const handleClose = () => {
setState({ open: false, filter: undefined });
setSelectedItemIds([]);
settlePending([]);
};
const modal = (
<GalleryModal
mode="select"
isOpen={state.open}
forceFilter={state.filter}
selectedItemIds={selectedItemIds}
onSelectItem={handleSelectItem}
onUseSelected={handleUseSelected}
onClose={handleClose}
/>
);
return { adapter, modal };
}

View File

@ -1,56 +0,0 @@
import type { AuthUser, AuthUserAdapter } from "@storyteller/ui-video-editor";
import type { UserInfo } from "~/models";
import { authentication } from "~/signals/authentication/authentication";
// Tauri AuthUserAdapter — wraps the Preact signal that holds the
// signed-in user. currentUser() reads the signal's value; subscribe()
// re-derives the projected AuthUser and notifies only on actual change.
//
// currentUser() must return a stable reference across calls while the
// underlying UserInfo identity is unchanged — React's
// useSyncExternalStore loop sees a new snapshot on every render
// otherwise and infinite-loops. The cache lives at module scope and
// is **only mutated by snapshot()**. Subscribers each maintain their
// own `previous` baseline so the cache can't be observed in a way
// that races between concurrent subscribers (the bug a webapp version
// of this file had earlier, which silently dropped sign-in/sign-out
// notifications to the second-and-later useAuthUser consumer when one
// ran before the others).
function projectUser(user: UserInfo | undefined): AuthUser | null {
if (!user) return null;
return { id: user.user_token, displayName: user.display_name };
}
function authUsersEqual(a: AuthUser | null, b: AuthUser | null): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
return a.id === b.id && a.displayName === b.displayName;
}
let cachedSource: UserInfo | undefined = undefined;
let cachedResult: AuthUser | null = null;
function snapshot(): AuthUser | null {
const source = authentication.userInfo.value;
if (source === cachedSource) return cachedResult;
cachedSource = source;
cachedResult = projectUser(source);
return cachedResult;
}
export const tauriAuthUserAdapter: AuthUserAdapter = {
currentUser() {
return snapshot();
},
subscribe(listener) {
// Per-listener `previous` — closed-over, not shared.
let previous = projectUser(authentication.userInfo.value);
return authentication.userInfo.subscribe((value) => {
const next = projectUser(value);
if (authUsersEqual(previous, next)) return;
previous = next;
listener(next);
});
},
};

View File

@ -1,129 +0,0 @@
import type {
ExportArtifact,
ExportSinkAdapter,
} from "@storyteller/ui-video-editor";
import { kindFromMime } from "@storyteller/ui-video-editor";
import { downloadUrlToPath } from "@storyteller/api";
import { downloadDir } from "@tauri-apps/api/path";
import { uploadByKind } from "./upload-by-kind";
// Tauri ExportSinkAdapter.
//
// Two destinations the user controls from the export popover:
// - saveToDisk: downloadUrlToPath writes the file to the OS Downloads
// directory.
// - saveToLibrary: UploadNewVideo / UploadAudio / UploadImage mirrors
// the render into the user's Artcraft media library.
//
// Progress events fire per destination so the lib's popover can render
// inline status rows; accept() resolves only after every requested
// destination settled. In-flight Set dedupes concurrent invocations.
//
// Return value: the absolute path of the file written to disk when
// saveToDisk succeeded, otherwise `null`. Matches the
// ExportSinkAdapter contract — the webapp adapter always returns
// `null` because the browser can't observe the chosen download path.
const inFlight = new Set<string>();
async function ensureSavePath(
_url: string,
filename: string,
): Promise<string | null> {
const dir = await downloadDir();
return `${dir}/${filename}`;
}
async function uploadToLibrary(artifact: ExportArtifact): Promise<void> {
const baseTitle = artifact.filename.replace(/\.[^.]+$/, "");
await uploadByKind({
kind: kindFromMime(artifact.mime),
blob: artifact.blob,
fileName: artifact.filename,
title: baseTitle,
});
}
export const tauriExportSinkAdapter: ExportSinkAdapter = {
async accept(artifact, options) {
const saveToDisk = options?.saveToDisk ?? true;
const saveToLibrary = options?.saveToLibrary ?? false;
const onProgress = options?.onProgress;
if (!saveToDisk && !saveToLibrary) {
return null;
}
const downloadUrl = URL.createObjectURL(artifact.blob);
if (inFlight.has(artifact.filename)) {
if (saveToDisk) {
onProgress?.({
destination: "disk",
status: "error",
error: "Already saving this export",
});
}
if (saveToLibrary) {
onProgress?.({
destination: "library",
status: "error",
error: "Already uploading this export",
});
}
setTimeout(() => URL.revokeObjectURL(downloadUrl), 60_000);
return null;
}
inFlight.add(artifact.filename);
let savedPath: string | null = null;
try {
if (saveToDisk) {
onProgress?.({ destination: "disk", status: "pending" });
try {
const targetPath = await ensureSavePath(downloadUrl, artifact.filename);
if (targetPath) {
await downloadUrlToPath(downloadUrl, targetPath);
savedPath = targetPath;
onProgress?.({ destination: "disk", status: "success" });
} else {
// User cancelled the dialog — treat as error so the row
// doesn't look like a silent success.
onProgress?.({
destination: "disk",
status: "error",
error: "Save dialog cancelled",
});
}
} catch (error) {
console.error("Export disk save failed:", error);
onProgress?.({
destination: "disk",
status: "error",
error: error instanceof Error ? error.message : "Disk save failed",
});
}
}
if (saveToLibrary) {
onProgress?.({ destination: "library", status: "uploading" });
try {
await uploadToLibrary(artifact);
onProgress?.({ destination: "library", status: "success" });
} catch (error) {
console.error("Export library upload failed:", error);
onProgress?.({
destination: "library",
status: "error",
error: error instanceof Error ? error.message : "Upload failed",
});
}
}
} finally {
inFlight.delete(artifact.filename);
URL.revokeObjectURL(downloadUrl);
}
return savedPath;
},
};

View File

@ -1,137 +0,0 @@
import type {
MediaHandle,
MediaKind,
MediaSourceAdapter,
ResolvedMedia,
} from "@storyteller/ui-video-editor";
import { kindFromMime } from "@storyteller/ui-video-editor";
import { MediaFilesApi } from "@storyteller/api";
import { uploadByKind } from "./upload-by-kind";
// Tauri MediaSourceAdapter — same shape as the webapp's adapter.
// Uploads route through MediaUploadApi (HTTPS), resolveMedia hits
// MediaFilesApi.GetMediaFileByToken, deleteHandle calls
// DeleteMediaFileByToken to roll back failed-mid-pipeline uploads.
//
// Tauri-specific drag-drops (OS-level paths through
// appWindow.onDragDropEvent) are converted to File objects in the
// PageVideoEditor's drop-bridge component before being fed into
// processMediaAssets — so this adapter never sees a Tauri-shaped
// payload, only standard Files. That keeps the adapter contract
// portable between webapp and desktop.
type ApiMediaFileClass = "video" | "audio" | "image" | "unknown" | string;
const filesApi = new MediaFilesApi();
function kindFromMediaClass(mediaClass: ApiMediaFileClass | null): MediaKind {
if (mediaClass === "video") return "video";
if (mediaClass === "audio") return "audio";
return "image";
}
const EXT_TO_MIME: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
mov: "video/quicktime",
m4v: "video/x-m4v",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
oga: "audio/ogg",
m4a: "audio/mp4",
aac: "audio/aac",
flac: "audio/flac",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
bmp: "image/bmp",
avif: "image/avif",
};
function extensionOf(path: string | null | undefined): string | null {
if (!path) return null;
const trimmed = path.split(/[?#]/)[0];
const dot = trimmed.lastIndexOf(".");
if (dot === -1 || dot === trimmed.length - 1) return null;
return trimmed.slice(dot + 1).toLowerCase();
}
function mimeForMediaFile({
maybeOriginalFilename,
cdnUrl,
publicBucketPath,
mediaClass,
}: {
maybeOriginalFilename: string | null;
cdnUrl: string;
publicBucketPath: string;
mediaClass: ApiMediaFileClass | null;
}): string {
const candidates = [maybeOriginalFilename, cdnUrl, publicBucketPath];
for (const candidate of candidates) {
const ext = extensionOf(candidate);
if (ext && EXT_TO_MIME[ext]) return EXT_TO_MIME[ext];
}
if (mediaClass === "video") return "video/mp4";
if (mediaClass === "audio") return "audio/mpeg";
if (mediaClass === "image") return "image/png";
return "application/octet-stream";
}
export const tauriMediaSourceAdapter: MediaSourceAdapter = {
async uploadLocalFile(file: File): Promise<MediaHandle> {
const kind = kindFromMime(file.type);
const id = await uploadByKind({
kind,
blob: file,
fileName: file.name,
});
return { id, kind };
},
async resolveMedia(handle: MediaHandle): Promise<ResolvedMedia> {
const response = await filesApi.GetMediaFileByToken({
mediaFileToken: handle.id,
});
if (!response.success || !response.data) {
throw new Error(
response.errorMessage || `Failed to resolve media ${handle.id}`,
);
}
const media = response.data;
return {
url: media.media_links.cdn_url,
mime: mimeForMediaFile({
maybeOriginalFilename: media.maybe_original_filename,
cdnUrl: media.media_links.cdn_url,
publicBucketPath: media.public_bucket_path,
mediaClass: media.media_class,
}),
durationMs: media.maybe_duration_millis ?? undefined,
};
},
releaseResolved() {
// no-op — HTTP URLs don't need explicit release
},
async deleteHandle(handle) {
const response = await filesApi.DeleteMediaFileByToken({
mediaFileToken: handle.id,
asMod: false,
});
if (!response.success) {
throw new Error(
response.errorMessage || `Failed to delete media ${handle.id}`,
);
}
},
};
export { kindFromMediaClass };

View File

@ -1,31 +0,0 @@
import type { ToastAdapter, ToastOptions } from "@storyteller/ui-video-editor";
import { ToastTypes } from "~/enums";
import { addToast } from "~/signals/toasts";
// Tauri ToastAdapter — routes lib toasts through the Artcraft desktop
// app's existing signal-based toast system (signals/toasts.ts +
// <Toaster /> mounted in MainApp).
//
// ToastTypes only has SUCCESS / WARNING / ERROR, so `info` collapses to
// SUCCESS (closest visual fallback). ToastOptions.description is
// concatenated after a newline because the toast UI is single-line.
function formatMessage(message: string, options?: ToastOptions): string {
if (!options?.description) return message;
return `${message}\n${options.description}`;
}
export const tauriToastAdapter: ToastAdapter = {
info(message, options) {
addToast(ToastTypes.SUCCESS, formatMessage(message, options));
},
success(message, options) {
addToast(ToastTypes.SUCCESS, formatMessage(message, options));
},
warning(message, options) {
addToast(ToastTypes.WARNING, formatMessage(message, options));
},
error(message, options) {
addToast(ToastTypes.ERROR, formatMessage(message, options));
},
};

View File

@ -1,41 +0,0 @@
import type { MediaKind } from "@storyteller/ui-video-editor";
import { MediaUploadApi } from "@storyteller/api";
// Single dispatch from MediaKind to the right MediaUploadApi endpoint.
// Both the media-source adapter (uploadLocalFile) and the export-sink
// adapter (uploadToLibrary) need this. Factoring it here means a future
// upload-API change (e.g. a new required field) lands in one place.
//
// Mirror of the webapp's helper at
// apps/artcraft-webapp/src/pages/video-editor/adapters/upload-by-kind.ts —
// the two adapters could share a workspace lib later but the helper
// is tiny, so duplication is cheap during initial Tauri integration.
const api = new MediaUploadApi();
export async function uploadByKind({
kind,
blob,
fileName,
title,
}: {
kind: MediaKind;
blob: Blob;
fileName: string;
title?: string;
}): Promise<string> {
const uuid = crypto.randomUUID();
const maybe_title = title ?? fileName;
const response =
kind === "video"
? await api.UploadNewVideo({ uuid, blob, fileName, maybe_title })
: kind === "audio"
? await api.UploadAudio({ uuid, blob, fileName, maybe_title })
: await api.UploadImage({ uuid, blob, fileName, maybe_title });
if (!response.success || !response.data) {
throw new Error(response.errorMessage || "Upload failed");
}
return response.data;
}

View File

@ -1,99 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
VideoEditor,
EditorCore,
buildDefaultScene,
createDefaultAdapters,
ZERO_MEDIA_TIME,
type TProject,
type VideoEditorAdapters,
} from "@storyteller/ui-video-editor";
import { tauriToastAdapter } from "./adapters/toast-adapter";
import { tauriAuthUserAdapter } from "./adapters/auth-user-adapter";
import { tauriMediaSourceAdapter } from "./adapters/media-source-adapter";
import { tauriExportSinkAdapter } from "./adapters/export-sink-adapter";
import { useTauriAssetGalleryAdapter } from "./adapters/asset-gallery-adapter";
// Tauri host for the @storyteller/ui-video-editor lib.
//
// Bootstraps an in-memory project (the IndexedDB project-storage default
// still persists across WebView restarts; a Tauri-specific filesystem-
// backed adapter is a future option but not needed yet) and wires
// every adapter through Tauri-native infra: addToast signal,
// authentication.userInfo signal, MediaUploadApi / MediaFilesApi.
//
// Mounted inside MainApp's tab switch — see MainApp.tsx case
// "VIDEO_EDITOR".
function buildBootstrapProject({ id }: { id: string }): TProject {
const scene = buildDefaultScene({ name: "Main scene", isMain: true });
const now = new Date();
return {
metadata: {
id,
name: "Untitled project",
duration: ZERO_MEDIA_TIME,
createdAt: now,
updatedAt: now,
},
scenes: [scene],
currentSceneId: scene.id,
settings: {
fps: { numerator: 30, denominator: 1 } as never,
canvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
version: 1,
};
}
export function PageVideoEditor() {
const [ready, setReady] = useState(false);
const { adapter: assetGalleryAdapter, modal: galleryModal } =
useTauriAssetGalleryAdapter();
const adapters = useMemo<Partial<VideoEditorAdapters>>(
() => ({
toast: tauriToastAdapter,
authUser: tauriAuthUserAdapter,
mediaSource: tauriMediaSourceAdapter,
assetGallery: assetGalleryAdapter,
exportSink: tauriExportSinkAdapter,
}),
[assetGalleryAdapter],
);
// Stable transient id so StrictMode + adapter ref churn don't
// reset the project on every effect re-run.
const transientIdRef = useRef<string>(`local-${crypto.randomUUID()}`);
const resolvedProjectId = transientIdRef.current;
useEffect(() => {
// Explicit initialize before EditorProvider mounts so the Tauri
// adapter bundle wins the first-call-wins idempotent semantics.
EditorCore.initialize({
adapters: { ...createDefaultAdapters(), ...adapters },
});
const editor = EditorCore.getInstance();
if (editor.project.getActive()?.metadata.id !== resolvedProjectId) {
const project = buildBootstrapProject({ id: resolvedProjectId });
editor.project.setActiveProject({ project });
editor.scenes.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
}
setReady(true);
}, [resolvedProjectId, adapters]);
if (!ready) return null;
return (
<div className="h-full w-full overflow-hidden">
<VideoEditor adapters={adapters} />
{galleryModal}
</div>
);
}

View File

@ -1,894 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlay,
faPause,
faVolumeHigh,
faVolumeMute,
faPhotoFilm,
faSparkles,
faImages,
faStepBackward,
faStepForward,
faArrowRotateRight,
faSave,
faVideo,
faCheck,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
downloadFileFromUrl,
MediaUploadApi,
EIntermediateFile,
} from "@storyteller/api";
import toast from "react-hot-toast";
import { v4 as uuidv4 } from "uuid";
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import { useTabStore } from "~/pages/Stores/TabState";
export const VideoFrameExtractor = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [startTime, setStartTime] = useState(0);
const [numFrames, setNumFrames] = useState(10);
const [frameDistance, setFrameDistance] = useState(100);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryVideos, setSelectedGalleryVideos] = useState<string[]>(
[],
);
const [extractedFrames, setExtractedFrames] = useState<
{ id: string; url: string; timestamp: number }[]
>([]);
const [isExtracting, setIsExtracting] = useState(false);
const [savingFrames, setSavingFrames] = useState<Set<string>>(new Set());
const [savedFrames, setSavedFrames] = useState<Set<string>>(new Set());
const [convertingFrames, setConvertingFrames] = useState<Set<string>>(
new Set(),
);
const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
const extractedFramesRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
if (!video.paused) {
setStartTime(video.currentTime);
}
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
setCurrentTime(0);
};
const handleEnded = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleVolumeChange = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("ended", handleEnded);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("volumechange", handleVolumeChange);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [videoUrl]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.volume = volume;
}
}, [volume]);
useEffect(() => {
return () => {
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
};
}, [videoUrl]);
const handleLocalVideoSelect = (files: FileList) => {
const file = files[0];
if (file && file.type.startsWith("video/")) {
setIsPlaying(false);
setCurrentTime(0);
setStartTime(0);
setDuration(0);
setExtractedFrames([]);
setSavingFrames(new Set());
setSavedFrames(new Set());
setConvertingFrames(new Set());
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
const url = URL.createObjectURL(file);
setVideoUrl(url);
}
};
const handleVideoSelect = (id: string) => {
setSelectedGalleryVideos((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
const maxSelections = 1;
if (prev.length >= maxSelections) {
return maxSelections === 1 ? [id] : prev;
}
return [...prev, id];
});
};
const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No video selected");
return;
}
if (isLoadingFromGallery) {
return;
}
setIsLoadingFromGallery(true);
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
setIsPlaying(false);
setCurrentTime(0);
setStartTime(0);
setDuration(0);
setExtractedFrames([]);
setSavingFrames(new Set());
setSavedFrames(new Set());
setConvertingFrames(new Set());
setVideoUrl(item.fullImage);
setIsGalleryModalOpen(false);
setSelectedGalleryVideos([]);
setIsLoadingFromGallery(false);
};
const togglePlayPause = () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play().catch((err) => {
console.error("Play failed:", err);
});
}
};
const toggleMute = () => {
if (!videoRef.current) return;
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
};
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!videoRef.current || !progressBarRef.current || !duration) return;
const rect = progressBarRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
const newTime = percentage * duration;
videoRef.current.currentTime = newTime;
setStartTime(newTime);
};
const seekToFrame = (direction: "first" | "last") => {
if (!videoRef.current || !duration) return;
if (direction === "first") {
videoRef.current.currentTime = 0;
setStartTime(0);
} else {
videoRef.current.currentTime = duration;
setStartTime(duration);
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
const formatTimePrecise = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const millis = Math.floor((seconds % 1) * 1000);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${millis.toString().padStart(3, "0")}`;
};
const handleExtract = async () => {
if (!videoRef.current) return;
setIsExtracting(true);
setExtractedFrames([]);
setSavingFrames(new Set());
setSavedFrames(new Set());
setConvertingFrames(new Set());
const video = videoRef.current;
// Ensure video metadata is loaded before extraction
if (video.readyState < 2 || !video.videoWidth || !video.videoHeight) {
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Video failed to load metadata"));
}, 5000);
const handleLoadedData = () => {
clearTimeout(timeout);
video.removeEventListener("loadeddata", handleLoadedData);
resolve();
};
video.addEventListener("loadeddata", handleLoadedData);
}).catch((error) => {
console.error("Video loading error:", error);
setIsExtracting(false);
return;
});
}
const originalTime = video.currentTime;
const wasPlaying = !video.paused;
if (wasPlaying) {
video.pause();
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) {
setIsExtracting(false);
return;
}
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const frames: { id: string; url: string; timestamp: number }[] = [];
let corsError = false;
for (let i = 0; i < numFrames; i++) {
const timestamp = startTime + (i * frameDistance) / 1000;
if (timestamp > duration) break;
video.currentTime = timestamp;
await new Promise<void>((resolve) => {
const handleSeeked = () => {
try {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/png");
frames.push({
id: `frame-${i + 1}`,
url: dataUrl,
timestamp,
});
} catch (error) {
console.error("Error extracting frame:", error);
if (error instanceof Error && error.name === "SecurityError") {
corsError = true;
}
}
video.removeEventListener("seeked", handleSeeked);
resolve();
};
video.addEventListener("seeked", handleSeeked, { once: true });
});
if (corsError) break;
}
video.currentTime = originalTime;
await new Promise<void>((resolve) => {
const handleRestored = () => {
video.removeEventListener("seeked", handleRestored);
resolve();
};
video.addEventListener("seeked", handleRestored, { once: true });
});
if (wasPlaying) {
video.play();
}
if (corsError) {
toast.error(
"CORS error: Cannot extract frames from this video. Try uploading the video file directly instead.",
);
setIsExtracting(false);
return;
}
if (frames.length === 0) {
toast.error("Failed to extract any frames");
setIsExtracting(false);
return;
}
setExtractedFrames(frames);
setIsExtracting(false);
setTimeout(() => {
extractedFramesRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 100);
};
const handleSaveFrame = async (
frameId: string,
frameUrl: string,
timestamp: number,
) => {
if (savedFrames.has(frameId) || savingFrames.has(frameId)) {
return;
}
setSavingFrames((prev) => new Set(prev).add(frameId));
try {
const response = await fetch(frameUrl);
const blob = await response.blob();
const fileName = `frame-${formatTimePrecise(timestamp)}.png`;
const mediaUploadApi = new MediaUploadApi();
const uploadResponse = await mediaUploadApi.UploadImage({
uuid: uuidv4(),
blob: blob,
fileName: fileName,
maybe_title: `video-frame-${timestamp}`,
is_intermediate_system_file: EIntermediateFile.false,
});
if (uploadResponse.success) {
setSavedFrames((prev) => new Set(prev).add(frameId));
setSavingFrames((prev) => {
const next = new Set(prev);
next.delete(frameId);
return next;
});
toast.success("Frame saved to library!");
} else {
setSavingFrames((prev) => {
const next = new Set(prev);
next.delete(frameId);
return next;
});
toast.error(uploadResponse.errorMessage || "Failed to save frame");
}
} catch (error) {
setSavingFrames((prev) => {
const next = new Set(prev);
next.delete(frameId);
return next;
});
toast.error("Failed to save frame");
console.error("Error saving frame:", error);
}
};
const handleTurnIntoVideo = async (
frameId: string,
frameUrl: string,
timestamp: number,
) => {
if (convertingFrames.has(frameId)) {
return;
}
setConvertingFrames((prev) => new Set(prev).add(frameId));
try {
const response = await fetch(frameUrl);
const blob = await response.blob();
const fileName = `frame-${formatTimePrecise(timestamp)}.png`;
const mediaUploadApi = new MediaUploadApi();
const uploadResponse = await mediaUploadApi.UploadImage({
uuid: uuidv4(),
blob: blob,
fileName: fileName,
maybe_title: `video-frame-${timestamp}`,
is_intermediate_system_file: EIntermediateFile.true,
});
if (uploadResponse.success && uploadResponse.data) {
const file = new File([blob], fileName, { type: "image/png" });
const referenceImage: RefImage = {
id: Math.random().toString(36).substring(7),
url: frameUrl,
file: file,
mediaToken: uploadResponse.data,
};
usePromptVideoStore.getState().setReferenceImages([referenceImage]);
useTabStore.getState().setActiveTab("VIDEO");
setConvertingFrames((prev) => {
const next = new Set(prev);
next.delete(frameId);
return next;
});
} else {
setConvertingFrames((prev) => {
const next = new Set(prev);
next.delete(frameId);
return next;
});
toast.error(
uploadResponse.errorMessage || "Failed to convert to video",
);
}
} catch (error) {
setConvertingFrames((prev) => {
const next = new Set(prev);
next.delete(frameId);
return next;
});
toast.error("Failed to convert frame to video");
console.error("Error converting frame:", error);
}
};
return (
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
!videoUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faPhotoFilm}
title="Extract Video Frames"
description="Capture perfect moments from your videos. Select frames at precise timestamps and save them as images."
accentBackgroundClass="bg-rose-500/40"
accentBorderClass="border-rose-400/30"
accept="video/*"
onFilesSelected={handleLocalVideoSelect}
primaryLabel="Select Video"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={() => setIsGalleryModalOpen(true)}
/>
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-xl border border-ui-panel-border bg-ui-controls/40">
<div className="relative aspect-video w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setVideoUrl("");
setStartTime(0);
setCurrentTime(0);
setExtractedFrames([]);
setSavingFrames(new Set());
setSavedFrames(new Set());
setConvertingFrames(new Set());
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
<video
ref={videoRef}
src={videoUrl}
className="h-full w-full bg-black"
onClick={togglePlayPause}
preload="metadata"
playsInline
crossOrigin="anonymous"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
>
<div
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
/>
<div
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize rounded-full bg-yellow-400 shadow-lg"
style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`,
}}
onMouseDown={(e) => {
e.stopPropagation();
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!progressBarRef.current || !duration) return;
const rect =
progressBarRef.current.getBoundingClientRect();
const moveX = moveEvent.clientX - rect.left;
const percentage = Math.max(
0,
Math.min(1, moveX / rect.width),
);
const newTime = percentage * duration;
setStartTime(newTime);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}
};
const handleMouseUp = () => {
document.removeEventListener(
"mousemove",
handleMouseMove,
);
document.removeEventListener(
"mouseup",
handleMouseUp,
);
};
document.addEventListener(
"mousemove",
handleMouseMove,
);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<FontAwesomeIcon
icon={faStepBackward}
className="text-sm text-white"
/>
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<FontAwesomeIcon
icon={isPlaying ? faPause : faPlay}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<FontAwesomeIcon
icon={faStepForward}
className="text-sm text-white"
/>
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isExtracting ? undefined : faSparkles}
loading={isExtracting}
onClick={handleExtract}
className="px-12 py-3 text-lg font-semibold"
disabled={isExtracting}
>
{isExtracting ? "Extracting..." : "Extract Frames"}
</Button>
</div>
<div className="grid w-full grid-cols-1 gap-5 md:grid-cols-2">
<div className="flex flex-col gap-5">
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Start Time
</div>
<div className="font-mono text-3xl font-bold text-primary drop-shadow-sm">
{formatTimePrecise(startTime)}
</div>
<div className="mt-3 flex items-center gap-2 text-xs text-base-fg/60">
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-400 shadow-lg shadow-yellow-400/50" />
<span>Adjust via the yellow marker on the video</span>
</div>
</div>
</div>
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
Video Information
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Duration
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
</div>
{videoRef.current && (
<div className="flex items-center justify-between py-2">
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
</span>
</div>
)}
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<h3 className="mb-5 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon
icon={faSparkles}
className="text-primary"
/>
Extraction Settings
</h3>
<div className="space-y-5">
<div>
<label
htmlFor="numFrames"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Number of Frames
</label>
<input
id="numFrames"
type="number"
min="1"
max="50"
value={numFrames}
onChange={(e) =>
setNumFrames(
Math.max(
1,
Math.min(50, parseInt(e.target.value) || 1),
),
)
}
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
htmlFor="frameDistance"
className="mb-2.5 block text-sm font-semibold text-base-fg"
>
Distance (ms)
</label>
<input
id="frameDistance"
type="number"
min="1"
max="10000"
value={frameDistance}
onChange={(e) =>
setFrameDistance(
Math.max(1, parseInt(e.target.value) || 1),
)
}
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div className="text-xs leading-relaxed text-base-fg/80">
Extracting multiple frames helps capture a sharp,
non-blurred frame.
</div>
</div>
</div>
</div>
</div>
{extractedFrames.length > 0 && (
<div
ref={extractedFramesRef}
className="rounded-2xl border border-ui-panel-border bg-ui-controls p-6 shadow-lg"
>
<div>
<div className="mb-6 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xl font-bold uppercase tracking-wider text-base-fg">
<FontAwesomeIcon
icon={faPhotoFilm}
className="text-primary"
/>
Extracted Frames
</h3>
<div className="bg-ui-badge rounded-full border-2 border-primary/30 px-5 py-2 text-sm font-bold text-base-fg shadow-lg">
{extractedFrames.length}{" "}
{extractedFrames.length === 1 ? "Frame" : "Frames"}
</div>
</div>
<div className="grid grid-cols-2 gap-5">
{extractedFrames.map((frame, index) => (
<div
key={frame.id}
className="group relative overflow-hidden rounded-xl border-2 border-ui-panel-border bg-ui-controls"
>
<div className="aspect-video overflow-hidden bg-black">
<img
src={frame.url}
alt={`Frame ${index + 1}`}
className="h-full w-full object-contain"
/>
</div>
<div className="space-y-2 bg-ui-background p-3">
<div className="flex items-center justify-between">
<span className="bg-ui-badge rounded border border-ui-panel-border px-2 py-1 text-xs font-bold text-base-fg">
#{index + 1}
</span>
<div className="font-mono text-xs font-semibold text-base-fg/70">
{formatTimePrecise(frame.timestamp)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="action"
icon={
savingFrames.has(frame.id)
? undefined
: savedFrames.has(frame.id)
? faCheck
: faSave
}
loading={savingFrames.has(frame.id)}
onClick={() =>
handleSaveFrame(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={
savingFrames.has(frame.id) ||
savedFrames.has(frame.id)
}
className="flex-1 px-3 py-1.5 text-xs font-semibold"
>
{savingFrames.has(frame.id)
? "Saving..."
: savedFrames.has(frame.id)
? "Saved"
: "Save"}
</Button>
<Button
variant="primary"
icon={
convertingFrames.has(frame.id)
? undefined
: faVideo
}
loading={convertingFrames.has(frame.id)}
onClick={() =>
handleTurnIntoVideo(
frame.id,
frame.url,
frame.timestamp,
)
}
disabled={convertingFrames.has(frame.id)}
className="flex-1 px-3 py-1.5 text-xs font-semibold"
>
Turn into Video
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</main>
</div>
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={() => {
if (!isLoadingFromGallery) {
setIsGalleryModalOpen(false);
setSelectedGalleryVideos([]);
}
}}
mode="select"
selectedItemIds={selectedGalleryVideos}
onSelectItem={handleVideoSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="video"
/>
</>
);
};

View File

@ -1,2 +0,0 @@
export { VideoFrameExtractor } from "./VideoFrameExtractor";

View File

@ -1,410 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlay,
faPause,
faVolumeHigh,
faVolumeMute,
faImages,
faStepBackward,
faStepForward,
faArrowRotateRight,
faDroplet,
faVideo,
} from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { downloadFileFromUrl } from "@storyteller/api";
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
import toast from "react-hot-toast";
export const VideoWatermarkRemover = () => {
const [videoUrl, setVideoUrl] = useState<string>("");
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isGalleryModalOpen, setIsGalleryModalOpen] = useState(false);
const [selectedGalleryVideos, setSelectedGalleryVideos] = useState<string[]>(
[],
);
const [isProcessing, setIsProcessing] = useState(false);
const [isLoadingFromGallery, setIsLoadingFromGallery] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handleLoadedMetadata = () => {
setDuration(video.duration);
setCurrentTime(0);
};
const handleEnded = () => setIsPlaying(false);
const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false);
const handleVolumeChange = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("ended", handleEnded);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("volumechange", handleVolumeChange);
return () => {
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("ended", handleEnded);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("volumechange", handleVolumeChange);
};
}, [videoUrl]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.volume = volume;
}
}, [volume]);
useEffect(() => {
if (videoRef.current && videoUrl) {
videoRef.current.load();
}
}, [videoUrl]);
useEffect(() => {
return () => {
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
};
}, [videoUrl]);
const handleLocalVideoSelect = (files: FileList) => {
const file = files[0];
if (file && file.type.startsWith("video/")) {
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
const url = URL.createObjectURL(file);
setVideoUrl(url);
}
};
const handleVideoSelect = (id: string) => {
setSelectedGalleryVideos((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
const maxSelections = 1;
if (prev.length >= maxSelections) {
return maxSelections === 1 ? [id] : prev;
}
return [...prev, id];
});
};
const handleGallerySelect = async (selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (!item || !item.fullImage) {
toast.error("No video selected");
return;
}
if (isLoadingFromGallery) {
return;
}
setIsLoadingFromGallery(true);
if (videoUrl && videoUrl.startsWith("blob:")) {
URL.revokeObjectURL(videoUrl);
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setVideoUrl(item.fullImage);
setIsGalleryModalOpen(false);
setSelectedGalleryVideos([]);
setIsLoadingFromGallery(false);
};
const togglePlayPause = () => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play().catch((err) => {
console.error("Play failed:", err);
});
}
};
const toggleMute = () => {
if (!videoRef.current) return;
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
};
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!videoRef.current || !progressBarRef.current || !duration) return;
const rect = progressBarRef.current.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = Math.max(0, Math.min(1, clickX / rect.width));
const newTime = percentage * duration;
videoRef.current.currentTime = newTime;
};
const seekToFrame = (direction: "first" | "last") => {
if (!videoRef.current || !duration) return;
if (direction === "first") {
videoRef.current.currentTime = 0;
} else {
videoRef.current.currentTime = duration;
}
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
};
const handleRemoveWatermark = async () => {
if (!videoRef.current || !videoUrl) {
toast.error("Please select a video first");
return;
}
setIsProcessing(true);
toast.loading("Processing watermark removal...", {
id: "watermark-removal",
});
try {
await new Promise((resolve) => setTimeout(resolve, 2000));
toast.success("Watermark removal completed!", {
id: "watermark-removal",
});
} catch (error) {
toast.error("Failed to remove watermark", { id: "watermark-removal" });
console.error("Error removing watermark:", error);
} finally {
setIsProcessing(false);
}
};
return (
<>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex-1 overflow-y-auto">
<main
className={
!videoUrl
? "flex min-h-full w-full items-center justify-center p-8"
: "flex w-full justify-center px-8 py-6"
}
>
{!videoUrl ? (
<div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<UploadEntryCard
icon={faDroplet}
title="Remove Video Watermark"
description="Clean up your videos by removing unwanted watermarks. Upload your video and let AI do the magic."
accentBackgroundClass="bg-cyan-500/40"
accentBorderClass="border-cyan-400/30"
accept="video/*"
onFilesSelected={handleLocalVideoSelect}
primaryLabel="Select Video"
secondaryLabel="Pick from Library"
secondaryIcon={faImages}
onSecondaryClick={() => setIsGalleryModalOpen(true)}
/>
</div>
</div>
) : (
<div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg">
<div className="relative aspect-video w-full bg-black">
<Button
icon={faArrowRotateRight}
variant="action"
onClick={() => {
setVideoUrl("");
setCurrentTime(0);
}}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
>
Switch Video
</Button>
<video
ref={videoRef}
src={videoUrl}
className="h-full w-full"
onClick={togglePlayPause}
preload="metadata"
playsInline
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div
ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20"
onClick={handleProgressBarClick}
>
<div
className="absolute h-full overflow-hidden rounded-full bg-primary"
style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}}
/>
</div>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="First Frame"
>
<FontAwesomeIcon
icon={faStepBackward}
className="text-sm text-white"
/>
</button>
<button
onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30"
>
<FontAwesomeIcon
icon={isPlaying ? faPause : faPlay}
className="text-white"
/>
</button>
<button
onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
title="Last Frame"
>
<FontAwesomeIcon
icon={faStepForward}
className="text-sm text-white"
/>
</button>
<div className="mx-1 h-6 w-px bg-white/20" />
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20"
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
className="text-sm text-white"
/>
</button>
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) =>
setVolume(parseFloat(e.target.value))
}
className="w-20 accent-white"
/>
</div>
<div className="font-mono text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center">
<Button
variant="primary"
icon={isProcessing ? undefined : faDroplet}
loading={isProcessing}
onClick={handleRemoveWatermark}
className="px-12 py-3 text-lg font-semibold"
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Remove Watermark"}
</Button>
</div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg">
<div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60">
<FontAwesomeIcon
icon={faVideo}
className="text-primary"
/>
Video Information
</div>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between border-b border-ui-divider py-2">
<span className="font-medium text-base-fg/70">
Duration
</span>
<span className="font-mono text-lg font-bold text-base-fg">
{formatTime(duration)}
</span>
</div>
{videoRef.current && (
<div className="flex items-center justify-between py-2">
<span className="font-medium text-base-fg/70">
Resolution
</span>
<span className="font-mono font-bold text-base-fg">
{videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight}
</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
</main>
</div>
</div>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={() => {
if (!isLoadingFromGallery) {
setIsGalleryModalOpen(false);
setSelectedGalleryVideos([]);
}
}}
mode="select"
selectedItemIds={selectedGalleryVideos}
onSelectItem={handleVideoSelect}
maxSelections={1}
onUseSelected={handleGallerySelect}
onDownloadClicked={downloadFileFromUrl}
forceFilter="video"
/>
</>
);
};

View File

@ -1,2 +0,0 @@
export { VideoWatermarkRemover } from "./VideoWatermarkRemover";

View File

@ -1,20 +1,11 @@
import { create } from "zustand";
export type TabId =
| "VIDEO"
| "EDIT"
| "IMAGE"
| "VIDEO"
| "AUDIO"
| "VIDEO_FRAME_EXTRACTOR"
| "VIDEO_WATERMARK_REMOVAL"
| "IMAGE_WATERMARK_REMOVAL"
| "IMAGE_TO_3D_OBJECT"
| "IMAGE_TO_3D_WORLD"
| "ANGLES"
| "STORYBOARD"
| "BACKGROUND_CHANGE"
| "VIDEO_EDITOR"
| "MOODBOARD"
| "SETTINGS";
const DEFAULT_TAB: TabId = "IMAGE";

View File

@ -1,7 +1,7 @@
import { signal } from "@preact/signals-core";
import deepEqual from "deep-equal";
import type { AudioMediaItem, MediaInfo } from "@storyteller/ui-pagescene";
import type { AudioMediaItem, MediaInfo } from "~/types/artcraft-web";
import { remapResponseToAudioMediaItems } from "./utilities";
// Signals and signal functions for background loading User's Movies

View File

@ -1,4 +1,4 @@
import type { MediaInfo, AudioMediaItem } from "@storyteller/ui-pagescene";
import type { MediaInfo, AudioMediaItem } from "~/types/artcraft-web";
import { AssetType } from "~/enums";
export const remapResponseToAudioMediaItems = (

View File

@ -1,4 +1,4 @@
import { AssetType } from "../enums";
import { AssetType } from "./enums";
export interface MediaItem {
version: number;

View File

@ -0,0 +1,14 @@
export { AssetFilterOption } from "./AssetFilterOption";
export { AssetType } from "./AssetType";
export { ClipGroup } from "./ClipGroup";
export {
FilterEngineCategories,
} from "./FilterEngineCategories";
export { FilterMediaClasses } from "./FilterMediaClasses";
export { FilterMediaType } from "./FilterMediaType";
export { GenerateTtsAudioErrorType } from "./GenerateTtsAudioErrorType";
export { MediaFileClass } from "./MediaFileClass";
export { MediaFileType } from "./MediaFileType";
export { ToastTypes } from "./ToastTypes";
export { WeightCategory } from "./WeightCategory";
export { WeightType } from "./WeightType";

View File

@ -0,0 +1,10 @@
// API-shape types and enums for the Artcraft/Storyteller web API, extracted
// from the retired 3D scene editor lib (@storyteller/ui-pagescene) when it
// was removed. These describe server payloads, not scene-editor state.
export * from "./enums";
export * from "./assets";
export * from "./mediaInfo";
export * from "./pagination";
export * from "./prompts";
export * from "./types";
export * from "./userBookmark";

View File

@ -5,7 +5,7 @@ import {
MediaFileType,
WeightCategory,
WeightType,
} from "../enums";
} from "./enums";
export interface MediaInfo {
token: string;

View File

@ -5,7 +5,7 @@ import {
MediaFileType,
WeightCategory,
WeightType,
} from "../enums";
} from "./enums";
import { MediaInfo } from "./mediaInfo";
import { Pagination } from "./pagination";

View File

@ -21,71 +21,44 @@
"staging-build": "vite build",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint playground/* .",
"typecheck": "tsc",
"update:icons": "npm update @awesome.me/kit-fde2be5eb0",
"chromatic": "npx chromatic --project-token=chpt_0945f90d5d3626c",
"prepare": "husky",
"test": "jest"
},
"dependencies": {
"@awesome.me/kit-fde2be5eb0": "^1.0.7",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-brands-svg-icons": "^6.5.2",
"@fortawesome/pro-regular-svg-icons": "^6.5.1",
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@mediapipe/tasks-vision": "^0.10.20",
"@preact/signals-react": "^2.0.1",
"@radix-ui/react-tooltip": "^1.2.7",
"@remix-run/deno": "^2.8.1",
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.17.4",
"@remix-run/router": "^1.23.2",
"@remix-run/serve": "^2.8.1",
"@tauri-apps/plugin-http": "2.4.3",
"@tweenjs/tween.js": "^18.6.4",
"@types/lodash": "^4.17.6",
"@types/react-virtualized": "^9.22.2",
"@uiw/color-convert": "^2.5.5",
"@zip.js/zip.js": "^2.7.51",
"ammo.js": "^0.0.10",
"ccapture.js": "github:aceslowman/ccapture.js#npm-fix",
"csstype": "^3.1.3",
"dayjs": "^1.11.10",
"deep-equal": "^2.2.3",
"detect-gpu": "^5.0.38",
"http-proxy-middleware": "^3.0.0",
"isbot": "^4.1.0",
"konva": "^9.3.20",
"lodash.debounce": "^4.0.8",
"nx": "^21.1.1",
"posthog-js": "^1.141.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
"react-drag-drop-files": "^2.3.10",
"react-resizable-panels": "*",
"react-helmet": "^6.1.0",
"react-hot-toast": "^2.5.2",
"react-image-crop": "^11.0.5",
"react-konva": "^18.2.10",
"react-router": "^6.26.1",
"react-router-dom": "^6.26.1",
"react-virtualized": "^9.22.6",
"tailwind-merge": "^2.2.2",
"terser": "^5.30.3",
"uuid": "^9.0.1",
"webm-writer": "^1.0.0"
"uuid": "^9.0.1"
},
"devDependencies": {
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@remix-run/dev": "^2.8.1",
"@types/deep-equal": "^1.0.4",
"@types/jest": "^29.5.12",
"@types/react": "^19.0.0",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^19.0.0",
"@types/react-helmet": "^6.1.11",
"@types/three": "^0.162.0",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^6.7.4",

View File

@ -28,30 +28,9 @@
"noEmit": true
},
"references": [
{
"path": "../../libs/components/video-editor"
},
{
"path": "../../libs/components/moodboard"
},
{
"path": "../../libs/components/vfx"
},
{
"path": "../../libs/omni-gen"
},
{
"path": "../../libs/components/pagedraw"
},
{
"path": "../../libs/components/help-menu"
},
{
"path": "../../libs/components/slider-v2"
},
{
"path": "../../libs/components/loading-spinner"
},
{
"path": "../../libs/soundboard"
},
@ -142,9 +121,6 @@
{
"path": "../../libs/tauri-api"
},
{
"path": "../../libs/components/pagescene"
},
{
"path": "../../libs/tauri-utils"
},

View File

@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -1,7 +0,0 @@
# @storyteller/ui-badge
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-badge` to execute the unit tests via [Vitest](https://vitest.dev/).

View File

@ -1,17 +0,0 @@
{
"name": "@storyteller/ui-badge",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}

View File

@ -1,2 +0,0 @@
export * from './lib/badge';

View File

@ -1,7 +0,0 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/

View File

@ -1,12 +0,0 @@
import { render } from '@testing-library/react';
import Badge from './badge';
describe('Badge', () => {
it('should render successfully', () => {
const { baseElement } = render(<Badge />);
expect(baseElement).toBeTruthy();
});
});

View File

@ -1,23 +0,0 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
interface BadgeProps {
label: string;
color?: string;
className?: string;
icon?: ReactNode;
}
export const Badge = ({ label, className, icon }: BadgeProps) => {
return (
<div
className={twMerge(
"flex items-center gap-1 rounded-[6px] bg-ui-controls/45 px-[4px] py-[1px] text-[10px] shadow-sm",
className
)}
>
{icon}
{label}
</div>
);
};

View File

@ -1,13 +0,0 @@
{
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}

View File

@ -1,45 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
]
}

View File

@ -1,34 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@ -1,50 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { isExternal } from '../../shared-vite-config';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/badge',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-badge',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: isExternal
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));

View File

@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -1,7 +0,0 @@
# @storyteller/ui-button-dropdown
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-button-dropdown` to execute the unit tests via [Vitest](https://vitest.dev/).

View File

@ -1,17 +0,0 @@
{
"name": "@storyteller/ui-button-dropdown",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}

View File

@ -1,2 +0,0 @@
export * from './lib/button-dropdown';

View File

@ -1,7 +0,0 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/

View File

@ -1,12 +0,0 @@
import { render } from '@testing-library/react';
import ButtonDropdown from './button-dropdown';
describe('ButtonDropdown', () => {
it('should render successfully', () => {
const { baseElement } = render(<ButtonDropdown />);
expect(baseElement).toBeTruthy();
});
});

View File

@ -1,215 +0,0 @@
import { ButtonHTMLAttributes, Fragment, useState } from "react";
import { Menu, Transition } from "@headlessui/react";
import {
IconDefinition,
faChevronDown,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal } from "@storyteller/ui-modal";
import { Button, ButtonProps } from "@storyteller/ui-button";
import { twMerge } from "tailwind-merge";
type UnionedButtonProps = { label?: string } & ButtonProps;
interface ButtonDropdownProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label: string;
icon?: IconDefinition;
align?: "left" | "right";
showSelected?: boolean;
options: Array<{
label: string;
className?: string;
icon?: IconDefinition;
selected?: boolean;
description?: string;
onClick?: () => void;
disabled?: boolean;
divider?: boolean;
onDialogOpen?: () => void;
dialogProps?: {
title: string;
content: React.ReactNode;
className?: string;
confirmButtonProps?: UnionedButtonProps;
closeButtonProps?: UnionedButtonProps;
showClose?: boolean;
onClose?: () => void;
};
}>;
}
export const ButtonDropdown = ({
className,
label,
options,
icon,
align = "left",
showSelected,
}: ButtonDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number | null>(
null,
);
const closeModal = () => {
setIsOpen(false);
if (selectedOptionIndex !== null) {
options[selectedOptionIndex]?.dialogProps?.onClose?.();
}
};
const handleOptionClick = (index: number) => {
const option = options[index];
if (option.onClick) {
option.onClick();
}
if (option.onDialogOpen) {
option.onDialogOpen();
}
if (option.dialogProps) {
setSelectedOptionIndex(index);
setIsOpen(true);
}
};
const currentDialogProps =
selectedOptionIndex !== null
? options[selectedOptionIndex]?.dialogProps
: null;
return (
<div className="relative">
<Menu as="div" className="inline-block text-left">
<Menu.Button as="div">
<Button
className={className}
icon={faChevronDown}
iconFlip={true}
variant="secondary"
>
{icon ? <FontAwesomeIcon icon={icon} /> : null}
{label}
</Button>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
static
className={twMerge(
"absolute z-20 mt-1 w-max divide-y divide-gray-100 overflow-hidden rounded-lg bg-brand-secondary py-1.5 shadow-xl focus:outline-none",
align === "left" ? "left-0" : "right-0",
)}
>
<div>
{options.map((option, index) => (
<Fragment key={index}>
{option.divider && (
<div className="my-1.5 border-t border-white/5" />
)}
<Menu.Item>
{({ active }) => (
<button
disabled={option.disabled}
className={twMerge(
"duration-50 bg-brand-secondary font-medium text-white transition-all",
active ? "bg-brand-secondary-800" : "",
option.disabled
? "pointer-events-none opacity-40"
: "",
"group flex w-full items-center py-1.5 pl-7 pr-4 text-sm",
option.className,
)}
onClick={() => handleOptionClick(index)}
>
<div className="flex w-full items-center">
{option.icon && (
<FontAwesomeIcon
icon={option.icon}
className="mr-2"
/>
)}
<div className="grow text-start">{option.label}</div>
<div className="ml-10 font-normal text-white/75">
{option.description && option.description}
</div>
{showSelected && (
<>
{option.selected ? (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
className="ml-3 flex h-5"
>
<path
opacity="1"
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"
fill="#FC6B68"
/>
<path
d="M369 175c-9.4 9.4-9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c-9.4-9.4 24.6-9.4 33.9 0z"
fill="#FFFFFF"
/>
</svg>
) : (
<div className="w-8" />
)}
</>
)}
</div>
</button>
)}
</Menu.Item>
</Fragment>
))}
</div>
</Menu.Items>
</Transition>
</Menu>
{currentDialogProps && (
<Modal
title={currentDialogProps.title}
isOpen={isOpen}
onClose={closeModal}
className={currentDialogProps.className}
>
{currentDialogProps.content}
<div className="mt-6 flex justify-end gap-2">
{currentDialogProps.showClose !== false &&
currentDialogProps.closeButtonProps && (
<Button
variant="secondary"
{...currentDialogProps.closeButtonProps}
onClick={closeModal}
>
{currentDialogProps.closeButtonProps.label}
</Button>
)}
{currentDialogProps.confirmButtonProps && (
<Button
{...currentDialogProps.confirmButtonProps}
onClick={(e) => {
if (currentDialogProps.confirmButtonProps?.onClick) {
currentDialogProps.confirmButtonProps?.onClick(e);
}
closeModal();
}}
>
{currentDialogProps.confirmButtonProps.label || "Confirm"}
</Button>
)}
</div>
</Modal>
)}
</div>
);
};

View File

@ -1,19 +0,0 @@
{
"files": [],
"include": [],
"references": [
{
"path": "../button"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json"
}

View File

@ -1,53 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
],
"rootDir": "src",
"jsx": "react-jsx",
"tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo"
},
"exclude": [
"out-tsc",
"dist",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.ts",
"src/**/*.tsx"
],
"references": [
{
"path": "../button/tsconfig.lib.json"
},
{
"path": "../modal/tsconfig.lib.json"
}
]
}

View File

@ -1,34 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./out-tsc/vitest",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
],
"jsx": "react-jsx"
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@ -1,50 +0,0 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import * as path from 'path';
import { isExternal } from '../../shared-vite-config';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/components/button-dropdown',
plugins: [react(), dts({ entryRoot: 'src', tsconfigPath: path.join(__dirname, 'tsconfig.lib.json') })],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
// Configuration for building your library.
// See: https://vitejs.dev/guide/build.html#library-mode
build: {
outDir: './dist',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
lib: {
// Could also be a dictionary or array of multiple entry points.
entry: 'src/index.ts',
name: '@storyteller/ui-button-dropdown',
fileName: 'index',
// Change this to the formats you want to support.
// Don't forget to update your package.json as well.
formats: ['es' as const]
},
rollupOptions: {
// External packages that should not be bundled into your library.
external: isExternal
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: './test-output/vitest/coverage',
provider: 'v8' as const,
}
},
}));

View File

@ -1,12 +0,0 @@
{
"presets": [
[
"@nx/react/babel",
{
"runtime": "automatic",
"useBuiltIns": "usage"
}
]
],
"plugins": []
}

View File

@ -1,7 +0,0 @@
# @storyteller/ui-button-icon
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test @storyteller/ui-button-icon` to execute the unit tests via [Vitest](https://vitest.dev/).

View File

@ -1,17 +0,0 @@
{
"name": "@storyteller/ui-button-icon",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
}
}

View File

@ -1,2 +0,0 @@
export * from './lib/button-icon';

View File

@ -1,7 +0,0 @@
/*
* Replace this with your own classes
*
* e.g.
* .container {
* }
*/

View File

@ -1,12 +0,0 @@
import { render } from '@testing-library/react';
import ButtonIcon from './button-icon';
describe('ButtonIcon', () => {
it('should render successfully', () => {
const { baseElement } = render(<ButtonIcon />);
expect(baseElement).toBeTruthy();
});
});

Some files were not shown because too many files have changed in this diff Show More