Merge pull request #176 from Brodypen/feature/speed-option
feat: Add speed option
This commit is contained in:
Generated
+42
-36
@@ -113,7 +113,6 @@
|
||||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -342,7 +341,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -1317,6 +1315,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1338,6 +1337,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1354,6 +1354,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1368,6 +1369,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -2051,7 +2053,6 @@
|
||||
"integrity": "sha512-LTATglVUPGkPf15zX1wTMlZ0+AU7cGEGF6ekVF1crA8eHUWsGjrYTB+Ht4E3HTrCok8weQG+K01rJndCp/l4XA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/core": "^0.16.13"
|
||||
@@ -2094,7 +2095,6 @@
|
||||
"integrity": "sha512-8Z1k96ZFxlhK2bgrY1JNWNwvaBeI/bciLM0yDOni2+aZwfIIiC7Y6PeWHTAvjHNjphz+XCt01WQmOYWCn0ML6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2109,7 +2109,6 @@
|
||||
"integrity": "sha512-PvLrfa8vkej3qinlebyhLpksJgCF5aiysDMSVhOZqwH5nQLLtDE9WYbnsofGw4r0VVpyw3H/ANCIzYTyCtP9Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2138,7 +2137,6 @@
|
||||
"integrity": "sha512-xW+9BtEvoIkkH/Wde9ql4nAFbYLkVINhpgAE7VcBUsuuB34WUbcBl/taOuUYQrPEFQJ4jfXiAJZ2H/rvKjCVnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13",
|
||||
@@ -2188,7 +2186,6 @@
|
||||
"integrity": "sha512-WEl2tPVYwzYL8OKme6Go2xqiWgKsgxlMwyHabdAU4tXaRwOCnOI7v4021gCcBb9zn/oWwguHuKHmK30Fw2Z/PA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2332,7 +2329,6 @@
|
||||
"integrity": "sha512-qoqtN8LDknm3fJm9nuPygJv30O3vGhSBD2TxrsCnhtOsxKAqVPJtFVdGd/qVuZ8nqQANQmTlfqTiK9mVWQ7MiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2347,7 +2343,6 @@
|
||||
"integrity": "sha512-Ev+Jjmj1nHYw897z9C3R9dYsPv7S2/nxdgfFb/h8hOwK0Ovd1k/+yYS46A0uj/JCKK0pQk8wOslYBkPwdnLorw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2365,7 +2360,6 @@
|
||||
"integrity": "sha512-05POQaEJVucjTiSGMoH68ZiELc7QqpIpuQlZ2JBbhCV+WCbPFUBcGSmE7w4Jd0E2GvCho/NoMODLwgcVGQA97A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.7.2",
|
||||
"@jimp/utils": "^0.16.13"
|
||||
@@ -2792,6 +2786,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz",
|
||||
"integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6"
|
||||
}
|
||||
@@ -2806,7 +2801,8 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz",
|
||||
"integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/core": {
|
||||
"version": "7.4.3",
|
||||
@@ -2833,7 +2829,8 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz",
|
||||
"integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/filter-drop-shadow": {
|
||||
"version": "5.2.0",
|
||||
@@ -2860,19 +2857,22 @@
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz",
|
||||
"integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/runner": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz",
|
||||
"integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/settings": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz",
|
||||
"integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/constants": "7.4.3",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
@@ -2884,6 +2884,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz",
|
||||
"integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/extensions": "7.4.3",
|
||||
"@pixi/settings": "7.4.3",
|
||||
@@ -2895,6 +2896,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz",
|
||||
"integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/color": "7.4.3",
|
||||
"@pixi/constants": "7.4.3",
|
||||
@@ -2909,19 +2911,22 @@
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
|
||||
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/utils/node_modules/earcut": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||
"license": "ISC"
|
||||
"license": "ISC",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pixi/utils/node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
@@ -4402,7 +4407,6 @@
|
||||
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -4414,7 +4418,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -4723,7 +4726,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -5528,7 +5530,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.9",
|
||||
"caniuse-lite": "^1.0.30001746",
|
||||
@@ -5841,6 +5842,7 @@
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
@@ -6283,7 +6285,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
@@ -6579,7 +6582,6 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7006,6 +7008,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7026,6 +7029,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -8666,7 +8670,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -9825,6 +9828,7 @@
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -10414,7 +10418,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -10559,6 +10562,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10576,6 +10580,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10724,6 +10729,7 @@
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@@ -10782,7 +10788,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -10795,7 +10800,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -11579,6 +11583,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
@@ -11598,6 +11603,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
@@ -11614,6 +11620,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -11632,6 +11639,7 @@
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
@@ -12269,7 +12277,6 @@
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.18.tgz",
|
||||
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -12342,6 +12349,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -12405,6 +12413,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -12419,6 +12428,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -12432,7 +12442,6 @@
|
||||
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
@@ -12585,7 +12594,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12819,6 +12827,7 @@
|
||||
"resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz",
|
||||
"integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"punycode": "^1.4.1",
|
||||
"qs": "^6.12.3"
|
||||
@@ -12831,7 +12840,8 @@
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
|
||||
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/use-callback-ref": {
|
||||
"version": "1.3.3",
|
||||
@@ -12945,7 +12955,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -13020,8 +13029,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
|
||||
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.0.16",
|
||||
@@ -13585,7 +13593,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -13599,7 +13606,6 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useState } from "react";
|
||||
import Block from '@uiw/react-color-block';
|
||||
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
|
||||
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
import { CropControl } from "./CropControl";
|
||||
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
|
||||
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
|
||||
@@ -90,6 +91,10 @@ interface SettingsPanelProps {
|
||||
onAnnotationStyleChange?: (id: string, style: Partial<AnnotationRegion['style']>) => void;
|
||||
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
selectedSpeedValue?: PlaybackSpeed | null;
|
||||
onSpeedChange?: (speed: PlaybackSpeed) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -145,6 +150,10 @@ export function SettingsPanel({
|
||||
onAnnotationStyleChange,
|
||||
onAnnotationFigureDataChange,
|
||||
onAnnotationDelete,
|
||||
selectedSpeedId,
|
||||
selectedSpeedValue,
|
||||
onSpeedChange,
|
||||
onSpeedDelete,
|
||||
}: SettingsPanelProps) {
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
const [customImages, setCustomImages] = useState<string[]>([]);
|
||||
@@ -321,6 +330,54 @@ export function SettingsPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-slate-200">Playback Speed</span>
|
||||
{selectedSpeedId && selectedSpeedValue && (
|
||||
<span className="text-[10px] uppercase tracking-wider font-medium text-[#d97706] bg-[#d97706]/10 px-2 py-0.5 rounded-full">
|
||||
{SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{SPEED_OPTIONS.map((option) => {
|
||||
const isActive = selectedSpeedValue === option.speed;
|
||||
return (
|
||||
<Button
|
||||
key={option.speed}
|
||||
type="button"
|
||||
disabled={!selectedSpeedId}
|
||||
onClick={() => onSpeedChange?.(option.speed)}
|
||||
className={cn(
|
||||
"h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
|
||||
"duration-200 ease-out",
|
||||
selectedSpeedId ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
|
||||
isActive
|
||||
? "border-[#d97706] bg-[#d97706] text-white shadow-[#d97706]/20"
|
||||
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-semibold">{option.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!selectedSpeedId && (
|
||||
<p className="text-[10px] text-slate-500 mt-2 text-center">Select a speed region to adjust</p>
|
||||
)}
|
||||
{selectedSpeedId && (
|
||||
<Button
|
||||
onClick={() => selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete Speed Region
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion type="multiple" defaultValue={["effects", "background"]} className="space-y-1">
|
||||
<AccordionItem value="effects" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
|
||||
<AccordionTrigger className="py-2.5 hover:no-underline">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
type FigureData,
|
||||
type SpeedRegion,
|
||||
type PlaybackSpeed,
|
||||
} from "./types";
|
||||
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
|
||||
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
|
||||
@@ -57,6 +60,8 @@ export default function VideoEditor() {
|
||||
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
|
||||
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
|
||||
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
|
||||
const [speedRegions, setSpeedRegions] = useState<SpeedRegion[]>([]);
|
||||
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
|
||||
const [annotationRegions, setAnnotationRegions] = useState<AnnotationRegion[]>([]);
|
||||
const [selectedAnnotationId, setSelectedAnnotationId] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
@@ -73,6 +78,7 @@ export default function VideoEditor() {
|
||||
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
|
||||
const nextZoomIdRef = useRef(1);
|
||||
const nextTrimIdRef = useRef(1);
|
||||
const nextSpeedIdRef = useRef(1);
|
||||
|
||||
const { shortcuts, isMac } = useShortcuts();
|
||||
const nextAnnotationIdRef = useRef(1);
|
||||
@@ -328,6 +334,60 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedTrimId]);
|
||||
|
||||
const handleSelectSpeed = useCallback((id: string | null) => {
|
||||
setSelectedSpeedId(id);
|
||||
if (id) {
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSpeedAdded = useCallback((span: Span) => {
|
||||
const id = `speed-${nextSpeedIdRef.current++}`;
|
||||
const newRegion: SpeedRegion = {
|
||||
id,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
speed: DEFAULT_PLAYBACK_SPEED,
|
||||
};
|
||||
setSpeedRegions((prev) => [...prev, newRegion]);
|
||||
setSelectedSpeedId(id);
|
||||
setSelectedZoomId(null);
|
||||
setSelectedTrimId(null);
|
||||
setSelectedAnnotationId(null);
|
||||
}, []);
|
||||
|
||||
const handleSpeedSpanChange = useCallback((id: string, span: Span) => {
|
||||
setSpeedRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === id
|
||||
? {
|
||||
...region,
|
||||
startMs: Math.round(span.start),
|
||||
endMs: Math.round(span.end),
|
||||
}
|
||||
: region,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSpeedDelete = useCallback((id: string) => {
|
||||
setSpeedRegions((prev) => prev.filter((region) => region.id !== id));
|
||||
if (selectedSpeedId === id) {
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, [selectedSpeedId]);
|
||||
|
||||
const handleSpeedChange = useCallback((speed: PlaybackSpeed) => {
|
||||
if (!selectedSpeedId) return;
|
||||
setSpeedRegions((prev) =>
|
||||
prev.map((region) =>
|
||||
region.id === selectedSpeedId ? { ...region, speed } : region,
|
||||
),
|
||||
);
|
||||
}, [selectedSpeedId]);
|
||||
|
||||
const handleAnnotationAdded = useCallback((span: Span) => {
|
||||
const id = `annotation-${nextAnnotationIdRef.current++}`;
|
||||
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
|
||||
@@ -503,6 +563,12 @@ export default function VideoEditor() {
|
||||
}
|
||||
}, [selectedAnnotationId, annotationRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
|
||||
setSelectedSpeedId(null);
|
||||
}
|
||||
}, [selectedSpeedId, speedRegions]);
|
||||
|
||||
const handleExport = useCallback(async (settings: ExportSettings) => {
|
||||
if (!videoPath) {
|
||||
toast.error('No video loaded');
|
||||
@@ -547,6 +613,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -673,6 +740,7 @@ export default function VideoEditor() {
|
||||
wallpaper,
|
||||
zoomRegions,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
showShadow: shadowIntensity > 0,
|
||||
shadowIntensity,
|
||||
showBlur,
|
||||
@@ -728,7 +796,7 @@ export default function VideoEditor() {
|
||||
setShowExportDialog(false);
|
||||
setExportProgress(null);
|
||||
}
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
}, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
|
||||
|
||||
const handleOpenExportDialog = useCallback(() => {
|
||||
if (!videoPath) {
|
||||
@@ -835,6 +903,7 @@ export default function VideoEditor() {
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationRegions}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
onSelectAnnotation={handleSelectAnnotation}
|
||||
@@ -883,6 +952,12 @@ export default function VideoEditor() {
|
||||
onTrimDelete={handleTrimDelete}
|
||||
selectedTrimId={selectedTrimId}
|
||||
onSelectTrim={handleSelectTrim}
|
||||
speedRegions={speedRegions}
|
||||
onSpeedAdded={handleSpeedAdded}
|
||||
onSpeedSpanChange={handleSpeedSpanChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
onSelectSpeed={handleSelectSpeed}
|
||||
annotationRegions={annotationRegions}
|
||||
onAnnotationAdded={handleAnnotationAdded}
|
||||
onAnnotationSpanChange={handleAnnotationSpanChange}
|
||||
@@ -945,6 +1020,10 @@ export default function VideoEditor() {
|
||||
onAnnotationStyleChange={handleAnnotationStyleChange}
|
||||
onAnnotationFigureDataChange={handleAnnotationFigureDataChange}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
selectedSpeedValue={selectedSpeedId ? speedRegions.find(r => r.id === selectedSpeedId)?.speed ?? null : null}
|
||||
onSpeedChange={handleSpeedChange}
|
||||
onSpeedDelete={handleSpeedDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type React from "react";
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
|
||||
import { getAssetPath } from "@/lib/assetPath";
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
|
||||
import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type SpeedRegion, type AnnotationRegion } from "./types";
|
||||
import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
|
||||
@@ -35,6 +35,7 @@ interface VideoPlaybackProps {
|
||||
padding?: number;
|
||||
cropRegion?: import('./types').CropRegion;
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
selectedAnnotationId?: string | null;
|
||||
@@ -74,6 +75,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
padding = 50,
|
||||
cropRegion,
|
||||
trimRegions = [],
|
||||
speedRegions = [],
|
||||
aspectRatio,
|
||||
annotationRegions = [],
|
||||
selectedAnnotationId,
|
||||
@@ -111,6 +113,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
|
||||
const layoutVideoContentRef = useRef<(() => void) | null>(null);
|
||||
const trimRegionsRef = useRef<TrimRegion[]>([]);
|
||||
const speedRegionsRef = useRef<SpeedRegion[]>([]);
|
||||
const motionBlurEnabledRef = useRef(motionBlurEnabled);
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
|
||||
@@ -319,6 +322,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
trimRegionsRef.current = trimRegions;
|
||||
}, [trimRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
speedRegionsRef.current = speedRegions;
|
||||
}, [speedRegions]);
|
||||
|
||||
useEffect(() => {
|
||||
motionBlurEnabledRef.current = motionBlurEnabled;
|
||||
}, [motionBlurEnabled]);
|
||||
@@ -557,6 +564,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(({
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
});
|
||||
|
||||
video.addEventListener('play', handlePlay);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from "react";
|
||||
import { useItem } from "dnd-timeline";
|
||||
import type { Span } from "dnd-timeline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
|
||||
import { ZoomIn, Scissors, MessageSquare, Gauge } from "lucide-react";
|
||||
import glassStyles from "./ItemGlass.module.css";
|
||||
|
||||
interface ItemProps {
|
||||
@@ -13,7 +13,8 @@ interface ItemProps {
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
zoomDepth?: number;
|
||||
variant?: 'zoom' | 'trim' | 'annotation';
|
||||
speedValue?: number;
|
||||
variant?: 'zoom' | 'trim' | 'annotation' | 'speed';
|
||||
}
|
||||
|
||||
// Map zoom depth to multiplier labels
|
||||
@@ -36,13 +37,14 @@ function formatMs(ms: number): string {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
export default function Item({
|
||||
id,
|
||||
span,
|
||||
rowId,
|
||||
isSelected = false,
|
||||
onSelect,
|
||||
zoomDepth = 1,
|
||||
speedValue,
|
||||
variant = 'zoom',
|
||||
children
|
||||
}: ItemProps) {
|
||||
@@ -54,17 +56,22 @@ export default function Item({
|
||||
|
||||
const isZoom = variant === 'zoom';
|
||||
const isTrim = variant === 'trim';
|
||||
const isSpeed = variant === 'speed';
|
||||
|
||||
const glassClass = isZoom
|
||||
? glassStyles.glassGreen
|
||||
: isTrim
|
||||
? glassStyles.glassRed
|
||||
: isSpeed
|
||||
? glassStyles.glassAmber
|
||||
: glassStyles.glassYellow;
|
||||
|
||||
const endCapColor = isZoom
|
||||
? '#21916A'
|
||||
: isTrim
|
||||
? '#ef4444'
|
||||
: isSpeed
|
||||
? '#d97706'
|
||||
: '#B4A046';
|
||||
|
||||
const timeLabel = useMemo(
|
||||
@@ -121,6 +128,13 @@ export default function Item({
|
||||
Trim
|
||||
</span>
|
||||
</>
|
||||
) : isSpeed ? (
|
||||
<>
|
||||
<Gauge className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="text-[11px] font-semibold tracking-tight whitespace-nowrap">
|
||||
{speedValue !== undefined ? `${speedValue}×` : 'Speed'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="w-3.5 h-3.5 shrink-0" />
|
||||
|
||||
@@ -76,6 +76,32 @@
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.glassAmber {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
-corner-smoothing: antialiased;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
|
||||
margin: 2px 0;
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.glassAmber:hover {
|
||||
background: rgba(245, 158, 11, 0.25);
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
|
||||
}
|
||||
|
||||
.glassAmber.selected {
|
||||
background: rgba(245, 158, 11, 0.35);
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 1px #f59e0b, 0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoomEndCap {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -92,7 +118,9 @@
|
||||
.glassRed:hover .zoomEndCap,
|
||||
.glassRed.selected .zoomEndCap,
|
||||
.glassYellow:hover .zoomEndCap,
|
||||
.glassYellow.selected .zoomEndCap {
|
||||
.glassYellow.selected .zoomEndCap,
|
||||
.glassAmber:hover .zoomEndCap,
|
||||
.glassAmber.selected .zoomEndCap {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTimelineContext } from "dnd-timeline";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, WandSparkles } from "lucide-react";
|
||||
import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TimelineWrapper from "./TimelineWrapper";
|
||||
@@ -9,7 +9,7 @@ import Row from "./Row";
|
||||
import Item from "./Item";
|
||||
import KeyframeMarkers from "./KeyframeMarkers";
|
||||
import type { Range, Span } from "dnd-timeline";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -27,6 +27,7 @@ import { detectZoomDwellCandidates, normalizeCursorTelemetry } from "./zoomSugge
|
||||
const ZOOM_ROW_ID = "row-zoom";
|
||||
const TRIM_ROW_ID = "row-trim";
|
||||
const ANNOTATION_ROW_ID = "row-annotation";
|
||||
const SPEED_ROW_ID = "row-speed";
|
||||
const FALLBACK_RANGE_MS = 1000;
|
||||
const TARGET_MARKER_COUNT = 12;
|
||||
const SUGGESTION_SPACING_MS = 1800;
|
||||
@@ -55,6 +56,12 @@ interface TimelineEditorProps {
|
||||
onAnnotationDelete?: (id: string) => void;
|
||||
selectedAnnotationId?: string | null;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
speedRegions?: SpeedRegion[];
|
||||
onSpeedAdded?: (span: Span) => void;
|
||||
onSpeedSpanChange?: (id: string, span: Span) => void;
|
||||
onSpeedDelete?: (id: string) => void;
|
||||
selectedSpeedId?: string | null;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
aspectRatio: AspectRatio;
|
||||
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
|
||||
}
|
||||
@@ -73,7 +80,8 @@ interface TimelineRenderItem {
|
||||
span: Span;
|
||||
label: string;
|
||||
zoomDepth?: number;
|
||||
variant: 'zoom' | 'trim' | 'annotation';
|
||||
speedValue?: number;
|
||||
variant: 'zoom' | 'trim' | 'annotation' | 'speed';
|
||||
}
|
||||
|
||||
const SCALE_CANDIDATES = [
|
||||
@@ -402,9 +410,11 @@ function Timeline({
|
||||
onSelectZoom,
|
||||
onSelectTrim,
|
||||
onSelectAnnotation,
|
||||
onSelectSpeed,
|
||||
selectedZoomId,
|
||||
selectedTrimId,
|
||||
selectedAnnotationId,
|
||||
selectedSpeedId,
|
||||
keyframes = [],
|
||||
}: {
|
||||
items: TimelineRenderItem[];
|
||||
@@ -415,9 +425,11 @@ function Timeline({
|
||||
onSelectZoom?: (id: string | null) => void;
|
||||
onSelectTrim?: (id: string | null) => void;
|
||||
onSelectAnnotation?: (id: string | null) => void;
|
||||
onSelectSpeed?: (id: string | null) => void;
|
||||
selectedZoomId: string | null;
|
||||
selectedTrimId?: string | null;
|
||||
selectedAnnotationId?: string | null;
|
||||
selectedSpeedId?: string | null;
|
||||
keyframes?: { id: string; time: number }[];
|
||||
}) {
|
||||
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
|
||||
@@ -436,6 +448,7 @@ function Timeline({
|
||||
onSelectZoom?.(null);
|
||||
onSelectTrim?.(null);
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectSpeed?.(null);
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left - sidebarWidth;
|
||||
@@ -447,11 +460,12 @@ function Timeline({
|
||||
const timeInSeconds = absoluteMs / 1000;
|
||||
|
||||
onSeek(timeInSeconds);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
}, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
|
||||
|
||||
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
|
||||
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
|
||||
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
|
||||
const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -518,6 +532,23 @@ function Timeline({
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Row id={SPEED_ROW_ID} isEmpty={speedItems.length === 0} hint="Press S to add speed">
|
||||
{speedItems.map((item) => (
|
||||
<Item
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
rowId={item.rowId}
|
||||
span={item.span}
|
||||
isSelected={item.id === selectedSpeedId}
|
||||
onSelect={() => onSelectSpeed?.(item.id)}
|
||||
variant="speed"
|
||||
speedValue={item.speedValue}
|
||||
>
|
||||
{item.label}
|
||||
</Item>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -546,6 +577,12 @@ export default function TimelineEditor({
|
||||
onAnnotationDelete,
|
||||
selectedAnnotationId,
|
||||
onSelectAnnotation,
|
||||
speedRegions = [],
|
||||
onSpeedAdded,
|
||||
onSpeedSpanChange,
|
||||
onSpeedDelete,
|
||||
selectedSpeedId,
|
||||
onSelectSpeed,
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
@@ -615,6 +652,12 @@ export default function TimelineEditor({
|
||||
onSelectAnnotation(null);
|
||||
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
|
||||
|
||||
const deleteSelectedSpeed = useCallback(() => {
|
||||
if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
|
||||
onSpeedDelete(selectedSpeedId);
|
||||
onSelectSpeed(null);
|
||||
}, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
|
||||
|
||||
useEffect(() => {
|
||||
setRange(createInitialRange(totalMs));
|
||||
}, [totalMs]);
|
||||
@@ -624,8 +667,10 @@ export default function TimelineEditor({
|
||||
// this effect on every drag/resize and races with dnd-timeline's internal state.
|
||||
const zoomRegionsRef = useRef(zoomRegions);
|
||||
const trimRegionsRef = useRef(trimRegions);
|
||||
const speedRegionsRef = useRef(speedRegions);
|
||||
zoomRegionsRef.current = zoomRegions;
|
||||
trimRegionsRef.current = trimRegions;
|
||||
speedRegionsRef.current = speedRegions;
|
||||
|
||||
useEffect(() => {
|
||||
if (totalMs === 0 || safeMinDurationMs <= 0) {
|
||||
@@ -655,21 +700,34 @@ export default function TimelineEditor({
|
||||
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
}
|
||||
});
|
||||
|
||||
speedRegionsRef.current.forEach((region) => {
|
||||
const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
|
||||
const minEnd = clampedStart + safeMinDurationMs;
|
||||
const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
|
||||
const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
|
||||
const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
|
||||
|
||||
if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
|
||||
onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
|
||||
}
|
||||
});
|
||||
// Only re-run when the timeline scale changes, not on every region edit
|
||||
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
|
||||
}, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]);
|
||||
|
||||
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
|
||||
// Determine which row the item belongs to
|
||||
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
|
||||
const isTrimItem = trimRegions.some(r => r.id === excludeId);
|
||||
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
|
||||
const isSpeedItem = speedRegions.some(r => r.id === excludeId);
|
||||
|
||||
if (isAnnotationItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to check overlap against a specific set of regions
|
||||
const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
|
||||
const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => {
|
||||
return regions.some((region) => {
|
||||
if (region.id === excludeId) return false;
|
||||
// True overlap: regions actually intersect (not just adjacent)
|
||||
@@ -685,8 +743,12 @@ export default function TimelineEditor({
|
||||
return checkOverlap(trimRegions);
|
||||
}
|
||||
|
||||
if (isSpeedItem) {
|
||||
return checkOverlap(speedRegions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
|
||||
// At least 5% of the timeline or 1000ms, whichever is larger, so the region
|
||||
// is always wide enough to grab and resize comfortably.
|
||||
@@ -840,6 +902,36 @@ export default function TimelineEditor({
|
||||
onTrimAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddSpeed = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
|
||||
if (defaultDuration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Always place speed region at playhead
|
||||
const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
|
||||
// Find the next speed region after the playhead
|
||||
const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
const nextRegion = sorted.find(region => region.startMs > startPos);
|
||||
const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
|
||||
|
||||
// Check if playhead is inside any speed region
|
||||
const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
|
||||
if (isOverlapping || gapToNext <= 0) {
|
||||
toast.error("Cannot place speed here", {
|
||||
description: "Speed region already exists at this location or not enough space available.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
|
||||
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
|
||||
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
|
||||
|
||||
const handleAddAnnotation = useCallback(() => {
|
||||
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
|
||||
return;
|
||||
@@ -875,6 +967,9 @@ export default function TimelineEditor({
|
||||
if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
|
||||
handleAddAnnotation();
|
||||
}
|
||||
if (e.key === 's' || e.key === 'S') {
|
||||
handleAddSpeed();
|
||||
}
|
||||
|
||||
// Tab: Cycle through overlapping annotations at current time
|
||||
if (e.key === 'Tab' && annotationRegions.length > 0) {
|
||||
@@ -908,12 +1003,14 @@ export default function TimelineEditor({
|
||||
deleteSelectedTrim();
|
||||
} else if (selectedAnnotationId) {
|
||||
deleteSelectedAnnotation();
|
||||
} else if (selectedSpeedId) {
|
||||
deleteSelectedSpeed();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
|
||||
}, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
|
||||
|
||||
const clampedRange = useMemo<Range>(() => {
|
||||
if (totalMs === 0) {
|
||||
@@ -966,26 +1063,38 @@ export default function TimelineEditor({
|
||||
};
|
||||
});
|
||||
|
||||
return [...zooms, ...trims, ...annotations];
|
||||
}, [zoomRegions, trimRegions, annotationRegions]);
|
||||
const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
|
||||
id: region.id,
|
||||
rowId: SPEED_ROW_ID,
|
||||
span: { start: region.startMs, end: region.endMs },
|
||||
label: `Speed ${index + 1}`,
|
||||
speedValue: region.speed,
|
||||
variant: 'speed',
|
||||
}));
|
||||
|
||||
return [...zooms, ...trims, ...annotations, ...speeds];
|
||||
}, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
|
||||
|
||||
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
|
||||
const allRegionSpans = useMemo(() => {
|
||||
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
return [...zooms, ...trims];
|
||||
}, [zoomRegions, trimRegions]);
|
||||
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
|
||||
return [...zooms, ...trims, ...speeds];
|
||||
}, [zoomRegions, trimRegions, speedRegions]);
|
||||
|
||||
const handleItemSpanChange = useCallback((id: string, span: Span) => {
|
||||
// Check if it's a zoom or trim item
|
||||
// Check if it's a zoom, trim, speed, or annotation item
|
||||
if (zoomRegions.some(r => r.id === id)) {
|
||||
onZoomSpanChange(id, span);
|
||||
} else if (trimRegions.some(r => r.id === id)) {
|
||||
onTrimSpanChange?.(id, span);
|
||||
} else if (speedRegions.some(r => r.id === id)) {
|
||||
onSpeedSpanChange?.(id, span);
|
||||
} else if (annotationRegions.some(r => r.id === id)) {
|
||||
onAnnotationSpanChange?.(id, span);
|
||||
}
|
||||
}, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
|
||||
}, [zoomRegions, trimRegions, speedRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange]);
|
||||
|
||||
if (!videoDuration || videoDuration === 0) {
|
||||
return (
|
||||
@@ -1041,6 +1150,15 @@ export default function TimelineEditor({
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddSpeed}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-slate-400 hover:text-[#d97706] hover:bg-[#d97706]/10 transition-all"
|
||||
title="Add Speed (S)"
|
||||
>
|
||||
<Gauge className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
@@ -1115,11 +1233,12 @@ export default function TimelineEditor({
|
||||
onSelectZoom={onSelectZoom}
|
||||
onSelectTrim={onSelectTrim}
|
||||
onSelectAnnotation={onSelectAnnotation}
|
||||
onSelectSpeed={onSelectSpeed}
|
||||
selectedZoomId={selectedZoomId}
|
||||
selectedTrimId={selectedTrimId}
|
||||
selectedAnnotationId={selectedAnnotationId}
|
||||
selectedSpeedId={selectedSpeedId}
|
||||
keyframes={keyframes}
|
||||
|
||||
/>
|
||||
</TimelineWrapper>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,27 @@ export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
height: 1,
|
||||
};
|
||||
|
||||
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
|
||||
|
||||
export interface SpeedRegion {
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
speed: PlaybackSpeed;
|
||||
}
|
||||
|
||||
export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
|
||||
{ speed: 0.25, label: "0.25×" },
|
||||
{ speed: 0.5, label: "0.5×" },
|
||||
{ speed: 0.75, label: "0.75×" },
|
||||
{ speed: 1.25, label: "1.25×" },
|
||||
{ speed: 1.5, label: "1.5×" },
|
||||
{ speed: 1.75, label: "1.75×" },
|
||||
{ speed: 2, label: "2×" },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
|
||||
|
||||
export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
1: 1.25,
|
||||
2: 1.5,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type React from 'react';
|
||||
import type { TrimRegion } from '../types';
|
||||
import type { TrimRegion, SpeedRegion } from '../types';
|
||||
|
||||
interface VideoEventHandlersParams {
|
||||
video: HTMLVideoElement;
|
||||
@@ -11,6 +11,7 @@ interface VideoEventHandlersParams {
|
||||
onPlayStateChange: (playing: boolean) => void;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
trimRegionsRef: React.MutableRefObject<TrimRegion[]>;
|
||||
speedRegionsRef: React.MutableRefObject<SpeedRegion[]>;
|
||||
}
|
||||
|
||||
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
@@ -24,6 +25,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
onPlayStateChange,
|
||||
onTimeUpdate,
|
||||
trimRegionsRef,
|
||||
speedRegionsRef,
|
||||
} = params;
|
||||
|
||||
const emitTime = (timeValue: number) => {
|
||||
@@ -39,16 +41,23 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
) || null;
|
||||
};
|
||||
|
||||
// Helper function to find the active speed region at the current time
|
||||
const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
|
||||
return speedRegionsRef.current.find(
|
||||
(region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
|
||||
) || null;
|
||||
};
|
||||
|
||||
function updateTime() {
|
||||
if (!video) return;
|
||||
|
||||
|
||||
const currentTimeMs = video.currentTime * 1000;
|
||||
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
|
||||
|
||||
|
||||
// If we're in a trim region during playback, skip to the end of it
|
||||
if (activeTrimRegion && !video.paused && !video.ended) {
|
||||
const skipToTime = activeTrimRegion.endMs / 1000;
|
||||
|
||||
|
||||
// If the skip would take us past the video duration, pause instead
|
||||
if (skipToTime >= video.duration) {
|
||||
video.pause();
|
||||
@@ -57,9 +66,12 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
|
||||
emitTime(skipToTime);
|
||||
}
|
||||
} else {
|
||||
// Apply playback speed from active speed region
|
||||
const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
|
||||
video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
|
||||
emitTime(video.currentTime);
|
||||
}
|
||||
|
||||
|
||||
if (!video.paused && !video.ended) {
|
||||
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
|
||||
import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
|
||||
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
|
||||
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
|
||||
import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
|
||||
@@ -22,6 +22,7 @@ interface FrameRenderConfig {
|
||||
videoWidth: number;
|
||||
videoHeight: number;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import GIF from 'gif.js';
|
||||
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
|
||||
import { StreamingVideoDecoder } from './streamingDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
|
||||
|
||||
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
|
||||
|
||||
@@ -16,6 +16,7 @@ interface GifExporterConfig {
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
@@ -100,6 +101,7 @@ export class GifExporter {
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
@@ -122,7 +124,7 @@ export class GifExporter {
|
||||
});
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
// Calculate frame delay in milliseconds (gif.js uses ms)
|
||||
@@ -142,6 +144,7 @@ export class GifExporter {
|
||||
await this.streamingDecoder.decodeAll(
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
|
||||
if (this.cancelled) {
|
||||
videoFrame.close();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebDemuxer } from 'web-demuxer';
|
||||
import type { TrimRegion } from '@/components/video-editor/types';
|
||||
import type { TrimRegion, SpeedRegion } from '@/components/video-editor/types';
|
||||
|
||||
export interface DecodedVideoInfo {
|
||||
width: number;
|
||||
@@ -67,6 +67,7 @@ export class StreamingVideoDecoder {
|
||||
async decodeAll(
|
||||
targetFrameRate: number,
|
||||
trimRegions: TrimRegion[] | undefined,
|
||||
speedRegions: SpeedRegion[] | undefined,
|
||||
onFrame: OnFrameCallback
|
||||
): Promise<void> {
|
||||
if (!this.demuxer || !this.metadata) {
|
||||
@@ -74,7 +75,10 @@ export class StreamingVideoDecoder {
|
||||
}
|
||||
|
||||
const decoderConfig = await this.demuxer.getDecoderConfig('video');
|
||||
const segments = this.computeSegments(this.metadata.duration, trimRegions);
|
||||
const segments = this.splitBySpeed(
|
||||
this.computeSegments(this.metadata.duration, trimRegions),
|
||||
speedRegions
|
||||
);
|
||||
const frameDurationUs = 1_000_000 / targetFrameRate;
|
||||
|
||||
// Async frame queue — decoder pushes, consumer pulls
|
||||
@@ -218,7 +222,7 @@ export class StreamingVideoDecoder {
|
||||
*/
|
||||
private async deliverSegment(
|
||||
frames: VideoFrame[],
|
||||
segment: { startSec: number; endSec: number },
|
||||
segment: { startSec: number; endSec: number; speed: number },
|
||||
targetFrameRate: number,
|
||||
frameDurationUs: number,
|
||||
startExportFrameIndex: number,
|
||||
@@ -226,7 +230,9 @@ export class StreamingVideoDecoder {
|
||||
): Promise<number> {
|
||||
if (frames.length === 0) return startExportFrameIndex;
|
||||
|
||||
const segmentFrameCount = Math.ceil((segment.endSec - segment.startSec) * targetFrameRate);
|
||||
const segmentFrameCount = Math.ceil(
|
||||
(segment.endSec - segment.startSec) / segment.speed * targetFrameRate
|
||||
);
|
||||
let exportFrameIndex = startExportFrameIndex;
|
||||
|
||||
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
|
||||
@@ -271,12 +277,39 @@ export class StreamingVideoDecoder {
|
||||
return segments;
|
||||
}
|
||||
|
||||
getEffectiveDuration(trimRegions?: TrimRegion[]): number {
|
||||
getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number {
|
||||
if (!this.metadata) throw new Error('Must call loadMetadata() first');
|
||||
const trimmed = (trimRegions || []).reduce(
|
||||
(sum, r) => sum + (r.endMs - r.startMs) / 1000, 0
|
||||
);
|
||||
return this.metadata.duration - trimmed;
|
||||
const trimSegments = this.computeSegments(this.metadata.duration, trimRegions);
|
||||
const speedSegments = this.splitBySpeed(trimSegments, speedRegions);
|
||||
return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0);
|
||||
}
|
||||
|
||||
private splitBySpeed(
|
||||
segments: Array<{ startSec: number; endSec: number }>,
|
||||
speedRegions?: SpeedRegion[]
|
||||
): Array<{ startSec: number; endSec: number; speed: number }> {
|
||||
if (!speedRegions || speedRegions.length === 0)
|
||||
return segments.map(s => ({ ...s, speed: 1 }));
|
||||
|
||||
const result: Array<{ startSec: number; endSec: number; speed: number }> = [];
|
||||
for (const segment of segments) {
|
||||
const overlapping = speedRegions
|
||||
.filter(sr => (sr.startMs / 1000) < segment.endSec && (sr.endMs / 1000) > segment.startSec)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
|
||||
if (overlapping.length === 0) { result.push({ ...segment, speed: 1 }); continue; }
|
||||
|
||||
let cursor = segment.startSec;
|
||||
for (const sr of overlapping) {
|
||||
const srStart = Math.max(sr.startMs / 1000, segment.startSec);
|
||||
const srEnd = Math.min(sr.endMs / 1000, segment.endSec);
|
||||
if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 });
|
||||
result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed });
|
||||
cursor = srEnd;
|
||||
}
|
||||
if (cursor < segment.endSec) result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 });
|
||||
}
|
||||
return result.filter(s => s.endSec - s.startSec > 0.0001);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
|
||||
@@ -2,13 +2,14 @@ import type { ExportConfig, ExportProgress, ExportResult } from './types';
|
||||
import { StreamingVideoDecoder } from './streamingDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import { VideoMuxer } from './muxer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion } from '@/components/video-editor/types';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion } from '@/components/video-editor/types';
|
||||
|
||||
interface VideoExporterConfig extends ExportConfig {
|
||||
videoUrl: string;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
showBlur: boolean;
|
||||
@@ -68,6 +69,7 @@ export class VideoExporter {
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
});
|
||||
@@ -81,7 +83,7 @@ export class VideoExporter {
|
||||
await this.muxer.initialize();
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
|
||||
@@ -96,6 +98,7 @@ export class VideoExporter {
|
||||
await this.streamingDecoder.decodeAll(
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
|
||||
if (this.cancelled) {
|
||||
videoFrame.close();
|
||||
|
||||
Reference in New Issue
Block a user