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:
parent
bce25f1025
commit
60e15a8ec1
Binary file not shown.
|
Before Width: | Height: | Size: 334 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 248 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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
|
||||
}
|
||||
|
||||
Binary file not shown.
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from "./ErrorDialog";
|
||||
export * from "./TopBar";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { AssetFilterOption } from "@storyteller/ui-pagescene";
|
||||
export { AssetFilterOption } from "~/types/artcraft-web";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { ClipGroup } from "@storyteller/ui-pagescene";
|
||||
export { ClipGroup } from "~/types/artcraft-web";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
// Single source of truth lives in @storyteller/ui-pagescene.
|
||||
export { ToastTypes } from "@storyteller/ui-pagescene";
|
||||
export { ToastTypes } from "~/types/artcraft-web";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { Angles } from "./Angles";
|
||||
export { useAnglesStore } from "./AnglesStore";
|
||||
|
|
@ -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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { PageBackgroundChange } from "./PageBackgroundChange";
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { ImageWatermarkRemover } from "./ImageWatermarkRemover";
|
||||
|
||||
|
|
@ -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);
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { PageMoodboard } from "./PageMoodboard";
|
||||
export { desktopMoodboardAdapter } from "./desktopMoodboardAdapter";
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 });
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { Storyboard } from "./Storyboard";
|
||||
export { useStoryboardStore } from "./StoryboardStore";
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { VideoFrameExtractor } from "./VideoFrameExtractor";
|
||||
|
||||
|
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
export { VideoWatermarkRemover } from "./VideoWatermarkRemover";
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AssetType } from "../enums";
|
||||
import { AssetType } from "./enums";
|
||||
|
||||
export interface MediaItem {
|
||||
version: number;
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
MediaFileType,
|
||||
WeightCategory,
|
||||
WeightType,
|
||||
} from "../enums";
|
||||
} from "./enums";
|
||||
|
||||
export interface MediaInfo {
|
||||
token: string;
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
MediaFileType,
|
||||
WeightCategory,
|
||||
WeightType,
|
||||
} from "../enums";
|
||||
} from "./enums";
|
||||
import { MediaInfo } from "./mediaInfo";
|
||||
import { Pagination } from "./pagination";
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
|
|
@ -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/).
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
|
||||
export * from './lib/badge';
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
|
|
@ -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/).
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
|
||||
export * from './lib/button-dropdown';
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "../button"
|
||||
},
|
||||
{
|
||||
"path": "../modal"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../../tsconfig.base.json"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nx/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
|
|
@ -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/).
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
|
||||
export * from './lib/button-icon';
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/*
|
||||
* Replace this with your own classes
|
||||
*
|
||||
* e.g.
|
||||
* .container {
|
||||
* }
|
||||
*/
|
||||
|
|
@ -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
Loading…
Reference in New Issue