Upload Nugget Video Trimmer v1.7.html
Browse files- Nugget Video Trimmer v1.7.html +1670 -0
Nugget Video Trimmer v1.7.html
ADDED
|
@@ -0,0 +1,1670 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Nugget Video Trimmer</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root{
|
| 9 |
+
--bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2a323d;
|
| 10 |
+
--txt:#e6edf3; --dim:#8b98a5; --accent:#4cc2ff; --accent2:#2a9fd6;
|
| 11 |
+
--ok:#3fb950; --warn:#d29922; --danger:#f85149;
|
| 12 |
+
--r:10px;
|
| 13 |
+
}
|
| 14 |
+
*{box-sizing:border-box}
|
| 15 |
+
html,body{margin:0;height:100%}
|
| 16 |
+
body{
|
| 17 |
+
background:var(--bg); color:var(--txt);
|
| 18 |
+
font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
| 19 |
+
-webkit-font-smoothing:antialiased;
|
| 20 |
+
}
|
| 21 |
+
.wrap{max-width:1180px;margin:0 auto;padding:20px 20px 60px}
|
| 22 |
+
header{display:flex;align-items:center;gap:12px;margin-bottom:18px}
|
| 23 |
+
header h1{font-size:17px;margin:0;font-weight:600;letter-spacing:.2px}
|
| 24 |
+
header .sub{color:var(--dim);font-size:12.5px}
|
| 25 |
+
header .ver{color:var(--dim);font-size:12px;font-variant-numeric:tabular-nums;
|
| 26 |
+
border:1px solid var(--line);border-radius:99px;padding:3px 10px;background:var(--panel)}
|
| 27 |
+
.spacer{flex:1}
|
| 28 |
+
|
| 29 |
+
button{
|
| 30 |
+
font:inherit;color:var(--txt);background:var(--panel2);border:1px solid var(--line);
|
| 31 |
+
padding:7px 13px;border-radius:8px;cursor:pointer;transition:.12s;
|
| 32 |
+
}
|
| 33 |
+
button:hover:not(:disabled){background:#242d39;border-color:#3a4553}
|
| 34 |
+
button:disabled{opacity:.4;cursor:not-allowed}
|
| 35 |
+
button.primary{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
|
| 36 |
+
button.primary:hover:not(:disabled){background:#6bcfff}
|
| 37 |
+
button.ghost{background:transparent}
|
| 38 |
+
button.danger{color:#ffb4ae;border-color:#5a2b2b}
|
| 39 |
+
button.danger:hover:not(:disabled){background:#341c1c;border-color:#7a3a3a}
|
| 40 |
+
.icobtn{padding:7px 11px;font-variant-numeric:tabular-nums}
|
| 41 |
+
.small{padding:5px 10px;font-size:12.5px}
|
| 42 |
+
|
| 43 |
+
/* ---------- drop zone ---------- */
|
| 44 |
+
#drop{
|
| 45 |
+
border:2px dashed var(--line);border-radius:14px;padding:64px 24px;text-align:center;
|
| 46 |
+
background:var(--panel);cursor:pointer;transition:.15s;
|
| 47 |
+
}
|
| 48 |
+
#drop:hover,#drop.over{border-color:var(--accent);background:#12212b}
|
| 49 |
+
#drop h2{margin:0 0 6px;font-size:18px;font-weight:600}
|
| 50 |
+
#drop p{margin:0;color:var(--dim);font-size:13px}
|
| 51 |
+
#drop .big{font-size:38px;line-height:1;margin-bottom:14px;opacity:.65}
|
| 52 |
+
|
| 53 |
+
/* ---------- layout ---------- */
|
| 54 |
+
#app{display:none}
|
| 55 |
+
.grid{display:grid;grid-template-columns:1fr 316px;gap:18px;align-items:start}
|
| 56 |
+
@media(max-width:900px){.grid{grid-template-columns:1fr}}
|
| 57 |
+
|
| 58 |
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px}
|
| 59 |
+
.card h3{margin:0 0 12px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
|
| 60 |
+
|
| 61 |
+
.stage{background:#000;border-radius:var(--r);overflow:hidden;display:flex;align-items:center;justify-content:center;position:relative;border:1px solid var(--line)}
|
| 62 |
+
.stage video,.stage canvas{max-width:100%;max-height:56vh;display:block}
|
| 63 |
+
#exportCanvas{display:none}
|
| 64 |
+
.badge{position:absolute;top:10px;left:10px;z-index:3;background:rgba(0,0,0,.66);backdrop-filter:blur(4px);
|
| 65 |
+
padding:4px 9px;border-radius:6px;font-size:11.5px;color:#cfe;letter-spacing:.3px}
|
| 66 |
+
|
| 67 |
+
/* ---------- crop overlay ---------- */
|
| 68 |
+
#cropLayer{position:absolute;display:none;touch-action:none;z-index:2}
|
| 69 |
+
#cropBox{position:absolute;box-shadow:0 0 0 9999px rgba(4,7,11,.62);
|
| 70 |
+
border:1px solid rgba(255,255,255,.92);cursor:move;box-sizing:border-box}
|
| 71 |
+
#cropBox::after{content:"";position:absolute;inset:0;pointer-events:none;
|
| 72 |
+
background:
|
| 73 |
+
linear-gradient(to right,transparent calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% + 0.5px),transparent calc(33.33% + 0.5px),transparent calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% + 0.5px),transparent calc(66.66% + 0.5px)),
|
| 74 |
+
linear-gradient(to bottom,transparent calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% - 0.5px),rgba(255,255,255,.3) calc(33.33% + 0.5px),transparent calc(33.33% + 0.5px),transparent calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% - 0.5px),rgba(255,255,255,.3) calc(66.66% + 0.5px),transparent calc(66.66% + 0.5px));}
|
| 75 |
+
.ch{position:absolute;width:16px;height:16px;background:#fff;border:1px solid rgba(0,0,0,.65);border-radius:3px;z-index:2}
|
| 76 |
+
.ch:hover{background:var(--accent)}
|
| 77 |
+
.ch[data-h=nw]{left:-8px;top:-8px;cursor:nwse-resize}
|
| 78 |
+
.ch[data-h=ne]{right:-8px;top:-8px;cursor:nesw-resize}
|
| 79 |
+
.ch[data-h=sw]{left:-8px;bottom:-8px;cursor:nesw-resize}
|
| 80 |
+
.ch[data-h=se]{right:-8px;bottom:-8px;cursor:nwse-resize}
|
| 81 |
+
.ch[data-h=n]{left:calc(50% - 8px);top:-8px;cursor:ns-resize}
|
| 82 |
+
.ch[data-h=s]{left:calc(50% - 8px);bottom:-8px;cursor:ns-resize}
|
| 83 |
+
.ch[data-h=w]{left:-8px;top:calc(50% - 8px);cursor:ew-resize}
|
| 84 |
+
.ch[data-h=e]{right:-8px;top:calc(50% - 8px);cursor:ew-resize}
|
| 85 |
+
.cropdims{position:absolute;left:50%;transform:translateX(-50%);bottom:5px;
|
| 86 |
+
background:rgba(0,0,0,.75);color:#dff;padding:2px 8px;border-radius:5px;
|
| 87 |
+
font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;pointer-events:none}
|
| 88 |
+
|
| 89 |
+
#cropBtn.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
|
| 90 |
+
#cropBtn.on .kbd{background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25);color:#04121c}
|
| 91 |
+
.arlab{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.6px;margin:2px 0 5px}
|
| 92 |
+
.arRow{margin-bottom:8px}
|
| 93 |
+
.arcustom{display:flex;align-items:center;gap:6px;margin-bottom:4px}
|
| 94 |
+
.arcustom input{width:100%;min-width:0;text-align:center}
|
| 95 |
+
.arcustom span{color:var(--dim)}
|
| 96 |
+
.arcustom.on input{border-color:var(--accent)}
|
| 97 |
+
|
| 98 |
+
/* ---------- transport ---------- */
|
| 99 |
+
.transport{display:flex;align-items:center;gap:8px;margin-top:12px;flex-wrap:wrap}
|
| 100 |
+
.tc{font-variant-numeric:tabular-nums;font-size:13px;color:var(--dim)}
|
| 101 |
+
.tc b{color:var(--txt);font-weight:600}
|
| 102 |
+
|
| 103 |
+
/* ---------- timeline ---------- */
|
| 104 |
+
.tl-wrap{margin-top:14px;user-select:none}
|
| 105 |
+
.tl{position:relative;height:70px;background:var(--panel2);border:1px solid var(--line);border-radius:8px;overflow:hidden;touch-action:none;cursor:pointer}
|
| 106 |
+
#strip{position:absolute;inset:0;display:flex;opacity:.9}
|
| 107 |
+
#strip canvas{height:100%;flex:1 1 0;min-width:0;object-fit:cover}
|
| 108 |
+
.mask{position:absolute;top:0;bottom:0;background:rgba(6,9,13,.72);pointer-events:none}
|
| 109 |
+
.sel{position:absolute;top:0;bottom:0;border-left:2px solid var(--accent);border-right:2px solid var(--accent);
|
| 110 |
+
box-shadow:inset 0 0 0 1px rgba(76,194,255,.25);pointer-events:none}
|
| 111 |
+
.handle{position:absolute;top:0;bottom:0;width:16px;margin-left:-8px;cursor:ew-resize;touch-action:none;z-index:5}
|
| 112 |
+
.handle::after{content:"";position:absolute;left:5px;top:50%;transform:translateY(-50%);
|
| 113 |
+
width:6px;height:26px;border-radius:3px;background:var(--accent);box-shadow:0 0 0 1px rgba(0,0,0,.4)}
|
| 114 |
+
.handle:hover::after{background:#8bd8ff}
|
| 115 |
+
.playhead{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:#fff;pointer-events:none;z-index:6;box-shadow:0 0 6px rgba(255,255,255,.7)}
|
| 116 |
+
.ruler{display:flex;justify-content:space-between;margin-top:5px;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
|
| 117 |
+
|
| 118 |
+
/* saved clips drawn along the bottom of the timeline */
|
| 119 |
+
#segBand{position:absolute;left:0;right:0;bottom:0;height:16px;pointer-events:none;z-index:4}
|
| 120 |
+
.segbar{position:absolute;bottom:0;height:16px;min-width:3px;background:rgba(63,185,80,.42);
|
| 121 |
+
border:1px solid rgba(63,185,80,.95);border-radius:3px;overflow:hidden;
|
| 122 |
+
font-size:9.5px;line-height:14px;text-align:center;color:#dfffe6;font-weight:600}
|
| 123 |
+
.segbar.on{background:rgba(76,194,255,.5);border-color:#8bd8ff;color:#eaf7ff}
|
| 124 |
+
|
| 125 |
+
.marks{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap;align-items:center}
|
| 126 |
+
.pill{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:12.5px;font-variant-numeric:tabular-nums}
|
| 127 |
+
.pill span{color:var(--dim)}
|
| 128 |
+
.pill b{font-weight:600}
|
| 129 |
+
.pill.dur b{color:var(--accent)}
|
| 130 |
+
.edit{cursor:text;border-bottom:1px dotted #4a5663;padding-bottom:1px}
|
| 131 |
+
.edit:hover{border-bottom-color:var(--accent);color:var(--accent)}
|
| 132 |
+
.edit input{width:88px;padding:0 3px;border:1px solid var(--accent);border-radius:4px;
|
| 133 |
+
background:#0b0f14;color:var(--txt);font:inherit;font-weight:600;text-align:center}
|
| 134 |
+
|
| 135 |
+
/* ---------- clip list ---------- */
|
| 136 |
+
#clipsWrap{display:none;margin-top:18px}
|
| 137 |
+
.clips-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
| 138 |
+
.clips-head h3{margin:0;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim);font-weight:600}
|
| 139 |
+
#clipList{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel)}
|
| 140 |
+
.crow{display:flex;align-items:center;gap:10px;padding:7px 10px;border-top:1px solid var(--line);font-size:12.5px}
|
| 141 |
+
.crow:first-child{border-top:none}
|
| 142 |
+
.crow:hover{background:var(--panel2)}
|
| 143 |
+
.crow.on{background:#12212b;box-shadow:inset 2px 0 0 var(--accent)}
|
| 144 |
+
.cnum{width:20px;color:var(--dim);font-variant-numeric:tabular-nums;flex:none}
|
| 145 |
+
.cname{flex:1 1 90px;min-width:60px;max-width:230px}
|
| 146 |
+
.cname input{width:100%;background:transparent;border:1px solid transparent;border-radius:6px;
|
| 147 |
+
padding:2px 6px;color:var(--txt);font:inherit}
|
| 148 |
+
.cname input:hover{border-color:var(--line)}
|
| 149 |
+
.cname input:focus{outline:none;border-color:var(--accent);background:#0b0f14}
|
| 150 |
+
.cname input::placeholder{color:#5a6572}
|
| 151 |
+
.ctimes{font-variant-numeric:tabular-nums;color:var(--dim);flex:none}
|
| 152 |
+
.ctimes b{color:var(--txt);font-weight:600}
|
| 153 |
+
.cdur{font-variant-numeric:tabular-nums;color:var(--accent);font-weight:600;width:64px;text-align:right;flex:none}
|
| 154 |
+
.cbtns{display:flex;gap:4px;flex:none}
|
| 155 |
+
.cbtns button{padding:3px 8px;font-size:11.5px;border-radius:6px}
|
| 156 |
+
@media(max-width:760px){ .ctimes{display:none} }
|
| 157 |
+
|
| 158 |
+
/* ---------- settings ---------- */
|
| 159 |
+
.row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
| 160 |
+
.row label{flex:0 0 78px;color:var(--dim);font-size:12.5px}
|
| 161 |
+
input[type=text],input[type=number],select{
|
| 162 |
+
font:inherit;background:var(--panel2);color:var(--txt);border:1px solid var(--line);
|
| 163 |
+
border-radius:7px;padding:6px 8px;width:100%;min-width:0;
|
| 164 |
+
}
|
| 165 |
+
input:focus,select:focus{outline:none;border-color:var(--accent)}
|
| 166 |
+
input[type=range]{width:100%;accent-color:var(--accent)}
|
| 167 |
+
.seg{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:10px}
|
| 168 |
+
.seg button{padding:5px 9px;font-size:12.5px;border-radius:7px}
|
| 169 |
+
.seg button.on{background:var(--accent);border-color:var(--accent);color:#04121c;font-weight:600}
|
| 170 |
+
.hint{color:var(--dim);font-size:11.5px;margin:-4px 0 12px}
|
| 171 |
+
.chk{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12.5px;margin-bottom:10px;cursor:pointer}
|
| 172 |
+
hr{border:none;border-top:1px solid var(--line);margin:14px 0}
|
| 173 |
+
|
| 174 |
+
.est{background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:12.5px;margin-bottom:12px}
|
| 175 |
+
.est div{display:flex;justify-content:space-between;margin:2px 0}
|
| 176 |
+
.est span{color:var(--dim)}
|
| 177 |
+
.est b{font-variant-numeric:tabular-nums;font-weight:600}
|
| 178 |
+
|
| 179 |
+
#exportBtn{width:100%;padding:11px}
|
| 180 |
+
#snapBtn{width:100%;padding:9px;margin-top:8px}
|
| 181 |
+
#gifBtn{display:block;width:auto;margin:10px auto 0;padding:2px 0;background:transparent;
|
| 182 |
+
border:none;border-radius:0;color:var(--accent);text-decoration:underline;font-size:12.5px;text-align:center}
|
| 183 |
+
#gifBtn:hover:not(:disabled){background:transparent;color:#8bd8ff;border-color:transparent}
|
| 184 |
+
#gifBtn:disabled{opacity:.4;text-decoration:none}
|
| 185 |
+
#stopBtn{width:100%;padding:9px;margin-top:8px;display:none}
|
| 186 |
+
.bar{height:6px;background:var(--panel2);border-radius:99px;overflow:hidden;margin-top:10px;display:none}
|
| 187 |
+
.bar i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
|
| 188 |
+
#status{font-size:12px;color:var(--dim);margin-top:8px;text-align:center;min-height:16px}
|
| 189 |
+
|
| 190 |
+
#result{display:none;margin-top:14px}
|
| 191 |
+
#result video{width:100%;border-radius:8px;background:#000;border:1px solid var(--line)}
|
| 192 |
+
#saved{display:none;margin-top:14px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);padding:10px 12px}
|
| 193 |
+
#saved h4{margin:0 0 6px;font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--dim)}
|
| 194 |
+
#savedList{margin:0;padding-left:16px;font-size:12px;color:var(--txt);line-height:1.7;max-height:190px;overflow:auto}
|
| 195 |
+
.note{font-size:11.5px;color:var(--warn);margin-top:8px;line-height:1.4}
|
| 196 |
+
.kbd{display:inline-block;background:var(--panel2);border:1px solid var(--line);border-bottom-width:2px;
|
| 197 |
+
border-radius:5px;padding:0 5px;font-size:11px;font-family:ui-monospace,Menlo,Consolas,monospace}
|
| 198 |
+
.keys{color:var(--dim);font-size:11.5px;margin-top:12px;line-height:1.9}
|
| 199 |
+
</style>
|
| 200 |
+
</head>
|
| 201 |
+
<body>
|
| 202 |
+
<div class="wrap">
|
| 203 |
+
|
| 204 |
+
<header>
|
| 205 |
+
<h1>Nugget Video Trimmer</h1>
|
| 206 |
+
<span class="sub">trim · resize · fps · quality</span>
|
| 207 |
+
<span class="spacer"></span>
|
| 208 |
+
<button id="newBtn" class="ghost" style="display:none">Load another video</button>
|
| 209 |
+
<span class="ver">v1.7</span>
|
| 210 |
+
</header>
|
| 211 |
+
|
| 212 |
+
<div id="drop">
|
| 213 |
+
<div class="big">▢</div>
|
| 214 |
+
<h2>Drop a video here</h2>
|
| 215 |
+
<p>or click to choose a file · MP4, MOV, WebM, MKV · nothing is uploaded, it all stays on this computer</p>
|
| 216 |
+
<input type="file" id="file" accept="video/*" hidden>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<div id="app">
|
| 220 |
+
<div class="grid">
|
| 221 |
+
|
| 222 |
+
<!-- LEFT -->
|
| 223 |
+
<div>
|
| 224 |
+
<div class="stage" id="stage">
|
| 225 |
+
<video id="video" playsinline></video>
|
| 226 |
+
<canvas id="exportCanvas"></canvas>
|
| 227 |
+
<div id="cropLayer">
|
| 228 |
+
<div id="cropBox" data-h="move">
|
| 229 |
+
<span class="ch" data-h="nw"></span><span class="ch" data-h="n"></span><span class="ch" data-h="ne"></span>
|
| 230 |
+
<span class="ch" data-h="w"></span><span class="ch" data-h="e"></span>
|
| 231 |
+
<span class="ch" data-h="sw"></span><span class="ch" data-h="s"></span><span class="ch" data-h="se"></span>
|
| 232 |
+
<span class="cropdims" id="cropDims"></span>
|
| 233 |
+
</div>
|
| 234 |
+
</div>
|
| 235 |
+
<div class="badge" id="srcBadge"></div>
|
| 236 |
+
</div>
|
| 237 |
+
|
| 238 |
+
<div class="transport">
|
| 239 |
+
<button id="playBtn" class="icobtn" title="Play / pause (Space)">▶</button>
|
| 240 |
+
<button id="prevF" class="icobtn" title="Previous frame (←)">⇤</button>
|
| 241 |
+
<button id="nextF" class="icobtn" title="Next frame (→)">⇥</button>
|
| 242 |
+
<span class="tc"><b id="curTime">0:00.00</b> / <span id="totTime">0:00.00</span></span>
|
| 243 |
+
<span class="spacer"></span>
|
| 244 |
+
<button id="cropBtn" title="Show or hide the crop box (C)">Crop <span class="kbd">C</span></button>
|
| 245 |
+
<button id="setIn">Set start <span class="kbd">I</span></button>
|
| 246 |
+
<button id="setOut">Set end <span class="kbd">O</span></button>
|
| 247 |
+
<button id="loopBtn" class="ghost" title="Loop the selection">Loop: off</button>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
<div class="tl-wrap">
|
| 251 |
+
<div class="tl" id="tl">
|
| 252 |
+
<div id="strip"></div>
|
| 253 |
+
<div class="mask" id="maskL"></div>
|
| 254 |
+
<div class="mask" id="maskR"></div>
|
| 255 |
+
<div class="sel" id="sel"></div>
|
| 256 |
+
<div id="segBand"></div>
|
| 257 |
+
<div class="playhead" id="ph"></div>
|
| 258 |
+
<div class="handle" id="hIn"></div>
|
| 259 |
+
<div class="handle" id="hOut"></div>
|
| 260 |
+
</div>
|
| 261 |
+
<div class="ruler"><span>0:00</span><span id="rulMid"></span><span id="rulEnd"></span></div>
|
| 262 |
+
|
| 263 |
+
<div class="marks">
|
| 264 |
+
<span class="pill"><span>Start</span> <b id="pIn" class="edit" title="Click to type an exact time">0:00.00</b></span>
|
| 265 |
+
<span class="pill"><span>End</span> <b id="pOut" class="edit" title="Click to type an exact time">0:00.00</b></span>
|
| 266 |
+
<span class="pill dur"><span>Duration</span> <b id="pDur" class="edit" title="Click to type an exact length, e.g. 30 or 1:30">0:00.00</b></span>
|
| 267 |
+
<button id="addClip" class="primary small" title="Save this selection to the clip list (A)">+ Add clip <span class="kbd" style="background:rgba(0,0,0,.15);border-color:rgba(0,0,0,.25)">A</span></button>
|
| 268 |
+
<button id="resetSel" class="ghost small">Reset to full clip</button>
|
| 269 |
+
</div>
|
| 270 |
+
|
| 271 |
+
<div class="keys">
|
| 272 |
+
<span class="kbd">Space</span> play/pause <span class="kbd">←</span><span class="kbd">→</span> step 1 frame
|
| 273 |
+
<span class="kbd">Shift</span>+<span class="kbd">←</span><span class="kbd">→</span> step 1 sec
|
| 274 |
+
<span class="kbd">I</span> set start <span class="kbd">O</span> set end <span class="kbd">A</span> add clip <span class="kbd">C</span> crop <span class="kbd">S</span> snapshot <span class="kbd">Home</span>/<span class="kbd">End</span> jump
|
| 275 |
+
<br>Click <b style="color:var(--txt)">Start</b>, <b style="color:var(--txt)">End</b> or <b style="color:var(--txt)">Duration</b> above to type an exact value — seconds (<code>90</code>) or minutes:seconds (<code>1:30</code>).
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<!-- clip list: only appears once you add something -->
|
| 279 |
+
<div id="clipsWrap">
|
| 280 |
+
<div class="clips-head">
|
| 281 |
+
<h3 id="clipsTitle">Clip list</h3>
|
| 282 |
+
<span class="spacer"></span>
|
| 283 |
+
<button id="clearClips" class="ghost small">Clear all</button>
|
| 284 |
+
</div>
|
| 285 |
+
<div id="clipList"></div>
|
| 286 |
+
<div class="hint" style="margin-top:8px">
|
| 287 |
+
Export uses this list — the timeline selection is only used for adding new clips.
|
| 288 |
+
Use ▶ to preview a clip, Set to overwrite it with the current timeline selection, ✕ to remove it. Click its times to type exact values.
|
| 289 |
+
Names are optional and become the file name.
|
| 290 |
+
</div>
|
| 291 |
+
</div>
|
| 292 |
+
</div>
|
| 293 |
+
</div>
|
| 294 |
+
|
| 295 |
+
<!-- RIGHT -->
|
| 296 |
+
<div>
|
| 297 |
+
<div class="card">
|
| 298 |
+
<div id="cropSection" style="display:none">
|
| 299 |
+
<h3>Crop & aspect ratio</h3>
|
| 300 |
+
<div class="seg arRow">
|
| 301 |
+
<button data-ar="0" data-lbl="free">Free</button>
|
| 302 |
+
<button data-ar="src" data-lbl="original">Original</button>
|
| 303 |
+
<button data-ar="1" data-lbl="1:1">1:1 square</button>
|
| 304 |
+
</div>
|
| 305 |
+
<div class="arlab">Landscape</div>
|
| 306 |
+
<div class="seg arRow">
|
| 307 |
+
<button data-ar="2.33333" data-lbl="21:9">21:9</button>
|
| 308 |
+
<button data-ar="1.77778" data-lbl="16:9">16:9</button>
|
| 309 |
+
<button data-ar="1.5" data-lbl="3:2">3:2</button>
|
| 310 |
+
<button data-ar="1.33333" data-lbl="4:3">4:3</button>
|
| 311 |
+
</div>
|
| 312 |
+
<div class="arlab">Portrait</div>
|
| 313 |
+
<div class="seg arRow">
|
| 314 |
+
<button data-ar="0.5625" data-lbl="9:16">9:16</button>
|
| 315 |
+
<button data-ar="0.5" data-lbl="1:2">1:2</button>
|
| 316 |
+
<button data-ar="0.66667" data-lbl="2:3">2:3</button>
|
| 317 |
+
<button data-ar="0.75" data-lbl="3:4">3:4</button>
|
| 318 |
+
<button data-ar="0.8" data-lbl="4:5">4:5</button>
|
| 319 |
+
</div>
|
| 320 |
+
<div class="arlab">Custom</div>
|
| 321 |
+
<div class="arcustom" id="arCustom">
|
| 322 |
+
<input type="number" id="arW" min="1" max="9999" step="1" placeholder="9">
|
| 323 |
+
<span>:</span>
|
| 324 |
+
<input type="number" id="arH" min="1" max="9999" step="1" placeholder="16">
|
| 325 |
+
<button id="arApply" class="small">Use</button>
|
| 326 |
+
</div>
|
| 327 |
+
<div class="hint" id="cropInfo" style="margin:8px 0"></div>
|
| 328 |
+
<button id="cropMax" class="small" style="width:100%">Biggest box that fits</button>
|
| 329 |
+
<hr>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<h3>Size</h3>
|
| 333 |
+
<div class="seg" id="scaleSeg">
|
| 334 |
+
<button data-s="1">100%</button>
|
| 335 |
+
<button data-s="0.75">75%</button>
|
| 336 |
+
<button data-s="0.5">50%</button>
|
| 337 |
+
<button data-s="0.25">25%</button>
|
| 338 |
+
</div>
|
| 339 |
+
<div class="row">
|
| 340 |
+
<label>Width</label><input type="number" id="w" min="16" step="2">
|
| 341 |
+
</div>
|
| 342 |
+
<div class="row">
|
| 343 |
+
<label>Height</label><input type="number" id="h" min="16" step="2">
|
| 344 |
+
</div>
|
| 345 |
+
<label class="chk"><input type="checkbox" id="lockAr" checked> Lock aspect ratio</label>
|
| 346 |
+
<div class="hint" id="sizeHint"></div>
|
| 347 |
+
|
| 348 |
+
<hr>
|
| 349 |
+
<h3>Frame rate</h3>
|
| 350 |
+
<div class="seg" id="fpsSeg"></div>
|
| 351 |
+
<div class="row">
|
| 352 |
+
<label>Custom</label><input type="number" id="fps" min="1" max="120" step="1">
|
| 353 |
+
</div>
|
| 354 |
+
<div class="hint">Higher than the source frame rate won't add detail.</div>
|
| 355 |
+
|
| 356 |
+
<hr>
|
| 357 |
+
<h3>Quality</h3>
|
| 358 |
+
<input type="range" id="q" min="0" max="100" value="78">
|
| 359 |
+
<div class="hint" id="qHint"></div>
|
| 360 |
+
<label class="chk"><input type="checkbox" id="keepAudio" checked> Include audio</label>
|
| 361 |
+
|
| 362 |
+
<div class="est">
|
| 363 |
+
<div><span>Output</span><b id="eSize">—</b></div>
|
| 364 |
+
<div><span>Bitrate</span><b id="eRate">—</b></div>
|
| 365 |
+
<div><span>Encoder</span><b id="eCodec">—</b></div>
|
| 366 |
+
<div><span id="eBytesLbl">Est. file size</span><b id="eBytes">—</b></div>
|
| 367 |
+
<div><span>Export takes about</span><b id="eTime">—</b></div>
|
| 368 |
+
</div>
|
| 369 |
+
|
| 370 |
+
<button id="exportBtn" class="primary">Export clip</button>
|
| 371 |
+
<button id="snapBtn" title="Save the current frame as an image (S)">Take snapshot</button>
|
| 372 |
+
<button id="gifBtn">Export as GIF</button>
|
| 373 |
+
<div class="hint" id="gifHint" style="margin:6px 0 0">GIF has only 256 colours and no sound, and files get large fast. A smaller width and 10–15 fps work best.</div>
|
| 374 |
+
<button id="stopBtn" class="danger">Stop</button>
|
| 375 |
+
<div class="bar" id="bar"><i id="barI"></i></div>
|
| 376 |
+
<div id="status"></div>
|
| 377 |
+
<div class="hint" id="expNote" style="display:none;margin:8px 0 0;text-align:center">
|
| 378 |
+
Leave this tab on screen — if you switch away, the export pauses and picks up when you come back.
|
| 379 |
+
</div>
|
| 380 |
+
|
| 381 |
+
<div id="result">
|
| 382 |
+
<video id="outVid" controls></video>
|
| 383 |
+
<img id="outImg" alt="" style="display:none;width:100%;border-radius:8px;background:#000;border:1px solid var(--line)">
|
| 384 |
+
<div style="display:flex;gap:8px;margin-top:10px">
|
| 385 |
+
<button id="dlBtn" class="primary" style="flex:1">Download again</button>
|
| 386 |
+
<button id="clearRes" class="ghost">Clear</button>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="note" id="outNote"></div>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<div id="saved">
|
| 392 |
+
<h4 id="savedWhere">Saved</h4>
|
| 393 |
+
<ol id="savedList"></ol>
|
| 394 |
+
<div class="note" id="savedNote"></div>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
<script>
|
| 404 |
+
"use strict";
|
| 405 |
+
(function(){
|
| 406 |
+
|
| 407 |
+
/* ---------------- helpers ---------------- */
|
| 408 |
+
const $ = id => document.getElementById(id);
|
| 409 |
+
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
|
| 410 |
+
function tc(t, frac){ // 1:23.45 / 1:05:03.20
|
| 411 |
+
if(!isFinite(t)||t<0) t=0;
|
| 412 |
+
const h = Math.floor(t/3600);
|
| 413 |
+
const m = Math.floor((t-h*3600)/60), s = t - h*3600 - m*60;
|
| 414 |
+
const mm = h ? String(m).padStart(2,"0") : String(m);
|
| 415 |
+
const hh = h ? h+":" : "";
|
| 416 |
+
return frac===false ? hh+mm+":"+String(Math.floor(s)).padStart(2,"0")
|
| 417 |
+
: hh+mm+":"+(s<10?"0":"")+s.toFixed(2);
|
| 418 |
+
}
|
| 419 |
+
function stamp(t){ t=Math.max(0,t||0); return Math.floor(t/60)+"m"+String(Math.floor(t%60)).padStart(2,"0")+"s"; }
|
| 420 |
+
function bytes(n){
|
| 421 |
+
if(!isFinite(n)) return "—";
|
| 422 |
+
if(n < 1024*1024) return (n/1024).toFixed(0)+" KB";
|
| 423 |
+
if(n < 1024*1024*1024) return (n/1048576).toFixed(1)+" MB";
|
| 424 |
+
return (n/1073741824).toFixed(2)+" GB";
|
| 425 |
+
}
|
| 426 |
+
function even(n){ n = Math.round(n); return n%2 ? n+1 : n; }
|
| 427 |
+
function safeName(s, n){ return String(s||"").replace(/[\\/:*?"<>|\x00-\x1f]+/g,"-").replace(/^\.+/,"").trim().slice(0,n||60); }
|
| 428 |
+
function once(el,ev){ return new Promise(r=>el.addEventListener(ev,r,{once:true})); }
|
| 429 |
+
|
| 430 |
+
/* ---------------- state ---------------- */
|
| 431 |
+
const V = $("video");
|
| 432 |
+
let srcUrl=null, srcName="clip", dur=0, natW=0, natH=0, srcFps=30;
|
| 433 |
+
let inT=0, outT=0, looping=false, dragging=null, exporting=false, cancelExport=false;
|
| 434 |
+
let audioCtx=null, mediaSrc=null, monitorGain=null, streamDest=null;
|
| 435 |
+
let lastBlob=null, lastExt="webm", lastMeta=null;
|
| 436 |
+
let segments=[], activeSeg=-1, segId=0;
|
| 437 |
+
|
| 438 |
+
/* ---------------- file loading ---------------- */
|
| 439 |
+
const drop=$("drop"), fileInput=$("file");
|
| 440 |
+
fileInput.addEventListener("click", e=>e.stopPropagation());
|
| 441 |
+
drop.addEventListener("click", ()=>fileInput.click());
|
| 442 |
+
fileInput.addEventListener("change", e=>{ if(e.target.files[0]) load(e.target.files[0]); e.target.value=""; });
|
| 443 |
+
["dragenter","dragover"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add("over")}));
|
| 444 |
+
["dragleave","drop"].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove("over")}));
|
| 445 |
+
// ONE drop handler only, on the window — two handlers meant a drop started the load twice
|
| 446 |
+
window.addEventListener("dragover", e=>e.preventDefault());
|
| 447 |
+
window.addEventListener("drop", e=>{
|
| 448 |
+
e.preventDefault(); drop.classList.remove("over");
|
| 449 |
+
const f = e.dataTransfer && e.dataTransfer.files[0];
|
| 450 |
+
if(f) load(f);
|
| 451 |
+
});
|
| 452 |
+
$("newBtn").addEventListener("click", ()=>fileInput.click());
|
| 453 |
+
|
| 454 |
+
const DROP_HTML = drop.innerHTML;
|
| 455 |
+
let loadSeq = 0; // only the newest load() is allowed to touch the UI
|
| 456 |
+
let loadedOnce = false;
|
| 457 |
+
|
| 458 |
+
async function load(file){
|
| 459 |
+
if(exporting){ alert("Hang on until the export finishes, then load the next video."); return; }
|
| 460 |
+
if(segments.length && loadedOnce &&
|
| 461 |
+
!confirm("Loading a new video clears the clip list ("+segments.length+" clips). Continue?")) return;
|
| 462 |
+
|
| 463 |
+
const myId = ++loadSeq;
|
| 464 |
+
const newUrl = URL.createObjectURL(file);
|
| 465 |
+
const prevUrl = srcUrl;
|
| 466 |
+
|
| 467 |
+
const bail = (msg)=>{
|
| 468 |
+
URL.revokeObjectURL(newUrl);
|
| 469 |
+
if(myId !== loadSeq) return; // a newer load took over — stay quiet
|
| 470 |
+
srcUrl = prevUrl;
|
| 471 |
+
if(loadedOnce){ // don't destroy a session that's already open
|
| 472 |
+
if(V.src !== prevUrl && prevUrl){ V.src = prevUrl; }
|
| 473 |
+
alert(msg+"\n\nYour current video is still open.");
|
| 474 |
+
}else{
|
| 475 |
+
drop.innerHTML = DROP_HTML; drop.style.display="block"; $("app").style.display="none";
|
| 476 |
+
alert(msg);
|
| 477 |
+
}
|
| 478 |
+
};
|
| 479 |
+
|
| 480 |
+
srcName = (file.name||"clip").replace(/\.[^.]+$/,"") || "clip";
|
| 481 |
+
srcUrl = newUrl;
|
| 482 |
+
clearResult(); clearSaved();
|
| 483 |
+
V.src = newUrl;
|
| 484 |
+
V.muted = false;
|
| 485 |
+
|
| 486 |
+
const how = await new Promise(res=>{
|
| 487 |
+
let settled=false, timer=0;
|
| 488 |
+
const done = v => { if(settled) return; settled=true; clearTimeout(timer);
|
| 489 |
+
V.onloadedmetadata=null; V.onerror=null; res(v); };
|
| 490 |
+
V.onloadedmetadata = ()=>done("ok");
|
| 491 |
+
V.onerror = ()=>done("err");
|
| 492 |
+
timer = setTimeout(()=>done("slow"), 120000);
|
| 493 |
+
});
|
| 494 |
+
if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; }
|
| 495 |
+
|
| 496 |
+
if(how==="err") return bail("This browser can't open “"+file.name+"”.\n\nTry an MP4, MOV or WebM file — Chrome can't read every format (AVI and some MKVs won't work).");
|
| 497 |
+
if(how==="slow") return bail("“"+file.name+"” is taking too long to open. If it's on a network or cloud drive, copy it to this computer first.");
|
| 498 |
+
|
| 499 |
+
dur = V.duration;
|
| 500 |
+
if(!isFinite(dur) || dur<=0){ // some WebM/MKV files report no duration until you seek past the end
|
| 501 |
+
try{
|
| 502 |
+
V.currentTime = 1e7;
|
| 503 |
+
await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,2500))]);
|
| 504 |
+
dur = isFinite(V.duration) ? V.duration : V.currentTime;
|
| 505 |
+
V.currentTime = 0;
|
| 506 |
+
await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]);
|
| 507 |
+
}catch(e){}
|
| 508 |
+
}
|
| 509 |
+
if(myId !== loadSeq){ URL.revokeObjectURL(newUrl); return; }
|
| 510 |
+
if(!isFinite(dur) || dur<=0) return bail("Couldn't work out how long that video is, so it can't be trimmed. Try converting it to MP4 first.");
|
| 511 |
+
|
| 512 |
+
if(prevUrl) URL.revokeObjectURL(prevUrl); // safe now: the new file is definitely in
|
| 513 |
+
natW = V.videoWidth || 640; natH = V.videoHeight || 360;
|
| 514 |
+
inT = 0; outT = dur;
|
| 515 |
+
segments = []; activeSeg = -1;
|
| 516 |
+
removeCrop();
|
| 517 |
+
|
| 518 |
+
if(!loadedOnce) drop.innerHTML = '<div class="big">▢</div><h2>Reading video…</h2><p>one moment</p>';
|
| 519 |
+
srcFps = 30;
|
| 520 |
+
await detectFps();
|
| 521 |
+
if(myId !== loadSeq) return;
|
| 522 |
+
|
| 523 |
+
loadedOnce = true;
|
| 524 |
+
drop.style.display="none"; $("app").style.display="block"; $("newBtn").style.display="inline-block";
|
| 525 |
+
|
| 526 |
+
$("w").value = natW; $("h").value = natH;
|
| 527 |
+
$("totTime").textContent = tc(dur);
|
| 528 |
+
$("rulEnd").textContent = tc(dur,false);
|
| 529 |
+
$("rulMid").textContent = tc(dur/2,false);
|
| 530 |
+
|
| 531 |
+
buildFpsSeg();
|
| 532 |
+
$("fps").value = Math.round(srcFps);
|
| 533 |
+
$("srcBadge").textContent = natW+"×"+natH+" · "+Math.round(srcFps)+" fps · "+tc(dur,false);
|
| 534 |
+
document.title = srcName+" — Nugget Video Trimmer";
|
| 535 |
+
markScale(1);
|
| 536 |
+
refresh(); update();
|
| 537 |
+
buildStrip(newUrl, myId);
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
/* estimate source fps by counting presented frames over a short muted sample */
|
| 541 |
+
async function detectFps(){
|
| 542 |
+
if(!("requestVideoFrameCallback" in HTMLVideoElement.prototype)) return;
|
| 543 |
+
const prevMuted = V.muted;
|
| 544 |
+
V.muted = true;
|
| 545 |
+
let stop = false;
|
| 546 |
+
try{
|
| 547 |
+
V.currentTime = Math.min(0.2, dur/4);
|
| 548 |
+
await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,1500))]);
|
| 549 |
+
let first=null, last=null, n=0;
|
| 550 |
+
const done = new Promise(res=>{
|
| 551 |
+
const finish = ()=>{ stop=true; res(); };
|
| 552 |
+
const cb=(now,md)=>{
|
| 553 |
+
if(stop) return;
|
| 554 |
+
if(first===null) first=md.mediaTime; else { last=md.mediaTime; n++; }
|
| 555 |
+
if(n>=12 || (last!==null && last-first>0.5)) return finish();
|
| 556 |
+
V.requestVideoFrameCallback(cb);
|
| 557 |
+
};
|
| 558 |
+
V.requestVideoFrameCallback(cb);
|
| 559 |
+
setTimeout(finish, 1400); // rVFC never fired (or is throttled) — give up
|
| 560 |
+
});
|
| 561 |
+
await V.play().catch(()=>{});
|
| 562 |
+
await done;
|
| 563 |
+
if(n>2 && last>first){
|
| 564 |
+
const f = n/(last-first);
|
| 565 |
+
if(f>4 && f<125) srcFps = Math.round(f*100)/100;
|
| 566 |
+
}
|
| 567 |
+
}catch(e){}
|
| 568 |
+
stop = true;
|
| 569 |
+
try{ V.pause(); }catch(e){}
|
| 570 |
+
V.muted = prevMuted;
|
| 571 |
+
V.currentTime = 0;
|
| 572 |
+
await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,800))]);
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
/* ---------------- filmstrip ---------------- */
|
| 576 |
+
async function buildStrip(url, id){
|
| 577 |
+
const strip=$("strip"); strip.innerHTML="";
|
| 578 |
+
const N=12, tw=160, th=90, total=dur;
|
| 579 |
+
const v=document.createElement("video");
|
| 580 |
+
v.src=url; v.muted=true; v.playsInline=true;
|
| 581 |
+
try{
|
| 582 |
+
await Promise.race([once(v,"loadeddata"), once(v,"error"), new Promise(r=>setTimeout(r,15000))]);
|
| 583 |
+
}catch(e){ return; }
|
| 584 |
+
for(let i=0;i<N;i++){
|
| 585 |
+
if(id !== loadSeq){ v.removeAttribute("src"); return; }
|
| 586 |
+
const c=document.createElement("canvas"); c.width=tw; c.height=th;
|
| 587 |
+
strip.appendChild(c);
|
| 588 |
+
try{
|
| 589 |
+
v.currentTime = Math.min(total-0.03, (i+0.5)*total/N);
|
| 590 |
+
await Promise.race([once(v,"seeked"), new Promise(r=>setTimeout(r,1500))]);
|
| 591 |
+
c.getContext("2d").drawImage(v,0,0,tw,th);
|
| 592 |
+
}catch(e){}
|
| 593 |
+
}
|
| 594 |
+
v.removeAttribute("src");
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
/* ---------------- transport ---------------- */
|
| 598 |
+
$("playBtn").addEventListener("click", togglePlay);
|
| 599 |
+
function togglePlay(){
|
| 600 |
+
if(exporting) return;
|
| 601 |
+
if(V.paused){ if(V.currentTime<inT-0.01||V.currentTime>=outT-0.01) V.currentTime=inT; V.play(); }
|
| 602 |
+
else V.pause();
|
| 603 |
+
}
|
| 604 |
+
V.addEventListener("play", ()=>$("playBtn").innerHTML="❙❙");
|
| 605 |
+
V.addEventListener("pause",()=>$("playBtn").innerHTML="▶");
|
| 606 |
+
V.addEventListener("timeupdate", update);
|
| 607 |
+
V.addEventListener("seeked", update);
|
| 608 |
+
|
| 609 |
+
function rafLoop(){
|
| 610 |
+
if(!V.paused && !exporting){
|
| 611 |
+
if(V.currentTime>=outT-0.02){ if(looping){ V.currentTime=inT; } else { V.pause(); V.currentTime=outT; } }
|
| 612 |
+
update();
|
| 613 |
+
}
|
| 614 |
+
requestAnimationFrame(rafLoop);
|
| 615 |
+
}
|
| 616 |
+
requestAnimationFrame(rafLoop);
|
| 617 |
+
|
| 618 |
+
$("prevF").addEventListener("click", ()=>step(-1));
|
| 619 |
+
$("nextF").addEventListener("click", ()=>step(1));
|
| 620 |
+
function step(dir, big){
|
| 621 |
+
V.pause();
|
| 622 |
+
const d = big ? 1 : 1/(srcFps||30);
|
| 623 |
+
V.currentTime = clamp(V.currentTime + dir*d, 0, dur);
|
| 624 |
+
}
|
| 625 |
+
/* Setting a start later than the current end used to clamp the start back onto
|
| 626 |
+
the end. Marking a new clip after the previous one is the normal way to work,
|
| 627 |
+
so the other mark gets out of the way instead. */
|
| 628 |
+
function setIn(t){
|
| 629 |
+
inT = clamp(t, 0, Math.max(0, dur-0.02));
|
| 630 |
+
if(outT <= inT + 0.02) outT = dur; // start moved past the end — open the end up
|
| 631 |
+
refresh();
|
| 632 |
+
}
|
| 633 |
+
function setOut(t){
|
| 634 |
+
outT = clamp(t, 0.02, dur);
|
| 635 |
+
if(inT >= outT - 0.02) inT = 0; // end moved before the start — open the start up
|
| 636 |
+
refresh();
|
| 637 |
+
}
|
| 638 |
+
$("setIn").addEventListener("click", ()=>setIn(V.currentTime));
|
| 639 |
+
$("setOut").addEventListener("click", ()=>setOut(V.currentTime));
|
| 640 |
+
$("resetSel").addEventListener("click", ()=>{ inT=0; outT=dur; refresh(); });
|
| 641 |
+
$("loopBtn").addEventListener("click", e=>{ looping=!looping; e.target.textContent="Loop: "+(looping?"on":"off"); e.target.classList.toggle("ghost",!looping); });
|
| 642 |
+
|
| 643 |
+
/* ---------------- click-to-type time fields ---------------- */
|
| 644 |
+
function parseTime(s){ // "1:23.4", "0:05", "83.4", "1:02:03"
|
| 645 |
+
s = String(s).trim(); if(!s) return null;
|
| 646 |
+
const parts = s.split(":").map(x=>parseFloat(x));
|
| 647 |
+
if(parts.some(x=>isNaN(x))) return null;
|
| 648 |
+
return parts.reduce((a,x)=>a*60+x, 0);
|
| 649 |
+
}
|
| 650 |
+
function makeTimeEditor(el, get, set){
|
| 651 |
+
el.addEventListener("click", function(e){
|
| 652 |
+
e.stopPropagation();
|
| 653 |
+
if(exporting || el.querySelector("input")) return;
|
| 654 |
+
const inp = document.createElement("input");
|
| 655 |
+
inp.value = tc(get()); el.textContent=""; el.appendChild(inp);
|
| 656 |
+
inp.focus(); inp.select();
|
| 657 |
+
let done=false;
|
| 658 |
+
const commit = ok => {
|
| 659 |
+
if(done) return; done=true;
|
| 660 |
+
const v = ok ? parseTime(inp.value) : null;
|
| 661 |
+
inp.remove();
|
| 662 |
+
if(v !== null && isFinite(v)) set(v);
|
| 663 |
+
refresh();
|
| 664 |
+
};
|
| 665 |
+
inp.addEventListener("blur", ()=>commit(true));
|
| 666 |
+
inp.addEventListener("click", ev=>ev.stopPropagation());
|
| 667 |
+
inp.addEventListener("keydown", ev=>{
|
| 668 |
+
ev.stopPropagation();
|
| 669 |
+
if(ev.key==="Enter"){ ev.preventDefault(); commit(true); }
|
| 670 |
+
if(ev.key==="Escape"){ ev.preventDefault(); commit(false); }
|
| 671 |
+
});
|
| 672 |
+
});
|
| 673 |
+
}
|
| 674 |
+
makeTimeEditor($("pIn"), ()=>inT, v=>{ setIn(v); V.currentTime=inT; });
|
| 675 |
+
makeTimeEditor($("pOut"), ()=>outT, v=>{ setOut(v); V.currentTime=outT; });
|
| 676 |
+
makeTimeEditor($("pDur"), ()=>outT-inT, v=>{
|
| 677 |
+
const len = clamp(v, 0.02, dur);
|
| 678 |
+
if(inT + len <= dur){ outT = inT + len; }
|
| 679 |
+
else { outT = dur; inT = Math.max(0, dur - len); }
|
| 680 |
+
V.currentTime = inT;
|
| 681 |
+
});
|
| 682 |
+
|
| 683 |
+
document.addEventListener("keydown", e=>{
|
| 684 |
+
if(exporting) return;
|
| 685 |
+
const t=e.target.tagName;
|
| 686 |
+
if(t==="INPUT"||t==="SELECT"||t==="TEXTAREA") return;
|
| 687 |
+
const k=e.key;
|
| 688 |
+
if(k===" "){ e.preventDefault(); togglePlay(); }
|
| 689 |
+
else if(k==="ArrowLeft"){ e.preventDefault(); step(-1, e.shiftKey); }
|
| 690 |
+
else if(k==="ArrowRight"){ e.preventDefault(); step(1, e.shiftKey); }
|
| 691 |
+
else if(k==="i"||k==="I"){ setIn(V.currentTime); }
|
| 692 |
+
else if(k==="o"||k==="O"){ setOut(V.currentTime); }
|
| 693 |
+
else if(k==="a"||k==="A"){ e.preventDefault(); addClip(); }
|
| 694 |
+
else if(k==="c"||k==="C"){ e.preventDefault(); toggleCrop(); }
|
| 695 |
+
else if(k==="s"||k==="S"){ e.preventDefault(); takeSnapshot(); }
|
| 696 |
+
else if(k==="Home"){ V.currentTime=inT; }
|
| 697 |
+
else if(k==="End"){ V.currentTime=outT; }
|
| 698 |
+
});
|
| 699 |
+
|
| 700 |
+
/* ---------------- timeline drag ---------------- */
|
| 701 |
+
const tl=$("tl");
|
| 702 |
+
function posToTime(clientX){
|
| 703 |
+
const r=tl.getBoundingClientRect();
|
| 704 |
+
return clamp((clientX-r.left)/r.width,0,1)*dur;
|
| 705 |
+
}
|
| 706 |
+
tl.addEventListener("pointerdown", e=>{
|
| 707 |
+
if(exporting||!dur) return;
|
| 708 |
+
if(e.target===$("hIn")) dragging="in";
|
| 709 |
+
else if(e.target===$("hOut")) dragging="out";
|
| 710 |
+
else { dragging="seek"; V.pause(); V.currentTime=posToTime(e.clientX); }
|
| 711 |
+
tl.setPointerCapture(e.pointerId);
|
| 712 |
+
});
|
| 713 |
+
tl.addEventListener("pointermove", e=>{
|
| 714 |
+
if(!dragging) return;
|
| 715 |
+
const t=posToTime(e.clientX);
|
| 716 |
+
if(dragging==="in"){ inT=clamp(t,0,outT-0.02); V.currentTime=inT; }
|
| 717 |
+
else if(dragging==="out"){ outT=clamp(t,inT+0.02,dur); V.currentTime=outT; }
|
| 718 |
+
else V.currentTime=t;
|
| 719 |
+
refresh();
|
| 720 |
+
});
|
| 721 |
+
["pointerup","pointercancel"].forEach(ev=>tl.addEventListener(ev,()=>dragging=null));
|
| 722 |
+
|
| 723 |
+
function refresh(){
|
| 724 |
+
const a = dur? inT/dur*100 : 0, b = dur? outT/dur*100 : 100;
|
| 725 |
+
$("maskL").style.left="0"; $("maskL").style.width=a+"%";
|
| 726 |
+
$("maskR").style.left=b+"%"; $("maskR").style.width=(100-b)+"%";
|
| 727 |
+
$("sel").style.left=a+"%"; $("sel").style.width=(b-a)+"%";
|
| 728 |
+
$("hIn").style.left=a+"%"; $("hOut").style.left=b+"%";
|
| 729 |
+
if(activeSeg>=0){ // highlight only while the selection still matches
|
| 730 |
+
const sg=segments[activeSeg];
|
| 731 |
+
if(!sg || Math.abs(sg.start-inT)>0.001 || Math.abs(sg.end-outT)>0.001) activeSeg=-1;
|
| 732 |
+
}
|
| 733 |
+
if(!$("pIn").querySelector("input")) $("pIn").textContent=tc(inT);
|
| 734 |
+
if(!$("pOut").querySelector("input")) $("pOut").textContent=tc(outT);
|
| 735 |
+
if(!$("pDur").querySelector("input")) $("pDur").textContent=tc(outT-inT);
|
| 736 |
+
renderClips(); renderBand(); estimate();
|
| 737 |
+
}
|
| 738 |
+
function update(){
|
| 739 |
+
$("curTime").textContent=tc(V.currentTime);
|
| 740 |
+
$("ph").style.left=(dur? V.currentTime/dur*100 : 0)+"%";
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
/* ---------------- clip list ---------------- */
|
| 744 |
+
$("addClip").addEventListener("click", addClip);
|
| 745 |
+
function addClip(){
|
| 746 |
+
if(exporting || !dur) return;
|
| 747 |
+
if(outT - inT < 0.05){ setStatus("Selection is too short to add."); return; }
|
| 748 |
+
segments.push({id:++segId, start:inT, end:outT, name:""});
|
| 749 |
+
activeSeg = segments.length-1;
|
| 750 |
+
setStatus("");
|
| 751 |
+
refresh();
|
| 752 |
+
}
|
| 753 |
+
$("clearClips").addEventListener("click", ()=>{
|
| 754 |
+
if(exporting || !segments.length) return;
|
| 755 |
+
if(segments.length>1 && !confirm("Remove all "+segments.length+" clips from the list?")) return;
|
| 756 |
+
segments=[]; activeSeg=-1; refresh();
|
| 757 |
+
});
|
| 758 |
+
|
| 759 |
+
function renderClips(){
|
| 760 |
+
const wrap=$("clipsWrap"), list=$("clipList");
|
| 761 |
+
wrap.style.display = segments.length ? "block" : "none";
|
| 762 |
+
$("clipsTitle").textContent = "Clip list ("+segments.length+")";
|
| 763 |
+
if(!segments.length){ list.innerHTML=""; return; }
|
| 764 |
+
if(list.querySelector("input:focus")) return; // don't yank a field being typed in
|
| 765 |
+
|
| 766 |
+
list.innerHTML="";
|
| 767 |
+
segments.forEach((s,i)=>{
|
| 768 |
+
const row=document.createElement("div");
|
| 769 |
+
row.className="crow"+(i===activeSeg?" on":"");
|
| 770 |
+
|
| 771 |
+
const num=document.createElement("span"); num.className="cnum"; num.textContent=(i+1);
|
| 772 |
+
|
| 773 |
+
const nameWrap=document.createElement("span"); nameWrap.className="cname";
|
| 774 |
+
const name=document.createElement("input");
|
| 775 |
+
name.type="text"; name.value=s.name||""; name.placeholder="name (optional)"; name.maxLength=40;
|
| 776 |
+
name.addEventListener("input", ()=>{ s.name=name.value; });
|
| 777 |
+
name.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") name.blur(); });
|
| 778 |
+
name.addEventListener("click", ev=>ev.stopPropagation());
|
| 779 |
+
nameWrap.appendChild(name);
|
| 780 |
+
|
| 781 |
+
const times=document.createElement("span"); times.className="ctimes";
|
| 782 |
+
const bS=document.createElement("b"); bS.className="edit"; bS.textContent=tc(s.start);
|
| 783 |
+
const bE=document.createElement("b"); bE.className="edit"; bE.textContent=tc(s.end);
|
| 784 |
+
times.appendChild(bS); times.appendChild(document.createTextNode(" → ")); times.appendChild(bE);
|
| 785 |
+
makeTimeEditor(bS, ()=>s.start, v=>{ s.start = clamp(v, 0, s.end-0.02); });
|
| 786 |
+
makeTimeEditor(bE, ()=>s.end, v=>{ s.end = clamp(v, s.start+0.02, dur); });
|
| 787 |
+
|
| 788 |
+
const d=document.createElement("span"); d.className="cdur"; d.textContent=tc(s.end-s.start);
|
| 789 |
+
|
| 790 |
+
const btns=document.createElement("span"); btns.className="cbtns";
|
| 791 |
+
const prev=document.createElement("button");
|
| 792 |
+
prev.textContent="▶"; prev.title="Load this clip onto the timeline and play it";
|
| 793 |
+
prev.addEventListener("click", ev=>{ ev.stopPropagation();
|
| 794 |
+
if(exporting) return;
|
| 795 |
+
activeSeg=i; inT=s.start; outT=s.end; V.currentTime=s.start; refresh();
|
| 796 |
+
V.play().catch(()=>{}); });
|
| 797 |
+
const use=document.createElement("button");
|
| 798 |
+
use.textContent="Set"; use.title="Replace this clip's start and end with the current timeline selection";
|
| 799 |
+
use.addEventListener("click", ev=>{ ev.stopPropagation();
|
| 800 |
+
if(exporting) return;
|
| 801 |
+
s.start=inT; s.end=outT; activeSeg=i; refresh(); });
|
| 802 |
+
const del=document.createElement("button");
|
| 803 |
+
del.textContent="✕"; del.title="Remove this clip"; del.className="danger";
|
| 804 |
+
del.addEventListener("click", ev=>{ ev.stopPropagation();
|
| 805 |
+
if(exporting) return;
|
| 806 |
+
segments.splice(i,1);
|
| 807 |
+
if(activeSeg>=segments.length) activeSeg=segments.length-1;
|
| 808 |
+
refresh(); });
|
| 809 |
+
btns.appendChild(prev); btns.appendChild(use); btns.appendChild(del);
|
| 810 |
+
|
| 811 |
+
row.appendChild(num); row.appendChild(nameWrap); row.appendChild(times);
|
| 812 |
+
row.appendChild(d); row.appendChild(btns);
|
| 813 |
+
row.addEventListener("click", ()=>{ // preview this clip on the timeline
|
| 814 |
+
if(exporting) return;
|
| 815 |
+
activeSeg=i; inT=s.start; outT=s.end; V.currentTime=s.start; refresh();
|
| 816 |
+
});
|
| 817 |
+
list.appendChild(row);
|
| 818 |
+
});
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
function renderBand(){
|
| 822 |
+
const band=$("segBand"); band.innerHTML="";
|
| 823 |
+
if(!dur) return;
|
| 824 |
+
segments.forEach((s,i)=>{
|
| 825 |
+
const el=document.createElement("div");
|
| 826 |
+
el.className="segbar"+(i===activeSeg?" on":"");
|
| 827 |
+
el.style.left=(s.start/dur*100)+"%";
|
| 828 |
+
el.style.width=Math.max(0.4,(s.end-s.start)/dur*100)+"%";
|
| 829 |
+
el.textContent=(i+1);
|
| 830 |
+
band.appendChild(el);
|
| 831 |
+
});
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
/* the list is what gets exported; with an empty list it's just the selection */
|
| 835 |
+
function exportList(){
|
| 836 |
+
return segments.length ? segments.map(s=>({start:s.start,end:s.end,name:s.name}))
|
| 837 |
+
: [{start:inT,end:outT,name:""}];
|
| 838 |
+
}
|
| 839 |
+
function totalLen(){ return exportList().reduce((a,s)=>a+Math.max(0,s.end-s.start),0); }
|
| 840 |
+
|
| 841 |
+
/* ---------------- crop ----------------
|
| 842 |
+
Held in source pixels so it survives any output size change. Everything that
|
| 843 |
+
sizes the output asks srcW()/srcH() rather than the video's own dimensions. */
|
| 844 |
+
let crop=null, cropAR=0, cropARLabel="free", curScale=1;
|
| 845 |
+
function srcW(){ return crop ? crop.w : natW; }
|
| 846 |
+
function srcH(){ return crop ? crop.h : natH; }
|
| 847 |
+
function drawSource(dctx, dw, dh){
|
| 848 |
+
dctx.imageSmoothingEnabled=true; dctx.imageSmoothingQuality="high";
|
| 849 |
+
if(crop) dctx.drawImage(V, crop.x, crop.y, crop.w, crop.h, 0, 0, dw, dh);
|
| 850 |
+
else dctx.drawImage(V, 0, 0, dw, dh);
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
function videoBox(){
|
| 854 |
+
const st=$("stage").getBoundingClientRect(), v=V.getBoundingClientRect();
|
| 855 |
+
return {left:v.left-st.left, top:v.top-st.top, w:v.width, h:v.height};
|
| 856 |
+
}
|
| 857 |
+
function layoutCrop(){
|
| 858 |
+
if(!crop || !natW) return;
|
| 859 |
+
const L=$("cropLayer"), b=videoBox();
|
| 860 |
+
if(!b.w || !b.h) return;
|
| 861 |
+
L.style.left=b.left+"px"; L.style.top=b.top+"px";
|
| 862 |
+
L.style.width=b.w+"px"; L.style.height=b.h+"px";
|
| 863 |
+
const sx=b.w/natW, sy=b.h/natH, B=$("cropBox");
|
| 864 |
+
B.style.left=(crop.x*sx)+"px"; B.style.top=(crop.y*sy)+"px";
|
| 865 |
+
B.style.width=(crop.w*sx)+"px"; B.style.height=(crop.h*sy)+"px";
|
| 866 |
+
$("cropDims").textContent = Math.round(crop.w)+" × "+Math.round(crop.h);
|
| 867 |
+
}
|
| 868 |
+
function cropChanged(finalise){
|
| 869 |
+
if(crop){
|
| 870 |
+
crop.x=Math.max(0,Math.round(crop.x)); crop.y=Math.max(0,Math.round(crop.y));
|
| 871 |
+
crop.w=Math.min(Math.round(crop.w), natW-crop.x);
|
| 872 |
+
crop.h=Math.min(Math.round(crop.h), natH-crop.y);
|
| 873 |
+
}
|
| 874 |
+
layoutCrop();
|
| 875 |
+
$("cropInfo").textContent = crop
|
| 876 |
+
? "Keeping "+crop.w+" × "+crop.h+" ("+cropARLabel+") out of "+natW+" × "+natH+" · drag the box or its handles"
|
| 877 |
+
: "";
|
| 878 |
+
if(finalise){ // only resize the output once the drag ends
|
| 879 |
+
if(curScale){ W.value=even(srcW()*curScale); H.value=even(srcH()*curScale); markScale(curScale); }
|
| 880 |
+
else if(lock.checked){ H.value=even((+W.value||srcW())*srcH()/srcW()); }
|
| 881 |
+
}
|
| 882 |
+
estimate();
|
| 883 |
+
}
|
| 884 |
+
function setCrop(x,y,w,h){
|
| 885 |
+
w=Math.max(16,Math.min(w,natW)); h=Math.max(16,Math.min(h,natH));
|
| 886 |
+
crop={x:clamp(x,0,natW-w), y:clamp(y,0,natH-h), w, h};
|
| 887 |
+
}
|
| 888 |
+
function snapToAR(ar){
|
| 889 |
+
if(!ar || !crop) return;
|
| 890 |
+
const cx=crop.x+crop.w/2, cy=crop.y+crop.h/2;
|
| 891 |
+
let w=Math.min(natW, Math.max(crop.w, crop.h*ar)), h=w/ar;
|
| 892 |
+
if(h>natH){ h=natH; w=h*ar; }
|
| 893 |
+
setCrop(cx-w/2, cy-h/2, w, h);
|
| 894 |
+
}
|
| 895 |
+
function biggestFit(ar){
|
| 896 |
+
let w=natW, h=natW/(ar||natW/natH);
|
| 897 |
+
if(h>natH){ h=natH; w=h*(ar||natW/natH); }
|
| 898 |
+
setCrop((natW-w)/2, (natH-h)/2, w, h);
|
| 899 |
+
}
|
| 900 |
+
|
| 901 |
+
function showCrop(on){
|
| 902 |
+
$("cropLayer").style.display = on ? "block" : "none";
|
| 903 |
+
$("cropSection").style.display = on ? "block" : "none";
|
| 904 |
+
$("cropBtn").classList.toggle("on", on);
|
| 905 |
+
}
|
| 906 |
+
$("cropBtn").addEventListener("click", toggleCrop);
|
| 907 |
+
function toggleCrop(){
|
| 908 |
+
if(exporting || !dur) return;
|
| 909 |
+
if(crop){ removeCrop(); return; }
|
| 910 |
+
if(cropAR) biggestFit(cropAR);
|
| 911 |
+
else setCrop(natW*0.1, natH*0.1, natW*0.8, natH*0.8);
|
| 912 |
+
showCrop(true); markAR(); cropChanged(true);
|
| 913 |
+
}
|
| 914 |
+
function removeCrop(){
|
| 915 |
+
crop=null; cropAR=0; cropARLabel="free";
|
| 916 |
+
showCrop(false);
|
| 917 |
+
if(curScale){ W.value=even(natW*curScale); H.value=even(natH*curScale); markScale(curScale); }
|
| 918 |
+
$("cropInfo").textContent="";
|
| 919 |
+
estimate();
|
| 920 |
+
}
|
| 921 |
+
$("cropMax").addEventListener("click", ()=>{ if(!crop) return; biggestFit(cropAR||natW/natH); cropChanged(true); });
|
| 922 |
+
|
| 923 |
+
function applyAR(ar, label){
|
| 924 |
+
cropAR = ar; cropARLabel = label;
|
| 925 |
+
markAR();
|
| 926 |
+
if(cropAR) snapToAR(cropAR);
|
| 927 |
+
cropChanged(true);
|
| 928 |
+
}
|
| 929 |
+
document.querySelectorAll(".arRow").forEach(row=>{
|
| 930 |
+
row.addEventListener("click", e=>{
|
| 931 |
+
const b=e.target.closest("button"); if(!b || !crop) return;
|
| 932 |
+
const v=b.dataset.ar;
|
| 933 |
+
applyAR(v==="src" ? natW/natH : parseFloat(v), v==="src" ? "original" : b.dataset.lbl);
|
| 934 |
+
});
|
| 935 |
+
});
|
| 936 |
+
function useCustomAR(){
|
| 937 |
+
if(!crop) return;
|
| 938 |
+
const a=+$("arW").value, b=+$("arH").value;
|
| 939 |
+
if(!(a>0) || !(b>0)){ $("arW").focus(); return; }
|
| 940 |
+
applyAR(a/b, a+":"+b);
|
| 941 |
+
}
|
| 942 |
+
$("arApply").addEventListener("click", useCustomAR);
|
| 943 |
+
["arW","arH"].forEach(id=>$(id).addEventListener("keydown", e=>{
|
| 944 |
+
e.stopPropagation();
|
| 945 |
+
if(e.key==="Enter"){ e.preventDefault(); useCustomAR(); }
|
| 946 |
+
}));
|
| 947 |
+
function markAR(){
|
| 948 |
+
let matched=false;
|
| 949 |
+
document.querySelectorAll(".arRow button").forEach(b=>{
|
| 950 |
+
const v=b.dataset.ar, val = v==="src" ? (natW/natH) : parseFloat(v);
|
| 951 |
+
const on = (!cropAR && v==="0") || (cropAR>0 && Math.abs(val-cropAR) < 0.004);
|
| 952 |
+
b.classList.toggle("on", on);
|
| 953 |
+
if(on) matched=true;
|
| 954 |
+
});
|
| 955 |
+
$("arCustom").classList.toggle("on", !matched && cropAR>0);
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
let cropDrag=null;
|
| 959 |
+
$("cropLayer").addEventListener("pointerdown", e=>{
|
| 960 |
+
const h=e.target.dataset.h;
|
| 961 |
+
if(!h || exporting || !crop) return;
|
| 962 |
+
e.preventDefault();
|
| 963 |
+
const b=videoBox();
|
| 964 |
+
cropDrag={h, s:{...crop}, px:e.clientX, py:e.clientY, sx:b.w/natW, sy:b.h/natH};
|
| 965 |
+
$("cropLayer").setPointerCapture(e.pointerId);
|
| 966 |
+
});
|
| 967 |
+
$("cropLayer").addEventListener("pointermove", e=>{
|
| 968 |
+
if(!cropDrag) return;
|
| 969 |
+
const dx=(e.clientX-cropDrag.px)/cropDrag.sx, dy=(e.clientY-cropDrag.py)/cropDrag.sy;
|
| 970 |
+
const s=cropDrag.s, H=cropDrag.h, ar=cropAR;
|
| 971 |
+
if(H==="move"){
|
| 972 |
+
setCrop(s.x+dx, s.y+dy, s.w, s.h);
|
| 973 |
+
}else if(ar){
|
| 974 |
+
if(H==="n"||H==="s"){
|
| 975 |
+
let hh = H==="n" ? s.h-dy : s.h+dy;
|
| 976 |
+
hh=Math.max(16,Math.min(hh,natH)); let ww=hh*ar;
|
| 977 |
+
if(ww>natW){ ww=natW; hh=ww/ar; }
|
| 978 |
+
const cx=s.x+s.w/2, y = H==="n" ? (s.y+s.h)-hh : s.y;
|
| 979 |
+
setCrop(cx-ww/2, y, ww, hh);
|
| 980 |
+
}else if(H==="e"||H==="w"){
|
| 981 |
+
let ww = H==="w" ? s.w-dx : s.w+dx;
|
| 982 |
+
ww=Math.max(16,Math.min(ww,natW)); let hh=ww/ar;
|
| 983 |
+
if(hh>natH){ hh=natH; ww=hh*ar; }
|
| 984 |
+
const cy=s.y+s.h/2, x = H==="w" ? (s.x+s.w)-ww : s.x;
|
| 985 |
+
setCrop(x, cy-hh/2, ww, hh);
|
| 986 |
+
}else{ // corner: opposite corner stays put
|
| 987 |
+
const west=H.indexOf("w")>=0, north=H.indexOf("n")>=0;
|
| 988 |
+
const ax = west ? s.x+s.w : s.x, ay = north ? s.y+s.h : s.y;
|
| 989 |
+
const availW = west ? ax : natW-ax, availH = north ? ay : natH-ay;
|
| 990 |
+
let ww = Math.max(west ? s.w-dx : s.w+dx, (north ? s.h-dy : s.h+dy)*ar);
|
| 991 |
+
ww = Math.max(16, Math.min(ww, availW, availH*ar));
|
| 992 |
+
const hh = ww/ar;
|
| 993 |
+
setCrop(west ? ax-ww : ax, north ? ay-hh : ay, ww, hh);
|
| 994 |
+
}
|
| 995 |
+
}else{
|
| 996 |
+
let l=s.x, tp=s.y, r=s.x+s.w, bt=s.y+s.h;
|
| 997 |
+
if(H.indexOf("w")>=0) l=clamp(s.x+dx, 0, r-16);
|
| 998 |
+
if(H.indexOf("e")>=0) r=clamp(s.x+s.w+dx, l+16, natW);
|
| 999 |
+
if(H.indexOf("n")>=0) tp=clamp(s.y+dy, 0, bt-16);
|
| 1000 |
+
if(H.indexOf("s")>=0) bt=clamp(s.y+s.h+dy, tp+16, natH);
|
| 1001 |
+
setCrop(l, tp, r-l, bt-tp);
|
| 1002 |
+
}
|
| 1003 |
+
cropChanged(false);
|
| 1004 |
+
});
|
| 1005 |
+
["pointerup","pointercancel"].forEach(ev=>$("cropLayer").addEventListener(ev, ()=>{
|
| 1006 |
+
if(cropDrag){ cropDrag=null; cropChanged(true); }
|
| 1007 |
+
}));
|
| 1008 |
+
window.addEventListener("resize", layoutCrop);
|
| 1009 |
+
if(window.ResizeObserver) new ResizeObserver(layoutCrop).observe($("stage"));
|
| 1010 |
+
V.addEventListener("loadeddata", layoutCrop);
|
| 1011 |
+
|
| 1012 |
+
/* ---------------- size ---------------- */
|
| 1013 |
+
const W=$("w"), H=$("h"), lock=$("lockAr");
|
| 1014 |
+
$("scaleSeg").addEventListener("click", e=>{
|
| 1015 |
+
const b=e.target.closest("button"); if(!b) return;
|
| 1016 |
+
const s=parseFloat(b.dataset.s);
|
| 1017 |
+
W.value=even(srcW()*s); H.value=even(srcH()*s); markScale(s); estimate();
|
| 1018 |
+
});
|
| 1019 |
+
function markScale(s){
|
| 1020 |
+
curScale = s;
|
| 1021 |
+
[...$("scaleSeg").children].forEach(b=>b.classList.toggle("on", parseFloat(b.dataset.s)===s));
|
| 1022 |
+
}
|
| 1023 |
+
W.addEventListener("input", ()=>{ if(lock.checked && srcW()) H.value=even(W.value*srcH()/srcW()); markScale(null); estimate(); });
|
| 1024 |
+
H.addEventListener("input", ()=>{ if(lock.checked && srcH()) W.value=even(H.value*srcW()/srcH()); markScale(null); estimate(); });
|
| 1025 |
+
|
| 1026 |
+
/* ---------------- fps ---------------- */
|
| 1027 |
+
function buildFpsSeg(){
|
| 1028 |
+
const seg=$("fpsSeg"); seg.innerHTML="";
|
| 1029 |
+
const opts=[Math.round(srcFps),60,30,24,15,12].filter((v,i,a)=>v<=Math.max(60,Math.round(srcFps))&&a.indexOf(v)===i);
|
| 1030 |
+
opts.forEach(f=>{
|
| 1031 |
+
const b=document.createElement("button");
|
| 1032 |
+
b.textContent = (f===Math.round(srcFps)? "Same ("+f+")" : f);
|
| 1033 |
+
b.dataset.f=f;
|
| 1034 |
+
b.addEventListener("click",()=>{ $("fps").value=f; markFps(); estimate(); });
|
| 1035 |
+
seg.appendChild(b);
|
| 1036 |
+
});
|
| 1037 |
+
markFps();
|
| 1038 |
+
}
|
| 1039 |
+
function markFps(){
|
| 1040 |
+
const f=+$("fps").value;
|
| 1041 |
+
[...$("fpsSeg").children].forEach(b=>b.classList.toggle("on", +b.dataset.f===f));
|
| 1042 |
+
}
|
| 1043 |
+
$("fps").addEventListener("input", ()=>{ markFps(); estimate(); });
|
| 1044 |
+
|
| 1045 |
+
/* ---------------- quality / estimate ---------------- */
|
| 1046 |
+
$("q").addEventListener("input", estimate);
|
| 1047 |
+
$("keepAudio").addEventListener("change", estimate);
|
| 1048 |
+
/* bits per pixel per frame. The browser's recorder is a realtime encoder, so it
|
| 1049 |
+
needs noticeably more bitrate than an offline one to look the same. At the
|
| 1050 |
+
78 default this is ~7 Mbps for 720p30 and ~16 Mbps for 1080p30. */
|
| 1051 |
+
function qualityBpp(){
|
| 1052 |
+
const q=+$("q").value/100;
|
| 1053 |
+
return 0.035 * Math.pow(0.55/0.035, q); // 0.035 → 0.55
|
| 1054 |
+
}
|
| 1055 |
+
function targets(){
|
| 1056 |
+
const w=even(clamp(+W.value||srcW(),16,7680)), h=even(clamp(+H.value||srcH(),16,4320));
|
| 1057 |
+
const f=clamp(+$("fps").value||Math.round(srcFps),1,120);
|
| 1058 |
+
const bitrate=Math.round(clamp(w*h*f*qualityBpp(), 250000, 120000000));
|
| 1059 |
+
return {w,h,f,bitrate};
|
| 1060 |
+
}
|
| 1061 |
+
function estimate(){
|
| 1062 |
+
const t=targets(), n=exportList().length, len=totalLen();
|
| 1063 |
+
$("eSize").textContent = t.w+"×"+t.h;
|
| 1064 |
+
$("eRate").textContent = (t.bitrate/1e6).toFixed(2)+" Mbps";
|
| 1065 |
+
$("eBytesLbl").textContent = n>1 ? "Est. size, all "+n : "Est. file size";
|
| 1066 |
+
$("eCodec").textContent = codecLabel();
|
| 1067 |
+
$("eBytes").textContent = bytes(t.bitrate*len/8 + (wantAudio()?192000:0)*len/8);
|
| 1068 |
+
$("eTime").textContent = len<1 ? "—" : (len<60? Math.ceil(len)+" s" : Math.ceil(len/60)+" min");
|
| 1069 |
+
const q=+$("q").value;
|
| 1070 |
+
$("qHint").textContent = (q<25?"Small file — fine for quick previews"
|
| 1071 |
+
: q<50?"Balanced — good for email and chat"
|
| 1072 |
+
: q<72?"High — good for web and presentations"
|
| 1073 |
+
: q<90?"Very high — hard to tell from the original"
|
| 1074 |
+
: "Maximum — biggest file, best detail");
|
| 1075 |
+
$("sizeHint").textContent = (crop ? "Cropped to "+crop.w+"×"+crop.h+" (from "+natW+"×"+natH+")."
|
| 1076 |
+
: "Source "+natW+"×"+natH+".")+" Width and height are rounded to even numbers.";
|
| 1077 |
+
$("exportBtn").textContent = n>1 ? "Export "+n+" clips" : (segments.length===1 ? "Export 1 clip" : "Export clip");
|
| 1078 |
+
$("gifBtn").textContent = n>1 ? "Export "+n+" GIFs" : "Export as GIF";
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
/* ---------------- recording engine ---------------- */
|
| 1082 |
+
/* H.264 High profile first — Baseline (the old default) has no CABAC and no
|
| 1083 |
+
B-frames, which is a big quality loss at the same bitrate. Level 5.2 so 4K
|
| 1084 |
+
is inside the profile limits. */
|
| 1085 |
+
const MIMES = [
|
| 1086 |
+
"video/mp4;codecs=avc1.640034,mp4a.40.2", // High 5.2
|
| 1087 |
+
"video/mp4;codecs=avc1.64002A,mp4a.40.2", // High 4.2
|
| 1088 |
+
"video/mp4;codecs=avc1.4D402A,mp4a.40.2", // Main 4.2
|
| 1089 |
+
"video/mp4;codecs=avc1.640034",
|
| 1090 |
+
"video/mp4",
|
| 1091 |
+
"video/webm;codecs=vp9,opus",
|
| 1092 |
+
"video/webm;codecs=vp9",
|
| 1093 |
+
"video/webm;codecs=vp8,opus",
|
| 1094 |
+
"video/webm"
|
| 1095 |
+
];
|
| 1096 |
+
function pickMime(){
|
| 1097 |
+
for(const m of MIMES) if(window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
|
| 1098 |
+
return "";
|
| 1099 |
+
}
|
| 1100 |
+
function codecLabel(){
|
| 1101 |
+
const m = pickMime();
|
| 1102 |
+
if(!m) return "none";
|
| 1103 |
+
if(/avc1\.64/.test(m)) return "H.264 High";
|
| 1104 |
+
if(/avc1\.4D/.test(m)) return "H.264 Main";
|
| 1105 |
+
if(/avc1/.test(m)) return "H.264";
|
| 1106 |
+
if(/mp4/.test(m)) return "MP4";
|
| 1107 |
+
if(/vp9/.test(m)) return "VP9";
|
| 1108 |
+
if(/vp8/.test(m)) return "VP8";
|
| 1109 |
+
return m;
|
| 1110 |
+
}
|
| 1111 |
+
function wantAudio(){ const c=$("keepAudio"); return !c || c.checked; }
|
| 1112 |
+
function setupAudio(){
|
| 1113 |
+
if(audioCtx) return;
|
| 1114 |
+
try{
|
| 1115 |
+
audioCtx = new (window.AudioContext||window.webkitAudioContext)();
|
| 1116 |
+
mediaSrc = audioCtx.createMediaElementSource(V);
|
| 1117 |
+
monitorGain = audioCtx.createGain(); monitorGain.gain.value = 1;
|
| 1118 |
+
mediaSrc.connect(monitorGain); monitorGain.connect(audioCtx.destination);
|
| 1119 |
+
streamDest = audioCtx.createMediaStreamDestination();
|
| 1120 |
+
mediaSrc.connect(streamDest);
|
| 1121 |
+
}catch(e){ audioCtx=null; }
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
/* records V from `a` to `z` onto the export canvas; resolves with {blob,ext,stalled,speed} */
|
| 1125 |
+
/* A clock that keeps ticking when the tab is in the background.
|
| 1126 |
+
|
| 1127 |
+
The bug this fixes: the whole export used to run inside requestAnimationFrame.
|
| 1128 |
+
Chrome stops firing rAF when a tab is hidden (or the window is fully covered),
|
| 1129 |
+
so the canvas was never repainted and the "we've reached the end, stop now"
|
| 1130 |
+
check never ran. Result: one frozen video frame plus audio, recording on until
|
| 1131 |
+
the source video ended. Web Audio runs on a realtime thread the browser does
|
| 1132 |
+
not throttle, so it drives things now; rAF and a timer are extra safety. */
|
| 1133 |
+
function makeClock(fn){
|
| 1134 |
+
const stops=[];
|
| 1135 |
+
try{
|
| 1136 |
+
if(audioCtx && audioCtx.createScriptProcessor){
|
| 1137 |
+
const node = audioCtx.createScriptProcessor(1024, 1, 1); // ~21 ms, ~47 Hz
|
| 1138 |
+
const mute = audioCtx.createGain(); mute.gain.value = 0;
|
| 1139 |
+
node.onaudioprocess = ()=>fn();
|
| 1140 |
+
node.connect(mute); mute.connect(audioCtx.destination);
|
| 1141 |
+
stops.push(()=>{ node.onaudioprocess=null; try{ node.disconnect(); mute.disconnect(); }catch(e){} });
|
| 1142 |
+
}
|
| 1143 |
+
}catch(e){}
|
| 1144 |
+
if(typeof requestAnimationFrame === "function"){
|
| 1145 |
+
let alive=true, raf=0;
|
| 1146 |
+
const loop=()=>{ if(!alive) return; fn(); raf=requestAnimationFrame(loop); };
|
| 1147 |
+
raf=requestAnimationFrame(loop);
|
| 1148 |
+
stops.push(()=>{ alive=false; try{ cancelAnimationFrame(raf); }catch(e){} });
|
| 1149 |
+
}
|
| 1150 |
+
const id=setInterval(fn, 60);
|
| 1151 |
+
stops.push(()=>clearInterval(id));
|
| 1152 |
+
return ()=>stops.forEach(f=>{ try{ f(); }catch(e){} });
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
let wakeLock=null;
|
| 1156 |
+
async function keepAwake(on){
|
| 1157 |
+
try{
|
| 1158 |
+
if(on && navigator.wakeLock && !wakeLock){ wakeLock = await navigator.wakeLock.request("screen"); }
|
| 1159 |
+
else if(!on && wakeLock){ await wakeLock.release(); wakeLock=null; }
|
| 1160 |
+
}catch(e){ wakeLock=null; }
|
| 1161 |
+
}
|
| 1162 |
+
|
| 1163 |
+
async function recordRange(a, z, t, mime, ctx, cv, onProg){
|
| 1164 |
+
const len = Math.max(0.02, z-a);
|
| 1165 |
+
|
| 1166 |
+
// Let the canvas sample on its own clock. Driving frames by hand with
|
| 1167 |
+
// requestFrame() was tried and measured worse: roughly one run in three came
|
| 1168 |
+
// out badly short (0.85 s for a 2.00 s clip), whereas this is consistent.
|
| 1169 |
+
const cStream = cv.captureStream(t.f);
|
| 1170 |
+
|
| 1171 |
+
const tracks = [...cStream.getVideoTracks()];
|
| 1172 |
+
if(streamDest && wantAudio()) tracks.push(...streamDest.stream.getAudioTracks());
|
| 1173 |
+
const stream = new MediaStream(tracks);
|
| 1174 |
+
|
| 1175 |
+
let rec;
|
| 1176 |
+
try{ rec = new MediaRecorder(stream, {mimeType:mime, videoBitsPerSecond:t.bitrate, audioBitsPerSecond:192000}); }
|
| 1177 |
+
catch(e){ rec = new MediaRecorder(stream, {mimeType:mime}); }
|
| 1178 |
+
|
| 1179 |
+
const chunks=[];
|
| 1180 |
+
rec.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
|
| 1181 |
+
const stopped = new Promise(r => rec.onstop = r);
|
| 1182 |
+
|
| 1183 |
+
const paintFrame = ()=> drawSource(ctx, t.w, t.h);
|
| 1184 |
+
|
| 1185 |
+
V.currentTime = a;
|
| 1186 |
+
await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,5000))]);
|
| 1187 |
+
drawSource(ctx, t.w, t.h);
|
| 1188 |
+
|
| 1189 |
+
let stopClock=null, stopping=false, stalled=false, aborted=false;
|
| 1190 |
+
let wall0=0, media0=a, speed=1, pausedTotal=0, pausedAt=0, hiddenPaused=false;
|
| 1191 |
+
let lastT=V.currentTime, stallMs=0, prevNow=performance.now();
|
| 1192 |
+
const frameMs = 1000/Math.max(1,t.f);
|
| 1193 |
+
let nextFrameAt = 0;
|
| 1194 |
+
const maxMs = len*1000*3 + 8000; // a clip can never run away, whatever else breaks
|
| 1195 |
+
|
| 1196 |
+
const stop = ()=>{
|
| 1197 |
+
if(stopClock){ stopClock(); stopClock=null; }
|
| 1198 |
+
const el=(performance.now()-wall0-pausedTotal)/1000;
|
| 1199 |
+
if(el>0.2) speed=(V.currentTime-media0)/el;
|
| 1200 |
+
V.pause();
|
| 1201 |
+
try{ rec.stop(); }catch(e){}
|
| 1202 |
+
};
|
| 1203 |
+
|
| 1204 |
+
// Chrome may stop decoding video for a hidden tab, so recording while hidden
|
| 1205 |
+
// can't be trusted. Pause instead and pick up where we left off.
|
| 1206 |
+
const onVis = ()=>{
|
| 1207 |
+
if(stopping) return;
|
| 1208 |
+
if(document.hidden && !hiddenPaused){
|
| 1209 |
+
hiddenPaused=true; pausedAt=performance.now();
|
| 1210 |
+
try{ V.pause(); }catch(e){}
|
| 1211 |
+
try{ if(rec.state==="recording") rec.pause(); }catch(e){}
|
| 1212 |
+
setStatus("Paused — come back to this tab to carry on exporting");
|
| 1213 |
+
}else if(!document.hidden && hiddenPaused){
|
| 1214 |
+
hiddenPaused=false; pausedTotal += performance.now()-pausedAt;
|
| 1215 |
+
prevNow=performance.now(); nextFrameAt=0;
|
| 1216 |
+
try{ if(rec.state==="paused") rec.resume(); }catch(e){}
|
| 1217 |
+
V.play().catch(()=>{});
|
| 1218 |
+
keepAwake(true);
|
| 1219 |
+
}
|
| 1220 |
+
};
|
| 1221 |
+
document.addEventListener("visibilitychange", onVis);
|
| 1222 |
+
|
| 1223 |
+
const tick = ()=>{
|
| 1224 |
+
if(stopping || hiddenPaused) return;
|
| 1225 |
+
const now=performance.now();
|
| 1226 |
+
if(now >= nextFrameAt){
|
| 1227 |
+
paintFrame();
|
| 1228 |
+
nextFrameAt = (nextFrameAt && now - nextFrameAt < frameMs) ? nextFrameAt + frameMs : now + frameMs;
|
| 1229 |
+
}
|
| 1230 |
+
if(Math.abs(V.currentTime-lastT)>0.001){ lastT=V.currentTime; stallMs=0; } else { stallMs += now-prevNow; }
|
| 1231 |
+
prevNow=now;
|
| 1232 |
+
if(onProg) onProg(clamp((V.currentTime-a)/len,0,1), Math.max(0,len-(V.currentTime-a)));
|
| 1233 |
+
if(cancelExport || V.currentTime >= z-0.005 || V.ended ||
|
| 1234 |
+
stallMs > 10000 || (now-wall0-pausedTotal) > maxMs){
|
| 1235 |
+
stopping=true;
|
| 1236 |
+
if(cancelExport) aborted=true; // half a clip is worse than none
|
| 1237 |
+
if(stallMs>10000 || (now-wall0-pausedTotal)>maxMs) stalled=true;
|
| 1238 |
+
stop();
|
| 1239 |
+
}
|
| 1240 |
+
};
|
| 1241 |
+
const onEnded = ()=>{ if(!stopping){ stopping=true; stop(); } };
|
| 1242 |
+
V.addEventListener("ended", onEnded);
|
| 1243 |
+
|
| 1244 |
+
// start playback FIRST, then the recorder — otherwise the frozen first frame
|
| 1245 |
+
// gets baked into the front of the clip and the output runs long
|
| 1246 |
+
V.playbackRate = 1;
|
| 1247 |
+
try{ await V.play(); }
|
| 1248 |
+
catch(e){
|
| 1249 |
+
document.removeEventListener("visibilitychange", onVis);
|
| 1250 |
+
V.removeEventListener("ended",onEnded);
|
| 1251 |
+
try{ cStream.getTracks().forEach(x=>x.stop()); }catch(e2){}
|
| 1252 |
+
throw new Error("Couldn't start playback: "+e.message);
|
| 1253 |
+
}
|
| 1254 |
+
wall0 = performance.now(); media0 = V.currentTime; prevNow = wall0; nextFrameAt = 0;
|
| 1255 |
+
rec.start(200);
|
| 1256 |
+
stopClock = makeClock(tick);
|
| 1257 |
+
|
| 1258 |
+
await stopped;
|
| 1259 |
+
if(stopClock){ stopClock(); stopClock=null; }
|
| 1260 |
+
document.removeEventListener("visibilitychange", onVis);
|
| 1261 |
+
V.removeEventListener("ended", onEnded);
|
| 1262 |
+
try{ cStream.getTracks().forEach(x=>x.stop()); }catch(e){}
|
| 1263 |
+
|
| 1264 |
+
const type = mime.split(";")[0];
|
| 1265 |
+
return { blob:new Blob(chunks,{type}), ext:(type.indexOf("mp4")>=0?"mp4":"webm"), stalled, speed, aborted };
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
/* ---------------- GIF encoder ----------------
|
| 1269 |
+
Written out longhand so the file stays self-contained and works offline.
|
| 1270 |
+
GIF is limited to 256 colours per frame, so each frame gets its own palette
|
| 1271 |
+
(median cut) and Floyd-Steinberg dithering to stop gradients banding. */
|
| 1272 |
+
const _hist=new Int32Array(32768), _rs=new Int32Array(32768), _gs=new Int32Array(32768), _bs=new Int32Array(32768);
|
| 1273 |
+
const _bins=new Int32Array(32768), _cache=new Int16Array(32768);
|
| 1274 |
+
|
| 1275 |
+
function gifBytes(){
|
| 1276 |
+
let b=new Uint8Array(1<<18), n=0;
|
| 1277 |
+
const grow=k=>{ if(n+k<=b.length) return; let L=b.length; while(L<n+k) L*=2;
|
| 1278 |
+
const t=new Uint8Array(L); t.set(b.subarray(0,n)); b=t; };
|
| 1279 |
+
return {
|
| 1280 |
+
u8(v){ grow(1); b[n++]=v&255; },
|
| 1281 |
+
u16(v){ grow(2); b[n++]=v&255; b[n++]=(v>>8)&255; },
|
| 1282 |
+
str(s){ grow(s.length); for(let i=0;i<s.length;i++) b[n++]=s.charCodeAt(i); },
|
| 1283 |
+
raw(a){ grow(a.length); b.set(a,n); n+=a.length; },
|
| 1284 |
+
size(){ return n; },
|
| 1285 |
+
out(){ return b.slice(0,n); }
|
| 1286 |
+
};
|
| 1287 |
+
}
|
| 1288 |
+
|
| 1289 |
+
function buildPalette(data, maxColors){
|
| 1290 |
+
_hist.fill(0); _rs.fill(0); _gs.fill(0); _bs.fill(0);
|
| 1291 |
+
for(let i=0;i<data.length;i+=4){
|
| 1292 |
+
const r=data[i], g=data[i+1], b=data[i+2];
|
| 1293 |
+
const k=((r>>3)<<10)|((g>>3)<<5)|(b>>3);
|
| 1294 |
+
_hist[k]++; _rs[k]+=r; _gs[k]+=g; _bs[k]+=b;
|
| 1295 |
+
}
|
| 1296 |
+
let m=0; for(let k=0;k<32768;k++) if(_hist[k]) _bins[m++]=k;
|
| 1297 |
+
const stat=(s,e)=>{
|
| 1298 |
+
let rmin=31,rmax=0,gmin=31,gmax=0,bmin=31,bmax=0;
|
| 1299 |
+
for(let i=s;i<e;i++){ const k=_bins[i], r=(k>>10)&31, g=(k>>5)&31, b=k&31;
|
| 1300 |
+
if(r<rmin)rmin=r; if(r>rmax)rmax=r;
|
| 1301 |
+
if(g<gmin)gmin=g; if(g>gmax)gmax=g;
|
| 1302 |
+
if(b<bmin)bmin=b; if(b>bmax)bmax=b; }
|
| 1303 |
+
const dr=rmax-rmin, dg=gmax-gmin, db=bmax-bmin;
|
| 1304 |
+
let chan=0, ext=dr;
|
| 1305 |
+
if(dg>ext){ chan=1; ext=dg; }
|
| 1306 |
+
if(db>ext){ chan=2; ext=db; }
|
| 1307 |
+
return {s,e,chan,ext};
|
| 1308 |
+
};
|
| 1309 |
+
let boxes=[stat(0,m)];
|
| 1310 |
+
while(boxes.length<maxColors){
|
| 1311 |
+
let bi=-1, best=0;
|
| 1312 |
+
for(let i=0;i<boxes.length;i++){ const B=boxes[i];
|
| 1313 |
+
if(B.e-B.s<2) continue;
|
| 1314 |
+
if(B.ext>best){ best=B.ext; bi=i; } }
|
| 1315 |
+
if(bi<0) break;
|
| 1316 |
+
const B=boxes[bi], shift=B.chan===0?10:B.chan===1?5:0;
|
| 1317 |
+
const sub=Array.from(_bins.subarray(B.s,B.e));
|
| 1318 |
+
sub.sort((x,y)=>(((x>>shift)&31)-((y>>shift)&31)));
|
| 1319 |
+
_bins.set(sub,B.s);
|
| 1320 |
+
let total=0; for(let i=B.s;i<B.e;i++) total+=_hist[_bins[i]];
|
| 1321 |
+
let acc=0, mid=B.s+1;
|
| 1322 |
+
for(let i=B.s;i<B.e;i++){ acc+=_hist[_bins[i]]; if(acc*2>=total){ mid=i+1; break; } }
|
| 1323 |
+
if(mid<=B.s) mid=B.s+1;
|
| 1324 |
+
if(mid>=B.e) mid=B.e-1;
|
| 1325 |
+
boxes.splice(bi,1,stat(B.s,mid),stat(mid,B.e));
|
| 1326 |
+
}
|
| 1327 |
+
const pal=new Uint8Array(768);
|
| 1328 |
+
for(let i=0;i<boxes.length;i++){
|
| 1329 |
+
const B=boxes[i]; let c=0,R=0,G=0,Bl=0;
|
| 1330 |
+
for(let x=B.s;x<B.e;x++){ const k=_bins[x]; c+=_hist[k]; R+=_rs[k]; G+=_gs[k]; Bl+=_bs[k]; }
|
| 1331 |
+
if(c){ pal[i*3]=Math.round(R/c); pal[i*3+1]=Math.round(G/c); pal[i*3+2]=Math.round(Bl/c); }
|
| 1332 |
+
}
|
| 1333 |
+
return {pal, count:Math.max(1,boxes.length)};
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
function mapToPalette(data, w, h, pal, palCount, dither){
|
| 1337 |
+
const idx=new Uint8Array(w*h);
|
| 1338 |
+
_cache.fill(-1);
|
| 1339 |
+
const nearest=(r,g,b)=>{
|
| 1340 |
+
const key=((r>>3)<<10)|((g>>3)<<5)|(b>>3);
|
| 1341 |
+
const hit=_cache[key]; if(hit>=0) return hit;
|
| 1342 |
+
let bd=Infinity, bi=0;
|
| 1343 |
+
for(let i=0;i<palCount;i++){
|
| 1344 |
+
const dr=r-pal[i*3], dg=g-pal[i*3+1], db=b-pal[i*3+2];
|
| 1345 |
+
const d=dr*dr*3+dg*dg*6+db*db; // roughly luma-weighted
|
| 1346 |
+
if(d<bd){ bd=d; bi=i; }
|
| 1347 |
+
}
|
| 1348 |
+
_cache[key]=bi; return bi;
|
| 1349 |
+
};
|
| 1350 |
+
if(!dither){
|
| 1351 |
+
for(let p=0,q=0;q<idx.length;q++,p+=4) idx[q]=nearest(data[p],data[p+1],data[p+2]);
|
| 1352 |
+
return idx;
|
| 1353 |
+
}
|
| 1354 |
+
const buf=new Float32Array(w*h*3);
|
| 1355 |
+
for(let p=0,q=0;q<w*h;q++,p+=4){ buf[q*3]=data[p]; buf[q*3+1]=data[p+1]; buf[q*3+2]=data[p+2]; }
|
| 1356 |
+
const cl=v=>v<0?0:(v>255?255:v);
|
| 1357 |
+
for(let y=0;y<h;y++){
|
| 1358 |
+
for(let x=0;x<w;x++){
|
| 1359 |
+
const q=y*w+x, o=q*3;
|
| 1360 |
+
const r=cl(buf[o]), g=cl(buf[o+1]), b=cl(buf[o+2]);
|
| 1361 |
+
const i=nearest(r|0,g|0,b|0); idx[q]=i;
|
| 1362 |
+
const er=(r-pal[i*3])*0.75, eg=(g-pal[i*3+1])*0.75, eb=(b-pal[i*3+2])*0.75;
|
| 1363 |
+
if(x+1<w){ const oo=(q+1)*3; buf[oo]+=er*0.4375; buf[oo+1]+=eg*0.4375; buf[oo+2]+=eb*0.4375; }
|
| 1364 |
+
if(y+1<h){
|
| 1365 |
+
if(x>0){ const oo=(q+w-1)*3; buf[oo]+=er*0.1875; buf[oo+1]+=eg*0.1875; buf[oo+2]+=eb*0.1875; }
|
| 1366 |
+
{ const oo=(q+w)*3; buf[oo]+=er*0.3125; buf[oo+1]+=eg*0.3125; buf[oo+2]+=eb*0.3125; }
|
| 1367 |
+
if(x+1<w){ const oo=(q+w+1)*3; buf[oo]+=er*0.0625; buf[oo+1]+=eg*0.0625; buf[oo+2]+=eb*0.0625; }
|
| 1368 |
+
}
|
| 1369 |
+
}
|
| 1370 |
+
}
|
| 1371 |
+
return idx;
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
+
function lzwWrite(minCodeSize, indices, g){
|
| 1375 |
+
const CLEAR=1<<minCodeSize, EOI=CLEAR+1;
|
| 1376 |
+
let codeSize=minCodeSize+1, next=EOI+1;
|
| 1377 |
+
let dict=new Map(), cur=0, curBits=0;
|
| 1378 |
+
const block=[];
|
| 1379 |
+
const flush=()=>{ if(block.length){ g.u8(block.length); for(let i=0;i<block.length;i++) g.u8(block[i]); block.length=0; } };
|
| 1380 |
+
const emit=code=>{
|
| 1381 |
+
cur |= code<<curBits; curBits += codeSize;
|
| 1382 |
+
while(curBits>=8){ block.push(cur&255); cur>>>=8; curBits-=8; if(block.length===255) flush(); }
|
| 1383 |
+
};
|
| 1384 |
+
g.u8(minCodeSize);
|
| 1385 |
+
emit(CLEAR);
|
| 1386 |
+
let prefix=indices[0];
|
| 1387 |
+
for(let i=1;i<indices.length;i++){
|
| 1388 |
+
const k=indices[i], key=prefix*256+k, found=dict.get(key);
|
| 1389 |
+
if(found!==undefined){ prefix=found; continue; }
|
| 1390 |
+
emit(prefix);
|
| 1391 |
+
if(next===4096){
|
| 1392 |
+
emit(CLEAR); dict=new Map(); next=EOI+1; codeSize=minCodeSize+1;
|
| 1393 |
+
}else{
|
| 1394 |
+
// grow the code width BEFORE claiming the code, so the decoder — which
|
| 1395 |
+
// builds its table one code behind — switches width at the same place
|
| 1396 |
+
if(next >= (1<<codeSize) && codeSize<12) codeSize++;
|
| 1397 |
+
dict.set(key,next); next++;
|
| 1398 |
+
}
|
| 1399 |
+
prefix=k;
|
| 1400 |
+
}
|
| 1401 |
+
emit(prefix); emit(EOI);
|
| 1402 |
+
if(curBits>0){ block.push(cur&255); if(block.length===255) flush(); }
|
| 1403 |
+
flush();
|
| 1404 |
+
g.u8(0);
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
function whileVisible(){
|
| 1408 |
+
if(!document.hidden) return Promise.resolve();
|
| 1409 |
+
setStatus("Paused — come back to this tab to carry on");
|
| 1410 |
+
return new Promise(res=>{
|
| 1411 |
+
const h=()=>{ if(!document.hidden){ document.removeEventListener("visibilitychange",h); res(); } };
|
| 1412 |
+
document.addEventListener("visibilitychange",h);
|
| 1413 |
+
});
|
| 1414 |
+
}
|
| 1415 |
+
|
| 1416 |
+
/* Renders by seeking frame by frame rather than playing in real time, so it is
|
| 1417 |
+
exact and doesn't care how fast the machine is. */
|
| 1418 |
+
async function renderGif(a, z, t, ctx, cv, onProg){
|
| 1419 |
+
const len=Math.max(0.02, z-a);
|
| 1420 |
+
const delay=Math.max(2, Math.round(100/Math.min(20, t.f))); // GIF delays are in 1/100 s
|
| 1421 |
+
const w=t.w, h=t.h;
|
| 1422 |
+
const n=Math.max(1, Math.min(900, Math.round(len*100/delay)));
|
| 1423 |
+
const g=gifBytes();
|
| 1424 |
+
g.str("GIF89a"); g.u16(w); g.u16(h); g.u8(0x70); g.u8(0); g.u8(0);
|
| 1425 |
+
g.u8(0x21); g.u8(0xFF); g.u8(11); g.str("NETSCAPE2.0"); g.u8(3); g.u8(1); g.u16(0); g.u8(0); // loop forever
|
| 1426 |
+
|
| 1427 |
+
let aborted=false, t0=performance.now();
|
| 1428 |
+
for(let i=0;i<n;i++){
|
| 1429 |
+
if(cancelExport){ aborted=true; break; }
|
| 1430 |
+
await whileVisible();
|
| 1431 |
+
V.currentTime = Math.min(z-0.001, a + i*delay/100);
|
| 1432 |
+
await Promise.race([once(V,"seeked"), new Promise(r=>setTimeout(r,3000))]);
|
| 1433 |
+
drawSource(ctx, w, h);
|
| 1434 |
+
const img=ctx.getImageData(0,0,w,h);
|
| 1435 |
+
const {pal,count}=buildPalette(img.data, 256);
|
| 1436 |
+
const idx=mapToPalette(img.data, w, h, pal, count, true);
|
| 1437 |
+
g.u8(0x21); g.u8(0xF9); g.u8(4); g.u8(0x04); g.u16(delay); g.u8(0); g.u8(0);
|
| 1438 |
+
g.u8(0x2C); g.u16(0); g.u16(0); g.u16(w); g.u16(h); g.u8(0x87);
|
| 1439 |
+
g.raw(pal);
|
| 1440 |
+
lzwWrite(8, idx, g);
|
| 1441 |
+
if(onProg){
|
| 1442 |
+
const per=(performance.now()-t0)/(i+1)/1000;
|
| 1443 |
+
onProg((i+1)/n, Math.max(0,(n-i-1)*per));
|
| 1444 |
+
}
|
| 1445 |
+
if((i&1)===0) await new Promise(r=>setTimeout(r,0)); // keep the page responsive
|
| 1446 |
+
}
|
| 1447 |
+
g.u8(0x3B);
|
| 1448 |
+
return { blob:new Blob([g.out()],{type:"image/gif"}), ext:"gif", stalled:false, speed:1, aborted,
|
| 1449 |
+
frames:n, fps:Math.round(1000/delay)/10 };
|
| 1450 |
+
}
|
| 1451 |
+
|
| 1452 |
+
/* ---------------- snapshot ----------------
|
| 1453 |
+
Grabs whatever frame is currently on screen — same crop and output size as
|
| 1454 |
+
the video/GIF export — and saves it as a PNG. No recording involved, so
|
| 1455 |
+
it's instant. */
|
| 1456 |
+
function takeSnapshot(){
|
| 1457 |
+
if(exporting || !dur) return;
|
| 1458 |
+
const t = targets();
|
| 1459 |
+
const cv = $("exportCanvas"); cv.width=t.w; cv.height=t.h;
|
| 1460 |
+
const ctx = cv.getContext("2d", {alpha:false});
|
| 1461 |
+
ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality="high";
|
| 1462 |
+
drawSource(ctx, t.w, t.h);
|
| 1463 |
+
cv.toBlob(blob=>{
|
| 1464 |
+
if(!blob || !blob.size){ setStatus("Couldn't capture that frame."); return; }
|
| 1465 |
+
const base = safeName(srcName,60) || "clip";
|
| 1466 |
+
const name = base+"_"+stamp(V.currentTime)+"_"+t.w+"x"+t.h+".png";
|
| 1467 |
+
pushDownload(blob, name);
|
| 1468 |
+
setStatus("Saved "+name);
|
| 1469 |
+
}, "image/png");
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
/* ---------------- export ---------------- */
|
| 1473 |
+
$("exportBtn").addEventListener("click", ()=>doExport("video"));
|
| 1474 |
+
$("snapBtn").addEventListener("click", takeSnapshot);
|
| 1475 |
+
$("gifBtn").addEventListener("click", ()=>doExport("gif"));
|
| 1476 |
+
$("stopBtn").addEventListener("click", ()=>{ cancelExport=true; setStatus("Stopping…"); });
|
| 1477 |
+
|
| 1478 |
+
async function doExport(mode){
|
| 1479 |
+
if(exporting || !dur) return;
|
| 1480 |
+
const isGif = mode === "gif";
|
| 1481 |
+
const t = targets();
|
| 1482 |
+
const list = exportList().filter(s => s.end - s.start >= 0.05);
|
| 1483 |
+
if(!list.length){ setStatus("Selection is too short."); return; }
|
| 1484 |
+
const multi = list.length > 1;
|
| 1485 |
+
const mime = isGif ? "image/gif" : pickMime();
|
| 1486 |
+
if(!mime){ setStatus("This browser can't record video. Try Chrome or Edge."); return; }
|
| 1487 |
+
|
| 1488 |
+
if(isGif){ // GIFs balloon quickly — say so before spending the time
|
| 1489 |
+
const gfps = Math.min(20, t.f);
|
| 1490 |
+
const frames = Math.round(list.reduce((x,sg)=>x+(sg.end-sg.start),0) * gfps);
|
| 1491 |
+
if(t.w*t.h*frames > 90e6 &&
|
| 1492 |
+
!confirm("That's "+frames+" frames at "+t.w+"×"+t.h+", which will make a very large GIF and take a while.\n\nA smaller width or fewer frames per second would help.\n\nCarry on anyway?")) return;
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
// several files → put them in a folder of your choosing (must happen on the click)
|
| 1496 |
+
let dirHandle = null;
|
| 1497 |
+
if(multi && window.showDirectoryPicker){
|
| 1498 |
+
try{
|
| 1499 |
+
dirHandle = await window.showDirectoryPicker({id:"nugget-clips", mode:"readwrite", startIn:"videos"});
|
| 1500 |
+
}catch(e){
|
| 1501 |
+
if(e && e.name === "AbortError"){ setStatus("Export cancelled — no folder chosen."); return; }
|
| 1502 |
+
dirHandle = null; // not supported here; fall back to downloads
|
| 1503 |
+
}
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
exporting=true; cancelExport=false;
|
| 1507 |
+
clearResult(); clearSaved();
|
| 1508 |
+
$("exportBtn").disabled=true; $("gifBtn").disabled=true; $("snapBtn").disabled=true;
|
| 1509 |
+
$("exportBtn").textContent = isGif ? "Building GIF…" : "Exporting…";
|
| 1510 |
+
$("stopBtn").style.display="block";
|
| 1511 |
+
$("expNote").style.display="block";
|
| 1512 |
+
$("bar").style.display="block"; $("barI").style.width="0%";
|
| 1513 |
+
V.pause();
|
| 1514 |
+
|
| 1515 |
+
const cv=$("exportCanvas"); cv.width=t.w; cv.height=t.h;
|
| 1516 |
+
const ctx=cv.getContext("2d",{alpha:false});
|
| 1517 |
+
ctx.imageSmoothingEnabled=true; ctx.imageSmoothingQuality="high";
|
| 1518 |
+
ctx.fillStyle="#000"; ctx.fillRect(0,0,t.w,t.h);
|
| 1519 |
+
V.style.display="none"; cv.style.display="block"; $("cropLayer").style.display="none";
|
| 1520 |
+
|
| 1521 |
+
setupAudio();
|
| 1522 |
+
if(audioCtx && audioCtx.state==="suspended"){ try{ await audioCtx.resume(); }catch(e){} }
|
| 1523 |
+
if(monitorGain) monitorGain.gain.value = 0;
|
| 1524 |
+
keepAwake(true);
|
| 1525 |
+
|
| 1526 |
+
const total = list.reduce((a,s)=>a+(s.end-s.start),0);
|
| 1527 |
+
let doneLen = 0, saved = [], slow = false, cut = false, failed = 0;
|
| 1528 |
+
|
| 1529 |
+
try{
|
| 1530 |
+
for(let i=0;i<list.length;i++){
|
| 1531 |
+
if(cancelExport) break;
|
| 1532 |
+
const s = list[i];
|
| 1533 |
+
const len = s.end - s.start;
|
| 1534 |
+
const label = multi ? "Clip "+(i+1)+" of "+list.length+" · " : "";
|
| 1535 |
+
|
| 1536 |
+
const onProg = (p, left)=>{
|
| 1537 |
+
const overall = (doneLen + p*len)/total;
|
| 1538 |
+
$("barI").style.width=(overall*100).toFixed(1)+"%";
|
| 1539 |
+
setStatus(label+(isGif?"building GIF ":"recording ")+Math.round(p*100)+"% · about "+Math.ceil(left)+"s left");
|
| 1540 |
+
};
|
| 1541 |
+
const r = isGif ? await renderGif(s.start, s.end, t, ctx, cv, onProg)
|
| 1542 |
+
: await recordRange(s.start, s.end, t, mime, ctx, cv, onProg);
|
| 1543 |
+
doneLen += len;
|
| 1544 |
+
lastExt = r.ext;
|
| 1545 |
+
if(r.stalled) cut = true;
|
| 1546 |
+
if(!r.stalled && r.speed < 0.9) slow = true;
|
| 1547 |
+
|
| 1548 |
+
if(r.aborted) break; // stopped part-way through this clip — discard it
|
| 1549 |
+
if(!r.blob.size){ failed++; continue; }
|
| 1550 |
+
|
| 1551 |
+
const fname = clipFileName(i, s, t, multi);
|
| 1552 |
+
if(dirHandle){
|
| 1553 |
+
setStatus(label+"saving…");
|
| 1554 |
+
try{
|
| 1555 |
+
const fh = await dirHandle.getFileHandle(fname, {create:true});
|
| 1556 |
+
const ws = await fh.createWritable();
|
| 1557 |
+
await ws.write(r.blob); await ws.close();
|
| 1558 |
+
saved.push(fname+" ("+bytes(r.blob.size)+")");
|
| 1559 |
+
}catch(e){ failed++; saved.push(fname+" — could not be written: "+e.message); }
|
| 1560 |
+
}else if(multi){
|
| 1561 |
+
pushDownload(r.blob, fname);
|
| 1562 |
+
saved.push(fname+" ("+bytes(r.blob.size)+")");
|
| 1563 |
+
await new Promise(r2=>setTimeout(r2,400)); // let the browser keep up
|
| 1564 |
+
}else{
|
| 1565 |
+
lastBlob = r.blob;
|
| 1566 |
+
lastMeta = {w:t.w, h:t.h, f:t.f, at:s.start, name:s.name};
|
| 1567 |
+
const url = URL.createObjectURL(lastBlob);
|
| 1568 |
+
if(isGif){ $("outImg").src=url; $("outImg").style.display="block"; $("outVid").style.display="none"; }
|
| 1569 |
+
else { $("outVid").src=url; $("outVid").style.display="block"; $("outImg").style.display="none"; }
|
| 1570 |
+
$("result").style.display="block";
|
| 1571 |
+
const n = download();
|
| 1572 |
+
saved.push((n||fname)+" ("+bytes(r.blob.size)+")");
|
| 1573 |
+
}
|
| 1574 |
+
}
|
| 1575 |
+
}catch(err){
|
| 1576 |
+
setStatus(err.message);
|
| 1577 |
+
}finally{
|
| 1578 |
+
exporting=false;
|
| 1579 |
+
if(monitorGain) monitorGain.gain.value=1;
|
| 1580 |
+
cv.style.display="none"; V.style.display="block";
|
| 1581 |
+
if(crop){ showCrop(true); layoutCrop(); }
|
| 1582 |
+
$("exportBtn").disabled=false; $("gifBtn").disabled=false; $("snapBtn").disabled=false; $("stopBtn").style.display="none";
|
| 1583 |
+
$("expNote").style.display="none";
|
| 1584 |
+
$("bar").style.display="none";
|
| 1585 |
+
keepAwake(false);
|
| 1586 |
+
V.currentTime=inT;
|
| 1587 |
+
estimate();
|
| 1588 |
+
}
|
| 1589 |
+
|
| 1590 |
+
// ---- report ----
|
| 1591 |
+
const notes=[];
|
| 1592 |
+
if(lastExt==="webm" && !isGif) notes.push("Saved as .webm — this browser can't record MP4. Plays in Chrome, Edge, Firefox and VLC.");
|
| 1593 |
+
if(cut) notes.push("Playback stalled on at least one clip, so it may be cut short.");
|
| 1594 |
+
if(slow && !isGif) notes.push("Your computer couldn't play the video at full speed, so clips may run slightly slow. Try a smaller output size or lower quality.");
|
| 1595 |
+
if(failed) notes.push(failed+" clip(s) could not be saved.");
|
| 1596 |
+
|
| 1597 |
+
if(multi){
|
| 1598 |
+
$("saved").style.display = saved.length ? "block" : "none";
|
| 1599 |
+
$("savedWhere").textContent = cancelExport
|
| 1600 |
+
? "Stopped — "+saved.length+" of "+list.length+" saved"
|
| 1601 |
+
: "Saved "+saved.length+" clip"+(saved.length===1?"":"s")+(dirHandle? " to “"+dirHandle.name+"”" : " to your Downloads folder");
|
| 1602 |
+
$("savedList").innerHTML="";
|
| 1603 |
+
saved.forEach(n=>{ const li=document.createElement("li"); li.textContent=n; $("savedList").appendChild(li); });
|
| 1604 |
+
if(!dirHandle && saved.length>1) notes.push("Chrome asks permission the first time a page saves several files — choose Allow if you see that prompt.");
|
| 1605 |
+
$("savedNote").textContent = notes.join(" ");
|
| 1606 |
+
setStatus(cancelExport ? "Stopped." : "Done — "+saved.length+" clip"+(saved.length===1?"":"s")+".");
|
| 1607 |
+
}else{
|
| 1608 |
+
$("outNote").textContent = notes.join(" ");
|
| 1609 |
+
setStatus(cancelExport ? "Stopped." : (saved[0] ? "Saved "+saved[0] : "Nothing was saved."));
|
| 1610 |
+
}
|
| 1611 |
+
}
|
| 1612 |
+
|
| 1613 |
+
function clipFileName(i, s, t, multi){
|
| 1614 |
+
const base = safeName(srcName,60) || "clip";
|
| 1615 |
+
if(lastExt==="gif"){
|
| 1616 |
+
const lbl0 = safeName(s.name,40);
|
| 1617 |
+
return base+"_"+(multi? String(i+1).padStart(2,"0")+"_" : "")+(lbl0 || stamp(s.start))+"_"+t.w+"px.gif";
|
| 1618 |
+
}
|
| 1619 |
+
if(!multi) return base+"_"+stamp(s.start)+"_"+t.w+"x"+t.h+"_"+t.f+"fps."+lastExt;
|
| 1620 |
+
const lbl = safeName(s.name,40);
|
| 1621 |
+
return base+"_"+String(i+1).padStart(2,"0")+"_"+(lbl || stamp(s.start))+"."+lastExt;
|
| 1622 |
+
}
|
| 1623 |
+
function pushDownload(blob, name){
|
| 1624 |
+
try{
|
| 1625 |
+
const a=document.createElement("a");
|
| 1626 |
+
a.href=URL.createObjectURL(blob); a.download=name; a.style.display="none";
|
| 1627 |
+
document.body.appendChild(a); a.click();
|
| 1628 |
+
setTimeout(()=>{ URL.revokeObjectURL(a.href); a.remove(); }, 12000);
|
| 1629 |
+
return name;
|
| 1630 |
+
}catch(e){ return null; }
|
| 1631 |
+
}
|
| 1632 |
+
function download(){
|
| 1633 |
+
if(!lastBlob || !lastBlob.size) return null;
|
| 1634 |
+
const m = lastMeta || Object.assign(targets(), {at:inT, name:""});
|
| 1635 |
+
const base = safeName(srcName,60) || "clip";
|
| 1636 |
+
const lbl = safeName(m.name,40);
|
| 1637 |
+
const name = lastExt==="gif"
|
| 1638 |
+
? base+"_"+(lbl || stamp(m.at))+"_"+m.w+"px.gif"
|
| 1639 |
+
: base+"_"+(lbl || stamp(m.at))+"_"+m.w+"x"+m.h+"_"+m.f+"fps."+lastExt;
|
| 1640 |
+
return pushDownload(lastBlob, name);
|
| 1641 |
+
}
|
| 1642 |
+
$("dlBtn").addEventListener("click", ()=>{ download(); });
|
| 1643 |
+
$("clearRes").addEventListener("click", clearResult);
|
| 1644 |
+
function clearResult(){
|
| 1645 |
+
lastBlob=null; lastMeta=null; $("result").style.display="none";
|
| 1646 |
+
const ov=$("outVid"); if(ov.src){ URL.revokeObjectURL(ov.src); ov.removeAttribute("src"); }
|
| 1647 |
+
const oi=$("outImg"); if(oi.src){ URL.revokeObjectURL(oi.src); oi.removeAttribute("src"); }
|
| 1648 |
+
setStatus("");
|
| 1649 |
+
}
|
| 1650 |
+
function clearSaved(){ $("saved").style.display="none"; $("savedList").innerHTML=""; $("savedNote").textContent=""; }
|
| 1651 |
+
function setStatus(s){ $("status").textContent=s; }
|
| 1652 |
+
|
| 1653 |
+
window.__vt = {
|
| 1654 |
+
get state(){ return {ready:$("app").style.display==="block",loads:loadSeq,dur,inT,outT,natW,natH,srcFps,
|
| 1655 |
+
exporting,size:lastBlob&&lastBlob.size,ext:lastExt,
|
| 1656 |
+
clips:segments.map(s=>({start:+s.start.toFixed(3),end:+s.end.toFixed(3),name:s.name}))}; },
|
| 1657 |
+
load, doExport, addClip,
|
| 1658 |
+
gif(){ return doExport("gif"); },
|
| 1659 |
+
set(a,b){ inT=a; outT=b; refresh(); },
|
| 1660 |
+
setCrop(x,y,w,h){ setCrop(x,y,w,h); showCrop(true); cropChanged(true); },
|
| 1661 |
+
getCrop(){ return crop && {...crop}; },
|
| 1662 |
+
setAR(v,l){ applyAR(v, l||String(v)); },
|
| 1663 |
+
arLabel(){ return cropARLabel; },
|
| 1664 |
+
setName(i,n){ segments[i].name=n; refresh(); },
|
| 1665 |
+
clearClips(){ segments=[]; activeSeg=-1; refresh(); }
|
| 1666 |
+
};
|
| 1667 |
+
})();
|
| 1668 |
+
</script>
|
| 1669 |
+
</body>
|
| 1670 |
+
</html>
|