IMFRI=YQj7C2r^}2vu*$F
z=qK`6EQ1s`P8qQy+ii5vh+Lr(PXw#rDd~|vv@{q(>pp&FwvK
z`~3erwu1m5&xmV3ly?feZ>WUJ3w01TZL5B~8NzOTLtSAqd1G2tB@(3N@zwrO6*l60
zR3BXyk*{nLy}Rey)D@+!hhigeeKJKi49!iQcGv1=pXqt&FG#AsVS^vB@_#fHN=|)!
z)%6a{bH1_M=?~1O6S%r&@I$%#tywjGp-#4#>~*I?K~Y_0EDw}&J)^VmkQ4VgJ#M2E
zlQ1;x6yyq2z&}~L+}VSWc24%hj(R%CHJvw;RJY!0pNi2jvvDN72W<4cm&zg?rg4m5
z&YKKs3P@jKDtdBG**kvq-kR4zpd0YJe0~W3+&ppMo~^n!{xhsv+35KxR@fa}Bu7-r
zB%ov3h=Sk>N%-rD5SDl9ZQE_a00#*9TM&)z0{kSx1OS|i515Tgpe8#WG_)K>nq4w+
zI>YIu#Kp7H{?QXnTd`!!{zI>{_GT818C+~!)Q=F}EMw+lG8Nu7a<|sd<}j+c!TEQ5>g=dynTA*z0szR8?w+QW
zS*s*J@l?eWPkD-RfRvlG=@&+DJ`X?aRFbkr8LV15iV_RR+QE!BHI5AeLJl$&2iyDy8Xa$5lCGE&o`%P;Zw|bpPl-+xSlJg
z=7QW)QW(ujn=HFlXlFbt@!dttB0+8>=c8@2vpHl+c}_=niZ&eErl{;CAXnFzH!~AR
z!{D2gdDbMca9A)nT<_|G{bR2ewK70av70oip}Ki(6%8Zvrp
zWhV44`^yaGWzu%_Jdufr+gX=U2H*0&e;Tdh_qjjmNy!_PqdZD^wP!Ok<056Lmec!^
zo#8_VGfGB1;*ai4fnOJ<##RyS-^(bg9spyRAUV<1QPt)ZO&trc3`=VjO;0zH@!Lv&1Q!)W8_@ak
zx+*SQ$v_acY4&3tO}`Tp3E5a4CjDXg-2MO_`f*aH0n^ms&>6X<`}AP!QL6}9{4w?M
zn%Bn4$w)T2rmQ2K)vd8Hoxi8oCt6DEGs8YwDV@<|62)7wG7_2<-MHqMEQ1=a(v?XCy
zF^=;%mW7;c?i&A^x~oqitSHk)19T<-!JfhCf$e+lP?w`c{(IV#g`-Y^YcFov@sOK-
zcMGBU%iAroB?~q;{sD%^g#XjtWO?KCu|(W;OsbTLc+qte6GF|*&kLk-kel5G4?p+v
zr1FA6q%%ujZ|bK?ABX7y0I&j4P6F`-0AG
zh$}|GkO72y)A}I!Np(FoH0kF1fZy&B)1zP$#+q7*dytX={vzxOfLjJ3eB$$R9XwkMhvyf`{i>_H=&YBr+VdPHE
zt1Nf!7gt)-r_#7%J>jOvdkX%rqyZV}_`0kihKjO3LPfj%CUO|wx(iluFOGkaWYgK`
z)jZFLYn5847)0zJK6mM}V||K!t&OCQmXblfyuA6qg1%!$XIYOc5?0ue|MWJ%s)A(l
zL85Qq!JVcnnWMreRT_~G$r)qU|
zvN&!7l!KAOvg5ZE{Z5UWQxJ;_=&F9F@ELeMJGK6bI!>Nj6^jJT=y}iz@cs#%a7q0$
zP+~?IibYZ%t&`Mn*rH32jr;KnR&3NzS~eCXWg24yP*hh}8}`1Y>0Y?UddCr|5YtFS
zm6Nq*D4m5B>+89Ra!u_Xh?IE;*-&Uz~v4Hy7K1s>J@rUo-Ad*
z_J)~`s{9{jBU1H0wd^9rV@Sy|({lJdrrm{VP7V)pucX+i^rLQWl9=6gnIlcmCu)ynyek(1k*;}eQ2>3Hw`RXlXw*fH$k?N8;odnG9;c0}Od
z4sK5F1Q^;-y7QCf!2D5@DL#$CV2B;`T1EYLlEcVI0lg3cy+G}}^Ut1$4;op*c>iBV
zk4+(oJh_3mgKdwe8C=DVsDj>3Ix!)>=N=c=E_|;!lpTI~r?a7Ul_OJ4A*SsO&vT<`
z3U%Q|_+ayfs_)g!J@*?kZIhIGwwj~^&2|P=j!(Jj
z)|J2SDZgmcxPSc)&h5%q)*W7Izsbxx487f;A?7OVtCn8ZtL@S%n9_PCt&KSC3uVf>
zKR*@uIy;T-H!+>1YW`L5son2aOxaqe%gtjs=40#ej-B;+(1){Q6+@Xkvw)-tjr!YY
z(eDMd{HF`rvco4MN{x_}9;PGcr;>K@OQQqRc8(TIoFW>lAkN=E%W40%e$ZREV6dy2
zaGQigIxoh3@+iDGe&m`UViQ`b(P#b`W>~BKB~Ev-Nl;$I33QMlq7_5DuKp_zrmVC6
zUUr$CPlgegyqE@>@MjYTxU@pvz5%uBDMbat&?P)}hq-X-p4K5HlZn2C)$S7|C4Su6
zHt_x}CzbKxekkb4sFzwKwJcDIFUEy?Vh7j=#;|o^H9k#9ZA;D+60FFBF*h0KGy#}IUBxa`-)cF@f%Rd(&Yt2%`N9Hhp5kfwW2)_g_*XyLr`
z6V!g&sU$||Yr9zr;W9)R=cLC%*ZVs;>fBtZulM@mM;WbtSM|11vf@WK?LMLTG}N->
z82Z9N@QxNR&15P?qdn%aLE*3aT{+&*eQ%)t?AKzPbg;4Uw+OOYByi5huM*#XX(&5O
zIY9Ij0N+c#@K)Ph4W9k-_N!GQ$mHOeR8&S4L6zwI(?|Z3UU`+o@lz0<_5Eg-8Jft`
z+kzJZ-ZJbsF}9Y{KG*IStSD#^?uBBgM?Pzr3V8@NKEdw;VjV
z{p?EqQv5~d>UCQ5AiEFPe~Bs7QYz=6=reQ=(GHqrz?3TxxNk5S>JRJ661B|-GrC(Ee
z-%E{VVPxcXFj+z;D?sE}@d{?d_J>m$tMYHd<|!RD`eXB||JHL|K{}UQ=AfO`(j7_E
z|Fh>Zymz_71iAiO5b{4Kt^OBJ{KsbGKP+*N<566GpIYm)HB4XIA!4XMF3l8UtF$K0~{;f4Jk+S(M02c>k=4ZqNn%
zcUA1anzfJzLVGUDhb;2v2pSNl;T~ko2|cHtf=|lX4o0NDwCV#sZZemfkT3mR7Y(7Z
zvZlNyStBA4B^uZ2gA|F#8n@4n3UMpn6xGjQ#EEecRDf;f$IH$&)!9A(Kphv8pM}z8
z*E`|y)@*KzRe~ZiGIw%Tl%T{>gKz+KgD8#HuK3qePiLd=?Zk^1qJ^QF;SWs*eLKa(=8M@B`Nk|OSV%Zq
zEhsIbSI_p@(=M2Ydn?#EUD>C*^F0mUP4bh_%DuAc-P-(o!R?-gEo(S0simt;jzhi&ixglt3hYJu<-0eV2O@^WW%Dwc~8i8rG_%?XJS)jU&r#-_a2c|K$43WvayCS^V
z3TeqcmB;*7+!SMT67KGH5iJ-wPp==Q#Vbm4K|#5oOi?vCTTs^&MMf442Mf@Z7LD5*
zFX8UP13K^j6j*$Xx4T|km|7KPK_jHBSYAW`?M;2W#ceRXM34oLFff(ioELINn9N|=
z*apIp0cE<1MhOFjeM9{B2|vKIC`x5=Q`6}0#sKQ%&Rb5|V^G%F*46}nZsKqe0A3p2
zaeNwBYqo`j$(?9#JsUhh2VQIQpaqjcCxA%Z+!Wp17BD&Ou|j2#ooq@KR@1Ng2#9W;
z{}hH#%O%(J|FoG^$jJ*=W}Ds`aCe5bMt=_I1dKRlv^8ahlZLwGIQAm!CjSLxijHh-
z!U~s^RLnWyK&-0vtvigUDQJCB^!m&nWF#WtBBomVzzJBO{zMnntXVuOaI0&LoChRi
zv79y6TCkTF_SY`AsdxE+48(Kz_0J@X{(l;ms(l*D`gh7ySb_ExSwKhry$x%6i1U%h
z|E3A!q+r&-LFJ#&P;kEfIgbfsO@K5u6dH9Qfrdr-chOyl?E$?+(&P>fl
za{$4JM(%Zm5|aAHiZj{3&VVOv;3Z0&s5lg$=xB$M~p}
zVHv!;{`sqp3yA<1yQmCZ6gz-6
zi6OtBiaZwbS-YJXwAASM`M9J)MLcBmG3EoP-go@r|5fipMu+)_;Kv5`nEk)SKAzyX
z_WjnN>LFvIXR2EP`H26iJl4NQw+?AVaca~Va&f?V2H*aCJN`%V9A1c-@w16iTx^&$
z^KMMTfR<{JSU(5JDBBXB%hq*318D^zRE07x)F@Xt%o)qU0tFdB8tY)mMYs08c+gHY
zmNYzr9F|m7Bl~6Xcr2#5t6Y<7#;jj%C^aq0W)`9H3Hi&GsWE(=I-QHVv?AD-&viY)
z@x5F&HcFY%if_x$lSm9P4^6RE=_*rbaB-ek*@4=$lhmy8=S~Qshl7r=p3mVTZEy-A
zh#)H|hnSRGNmOBMIKQpUkOKiKN_>ODQ>gRvPg1Yt{`U49S60=As5*I
z?I7hnwmXscQdj!d8IlV5*4?+iC{8TQPABjwG?!^&NPym$_
z660q-%en*Tz&Y>v&Q8}%)5JT#E2C?v18Fy&IB)=c4!3kqWR%<^_$Y2}S_YO`y}lD^
z3mpIPd
z=lvw*DowL4=hxGo0kOz=la=Cc!ULj~Bv^>U!3&M7dYmjg-v<2p6k)ogN~;z!`VADZ
zjP6^?%sAFyfY;2>lDuCj;MNdZ{Y73QTX!=akk-nr
zrQBFy1w~Pk*uBF|U>*&Dw;a^fh#lNA00E7~|3g&x2rZc;N>D)odWdfOjSoqu@1vmF
z#D5=DCFU)Za2WlI{xp#zbg7s^HZCFlM{wyb>WUg`kkP|$4Aw6Co83jN3j>rgjT&ev
zQN*(6=)CRn>EUk5caj@V5I0c%M?<{af(iYSe}6O3g5!+3{^h`QXnPBOjrmN9(4gZZ
zDY#-Kw*$lTsG;*^V3)PP22BJRJ-jzW@AF)e5-|#DeZyN2}L=PcHud83kW@vCD!3g!b-SxWj)1ME(B9
z1`jrmF-Hv<*JzB2ERUXw439<2ClEsiZw{z*)F+ham)SR)d6NFSl
ziCZ2}pCE)hWHPiC8Xb;z1Ru{}3N}L{&d5Nd|D9F=sMr7AU(+j#Bxr`xKg>i7T*Gj?lE~g|>{lnTn
ztJqvK?Q4e4q5Z&O1Pe*4N9|c|unQNOjn=ywl6SP%xWqYo16u}pP=}}BAO8#RG4Jb4
zU*J?FBcAeJ@MC7C<@d5KpZ@Lp{zg^5FQw)CnWdb8Y;1G}*-a
z&JV-68mE%O4Q@aQ@_bZu^03`sRCe73o3A>&I+N>Xz&m@HeeHNqt`*;1C
z`Gi)0?epF7BXkCF|Kg3{h?WorTk<@Fpt&>evFfGQoHd=-+$y(v49
z({osUR5h>a@U1d3)_tnJik~__ieOJDz)F*D
z;oeE^r#lr**Qn^$0P3W*mdvBB$+%6z*CS8MTob?p*iQ$E_Z(rz7dBj(S}FIZg;{i*
zLJn`T0od_wZD=kk1zatUUsA%q$g0mTG(hTlWL-GKH>!d9Dvml&u~YziSn83fa&<1k
zoLLIO6G}hDS*MydOYzKp*hvj1hAuXT;Km8CrB9~9_QXEd9DCMrP2k1+O-w@pEPlm@
zX94l?%etH|t!)iUhA`ao>mo?4t%>843zG#}9Wh
zErug+pM1;f;3(;&*fjQaUW+=^z8nK%c`+w1BR|v|6$`#-)+uOZb$L0Dh-!St$^0rLdc3)$D9j>TQ!%O0%}~cz7tL@*BOKgLV%pHUMCmHXA{^%i#v(`3AD}~3WQ)SaU?k}_nim{i>
zNpp4h+eLCCp`FDI0O(he6zR;M&Z0SEn0;f<{S^WHGWxrHr0xEboQ82yv}=xm9Nybi
ztAqN$#K_jfnYCmdV*GKNNa#Ngn9nwPtF6~ac1K>`Y7($N_FrH{4_ub%(nmGfC6;&^
zmWDAf7fvSHBb!||%q<$7JWaN=di0}Zal85bj>@|vy1o*R&6?N|X}t_Y0dy^zSRY?(
zGS3FjSB`gNH`#x~na{*K`q8OsX~V9NJ)Tb!k5)1QU49_fP!Li|Lnb@eL$V-?H@4o{
zg|!$hqvgmnb?V~PXe~>g=%*YAm+}|_-lEd^NXs#=<~x6}*vnt4a+%tuv+n^#NVleQ
zC~=3b<6+m;&aCm(6MxP;nJV^D{?xWQtPt&GZRHvdEp~6`T^GRr26}>2|J>uZxB7mu
zuxmqyCYmjmruI95?$3woWh7&G;TkRWQQp`-R})1pAKHT|imFfMS%MaNstpzxh7$!&
znL@yfx8MjX*$)EaM*Wv_Um1tyhX1e0_Eza0FHY?EpOJmNKg3_T_VtW|*@;hFx^J&_
zQudj^eqN}o?%LlaW)~-W{T0nusoDk%Nm-fs=E*f|dO!Z(-km(%;=M)RWB=}|IqoV`
z0yKKBai|ITXvhXRF)c0LuJcxY;zsY~d6SO%yu79vY_`1Q)Uwm9L6a^mFieVZ$??%j
z|59@<`^^O>h31beJI~*|p1rU6J@5J1_dnW{-@p3w*LMG~W&c*x)Ytu4zW(^M_%&y3
zX4h}{fAWI3cwYTgd(-bP?)e3pnWz81wL2(w|Nfod@8AA*n%%!HH>a=8+@|t-(ArBm
zHgy~S9KJmD_*=ez^B3prowe@z^Ygv;cGP@LTf09a_q?9nth##{B1aD|y}$godcI%H
zzt1j#Z>E0MzrO61w(
zPfuxe+xBzwWbfYi@%h7g`{HMh*RDPG@$BL1(5(6Q_LU`OtgJhbJo7_e@bWgk`lZ&M
zg)Yv`%h%VJ{5pO9*t#v(>tlj7`no*|P2~N0eC|oD7qxy}UKjo6=rXy!UZ7~coZgw(
zxEJ3<{_y`$6%tr`UHNHY!V6c~n4NoNSNfGdW{AE1Qf=40zL3o~Wh7RVRC6t0W@et6
zwz)Z47pP#bIXRuNHe?`|H;)HumhbVU<5*UmxY%v3vX8zq{_{g|EJvwKXeT
zgXMPq_S@Fhw`E0FSujr2>RO~zZvFR$!}=F**H<6tR1b16naH%LQ$)abNkfJbtBbIv
n!YmKQi!NLpY9mkT8~<@m(WPHR>kWWs9WelR=)0_QN@xNA7mSa2
literal 0
HcmV?d00001
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..7da796f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,10 @@
+services:
+ animatr-render:
+ build: ./server
+ ports:
+ - "6000:6000"
+ environment:
+ - NODE_ENV=production
+ - PORT=6000
+ shm_size: "2gb"
+ restart: unless-stopped
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..17c27fe
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Animatr
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..408a060
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1636 @@
+{
+ "name": "animatr",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "animatr",
+ "version": "0.0.0",
+ "dependencies": {
+ "@phosphor-icons/vue": "^2.2.1",
+ "konva": "^9.3.16",
+ "pinia": "^2.2.6",
+ "vue": "^3.5.13",
+ "vue-konva": "^3.1.2"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.6.3",
+ "vite": "^6.0.3",
+ "vue-tsc": "^2.1.10"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@phosphor-icons/vue": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/@phosphor-icons/vue/-/vue-2.2.1.tgz",
+ "integrity": "sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "vue": ">=3.2.39"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.19.17",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
+ "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.15"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz",
+ "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.15",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz",
+ "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
+ "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/shared": "3.5.32",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
+ "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.32",
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
+ "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.2",
+ "@vue/compiler-core": "3.5.32",
+ "@vue/compiler-dom": "3.5.32",
+ "@vue/compiler-ssr": "3.5.32",
+ "@vue/shared": "3.5.32",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.8",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
+ "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.32",
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/compiler-vue2": {
+ "version": "2.7.16",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
+ "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "de-indent": "^1.0.2",
+ "he": "^1.2.0"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/language-core": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz",
+ "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.15",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/compiler-vue2": "^2.7.16",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^1.0.3",
+ "minimatch": "^9.0.3",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
+ "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
+ "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.32",
+ "@vue/shared": "3.5.32"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
+ "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.32",
+ "@vue/runtime-core": "3.5.32",
+ "@vue/shared": "3.5.32",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
+ "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.32",
+ "@vue/shared": "3.5.32"
+ },
+ "peerDependencies": {
+ "vue": "3.5.32"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
+ "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
+ "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/de-indent": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
+ "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/konva": {
+ "version": "9.3.22",
+ "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.22.tgz",
+ "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/lavrton"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/konva"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/lavrton"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pinia": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
+ "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.3",
+ "vue-demi": "^0.14.10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.4.4",
+ "vue": "^2.7.0 || ^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.10",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+ "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "devOptional": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.32",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
+ "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.32",
+ "@vue/compiler-sfc": "3.5.32",
+ "@vue/runtime-dom": "3.5.32",
+ "@vue/server-renderer": "3.5.32",
+ "@vue/shared": "3.5.32"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-demi": {
+ "version": "0.14.10",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
+ "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-konva": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/vue-konva/-/vue-konva-3.4.0.tgz",
+ "integrity": "sha512-l7yqAPmc6gPbdXzxoukTiXzA6EdqTtPhzQp1pTdppwWLiimPCgFHcqp8nApXQN5dqF11p2OejwjKv150cf9ncA==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/lavrton"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/konva"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/lavrton"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0",
+ "npm": ">= 3.0.0"
+ },
+ "peerDependencies": {
+ "konva": ">7",
+ "vue": "^3"
+ }
+ },
+ "node_modules/vue-tsc": {
+ "version": "2.2.12",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",
+ "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.15",
+ "@vue/language-core": "2.2.12"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..6fbdecd
--- /dev/null
+++ b/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "animatr",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "type-check": "vue-tsc --noEmit"
+ },
+ "dependencies": {
+ "@phosphor-icons/vue": "^2.2.1",
+ "konva": "^9.3.16",
+ "pinia": "^2.2.6",
+ "vue": "^3.5.13",
+ "vue-konva": "^3.1.2"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "@vitejs/plugin-vue": "^5.2.1",
+ "typescript": "^5.6.3",
+ "vite": "^6.0.3",
+ "vue-tsc": "^2.1.10"
+ }
+}
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..54e5f26
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,4 @@
+node_modules
+npm-debug.log
+.env
+*.mp4
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..638c64b
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,20 @@
+FROM node:20-slim
+
+RUN apt-get update && apt-get install -y \
+ chromium \
+ ffmpeg \
+ fonts-liberation \
+ fonts-noto-color-emoji \
+ --no-install-recommends \
+ && rm -rf /var/lib/apt/lists/*
+
+ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
+ENV PORT=6000
+
+WORKDIR /app
+COPY package.json ./
+RUN npm install --omit=dev
+COPY . .
+
+EXPOSE 6000
+CMD ["node", "server.js"]
diff --git a/server/package.json b/server/package.json
new file mode 100644
index 0000000..dade8f8
--- /dev/null
+++ b/server/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "animatr-server",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js"
+ },
+ "dependencies": {
+ "cors": "^2.8.5",
+ "express": "^4.21.2",
+ "puppeteer-core": "^23.11.1"
+ }
+}
diff --git a/server/render.html b/server/render.html
new file mode 100644
index 0000000..4c1dd90
--- /dev/null
+++ b/server/render.html
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/server.js b/server/server.js
new file mode 100644
index 0000000..44dda60
--- /dev/null
+++ b/server/server.js
@@ -0,0 +1,198 @@
+import express from 'express'
+import cors from 'cors'
+import path from 'node:path'
+import fs from 'node:fs/promises'
+import os from 'node:os'
+import { randomUUID } from 'node:crypto'
+import { spawn } from 'node:child_process'
+import { fileURLToPath } from 'node:url'
+import puppeteer from 'puppeteer-core'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+const PORT = Number(process.env.PORT || 6000)
+const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
+const RENDER_HTML = path.join(__dirname, 'render.html')
+
+const app = express()
+app.use(cors())
+app.use(express.json({ limit: '200mb' }))
+
+/** @type {Map} */
+const jobs = new Map()
+
+app.get('/health', (_req, res) => res.json({ ok: true }))
+
+app.post('/render', (req, res) => {
+ const { scene, options } = req.body || {}
+ if (!scene || !scene.canvas || !Array.isArray(scene.layers)) {
+ return res.status(400).json({ error: 'invalid scene' })
+ }
+ const job = {
+ id: randomUUID(),
+ progress: 0,
+ done: false,
+ error: null,
+ filePath: null,
+ subscribers: new Set(),
+ abort: new AbortController(),
+ }
+ jobs.set(job.id, job)
+ res.json({ jobId: job.id })
+ runJob(job, scene, options || {}).catch((err) => {
+ job.error = err?.message || String(err)
+ job.done = true
+ notify(job)
+ })
+})
+
+app.get('/render/:id/events', (req, res) => {
+ const job = jobs.get(req.params.id)
+ if (!job) return res.status(404).end()
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ Connection: 'keep-alive',
+ })
+ const send = () => {
+ const payload = {
+ progress: job.progress,
+ done: job.done,
+ error: job.error,
+ url: job.done && !job.error ? `/render/${job.id}/file` : null,
+ }
+ res.write(`data: ${JSON.stringify(payload)}\n\n`)
+ }
+ job.subscribers.add(send)
+ send()
+ const keepalive = setInterval(() => res.write(': keepalive\n\n'), 15000)
+ req.on('close', () => {
+ job.subscribers.delete(send)
+ clearInterval(keepalive)
+ })
+})
+
+app.get('/render/:id/file', (req, res) => {
+ const job = jobs.get(req.params.id)
+ if (!job || !job.filePath || job.error) return res.status(404).end()
+ res.download(job.filePath, 'animation.mp4')
+})
+
+app.delete('/render/:id', (req, res) => {
+ const job = jobs.get(req.params.id)
+ if (!job) return res.status(404).end()
+ job.abort.abort()
+ res.json({ ok: true })
+})
+
+function notify(job) {
+ for (const sub of Array.from(job.subscribers)) {
+ try { sub() } catch { /* noop */ }
+ }
+}
+
+function effectiveEnd(scene) {
+ let max = 0
+ for (const layer of scene.layers) {
+ for (const kf of layer.keyframes) {
+ if (kf.time > max) max = kf.time
+ }
+ }
+ if (max <= 0) return scene.duration
+ return Math.min(max, scene.duration)
+}
+
+async function runJob(job, scene, options) {
+ const fps = Number(options.fps || scene.fps || 60)
+ const sceneEnd = effectiveEnd(scene)
+ const duration = Number(options.duration || sceneEnd)
+ const totalFrames = Math.max(1, Math.round(duration * fps))
+ const crf = String(options.crf ?? 18)
+ const preset = String(options.preset ?? 'fast')
+
+ const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
+ const outPath = path.join(workDir, 'out.mp4')
+
+ const browser = await puppeteer.launch({
+ executablePath: CHROME_PATH,
+ headless: 'new',
+ args: [
+ '--no-sandbox',
+ '--disable-setuid-sandbox',
+ '--disable-dev-shm-usage',
+ '--hide-scrollbars',
+ ],
+ defaultViewport: {
+ width: scene.canvas.width,
+ height: scene.canvas.height,
+ deviceScaleFactor: 1,
+ },
+ })
+
+ const ffmpeg = spawn('ffmpeg', [
+ '-y',
+ '-f', 'image2pipe',
+ '-framerate', String(fps),
+ '-i', '-',
+ '-c:v', 'libx264',
+ '-pix_fmt', 'yuv420p',
+ '-preset', preset,
+ '-crf', crf,
+ '-movflags', '+faststart',
+ outPath,
+ ])
+ let ffmpegErr = ''
+ ffmpeg.stderr.on('data', (d) => { ffmpegErr += d.toString() })
+ const ffmpegDone = new Promise((resolve, reject) => {
+ ffmpeg.on('exit', (code) => {
+ if (code === 0) resolve()
+ else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`))
+ })
+ ffmpeg.on('error', reject)
+ })
+
+ try {
+ const page = await browser.newPage()
+ await page.goto('file://' + RENDER_HTML, { waitUntil: 'domcontentloaded' })
+ await page.evaluate((s) => window.__animatrInit(s), scene)
+
+ for (let i = 0; i < totalFrames; i++) {
+ if (job.abort.signal.aborted) {
+ try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
+ throw new Error('aborted')
+ }
+ const t = Math.min(i / fps, duration)
+ await page.evaluate((time) => window.__animatrSeek(time), t)
+ const buf = await page.screenshot({
+ type: 'png',
+ omitBackground: false,
+ clip: { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height },
+ })
+ if (!ffmpeg.stdin.write(buf)) {
+ await new Promise((resolve) => ffmpeg.stdin.once('drain', resolve))
+ }
+ job.progress = (i + 1) / totalFrames
+ notify(job)
+ }
+ ffmpeg.stdin.end()
+ await ffmpegDone
+ job.filePath = outPath
+ job.done = true
+ notify(job)
+
+ setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000)
+ } finally {
+ await browser.close().catch(() => {})
+ }
+}
+
+async function cleanupJob(jobId, workDir) {
+ jobs.delete(jobId)
+ await fs.rm(workDir, { recursive: true, force: true })
+}
+
+app.listen(PORT, () => {
+ console.log(`animatr render server listening on :${PORT}`)
+ console.log(`chromium: ${CHROME_PATH}`)
+})
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 0000000..b8c7670
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,262 @@
+
+
+
+
+
+
+
+
+
+
+
{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue
new file mode 100644
index 0000000..9c8dad1
--- /dev/null
+++ b/src/components/AssetSidebar.vue
@@ -0,0 +1,275 @@
+
+
+
+
+
Assets
+
+
+
+
+
+
+
+
+
+
{{ uploadError }}
+
+
Bibliothek
+
+ -
+
+ {{ item.asset.name }}
+
+
+ - keine Assets
+
+
+
+
+
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
new file mode 100644
index 0000000..75e6d60
--- /dev/null
+++ b/src/components/CanvasStage.vue
@@ -0,0 +1,581 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ store.scene.canvas.width }} × {{ store.scene.canvas.height }}
+
+
+
+
diff --git a/src/components/DocumentSettings.vue b/src/components/DocumentSettings.vue
new file mode 100644
index 0000000..ca3366a
--- /dev/null
+++ b/src/components/DocumentSettings.vue
@@ -0,0 +1,540 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UI Theme
+
+ Gespeichert im Browser, beim nächsten Öffnen wiederverwendet.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ExportDialog.vue b/src/components/ExportDialog.vue
new file mode 100644
index 0000000..9db7e12
--- /dev/null
+++ b/src/components/ExportDialog.vue
@@ -0,0 +1,460 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ progress.label }}
+
{{ Math.round(progress.progress * 100) }}%
+
+
+
+
+
+
+
+
+
Export fehlgeschlagen
+
{{ progress.error }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue
new file mode 100644
index 0000000..b888286
--- /dev/null
+++ b/src/components/PropertiesPanel.vue
@@ -0,0 +1,288 @@
+
+
+
+
+
+
+
diff --git a/src/components/RangeSlider.vue b/src/components/RangeSlider.vue
new file mode 100644
index 0000000..70f5717
--- /dev/null
+++ b/src/components/RangeSlider.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
+
{{ formattedValue }}
+
+
+
+
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
new file mode 100644
index 0000000..227adf0
--- /dev/null
+++ b/src/components/Timeline.vue
@@ -0,0 +1,839 @@
+
+
+
+
+
+
+
diff --git a/src/composables/usePlaybackLoop.ts b/src/composables/usePlaybackLoop.ts
new file mode 100644
index 0000000..c7cf500
--- /dev/null
+++ b/src/composables/usePlaybackLoop.ts
@@ -0,0 +1,23 @@
+import { onBeforeUnmount, onMounted } from 'vue'
+import { useSceneStore } from '@/stores/scene'
+
+export function usePlaybackLoop() {
+ const store = useSceneStore()
+ let raf = 0
+ let last = 0
+
+ const loop = (now: number) => {
+ const dt = last === 0 ? 0 : (now - last) / 1000
+ last = now
+ if (store.playing) store.tick(dt)
+ raf = requestAnimationFrame(loop)
+ }
+
+ onMounted(() => {
+ raf = requestAnimationFrame(loop)
+ })
+
+ onBeforeUnmount(() => {
+ cancelAnimationFrame(raf)
+ })
+}
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
new file mode 100644
index 0000000..c436d3a
--- /dev/null
+++ b/src/composables/useTheme.ts
@@ -0,0 +1,35 @@
+import { ref, watch } from 'vue'
+
+export type Theme = 'dark' | 'light'
+
+const STORAGE_KEY = 'animatr.theme'
+
+function readInitial(): Theme {
+ if (typeof localStorage === 'undefined') return 'dark'
+ const v = localStorage.getItem(STORAGE_KEY)
+ return v === 'light' ? 'light' : 'dark'
+}
+
+const theme = ref(readInitial())
+
+function apply(t: Theme) {
+ if (typeof document === 'undefined') return
+ document.documentElement.dataset.theme = t
+ try {
+ localStorage.setItem(STORAGE_KEY, t)
+ } catch {
+ /* ignore quota */
+ }
+}
+
+apply(theme.value)
+watch(theme, (t) => apply(t))
+
+export function useTheme() {
+ return {
+ theme,
+ setTheme(t: Theme) {
+ theme.value = t
+ },
+ }
+}
diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts
new file mode 100644
index 0000000..759e02e
--- /dev/null
+++ b/src/lib/exportPlayer.ts
@@ -0,0 +1,326 @@
+import type { Scene } from '@/types/scene'
+
+const RUNTIME_SOURCE = `(function(){
+ var SCENE = __SCENE__;
+ var OPTIONS = __OPTIONS__;
+
+ function easing(name, t){
+ if(name==='easeIn')return t*t;
+ if(name==='easeOut')return 1-(1-t)*(1-t);
+ if(name==='easeInOut')return t<0.5?2*t*t:1-Math.pow(-2*t+2,2)/2;
+ return t;
+ }
+ function merge(base,partial){
+ var o={};for(var k in base)o[k]=base[k];
+ if(partial)for(var k2 in partial)o[k2]=partial[k2];
+ return o;
+ }
+ function lerp(a,b,t){return a+(b-a)*t;}
+ function parseHex(hex){
+ if(!hex)return null;
+ var h=String(hex).trim().replace('#','');
+ if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];
+ if(h.length<6)return null;
+ var n=parseInt(h.slice(0,6),16);
+ if(isNaN(n))return null;
+ return [(n>>16)&255,(n>>8)&255,n&255];
+ }
+ function formatHex(r,g,b){
+ function c(v){v=Math.round(v);if(v<0)v=0;if(v>255)v=255;var s=v.toString(16);return s.length<2?'0'+s:s;}
+ return '#'+c(r)+c(g)+c(b);
+ }
+ function lerpColor(a,b,t){
+ var pa=parseHex(a),pb=parseHex(b);
+ if(!pa||!pb)return t<0.5?a:b;
+ return formatHex(lerp(pa[0],pb[0],t),lerp(pa[1],pb[1],t),lerp(pa[2],pb[2],t));
+ }
+ function lerpProps(a,b,t){
+ var out={
+ x:lerp(a.x,b.x,t),
+ y:lerp(a.y,b.y,t),
+ scaleX:lerp(a.scaleX,b.scaleX,t),
+ scaleY:lerp(a.scaleY,b.scaleY,t),
+ rotation:lerp(a.rotation,b.rotation,t),
+ opacity:lerp(a.opacity,b.opacity,t)
+ };
+ if(a.fill&&b.fill)out.fill=lerpColor(a.fill,b.fill,t);
+ else if(b.fill)out.fill=b.fill;
+ else if(a.fill)out.fill=a.fill;
+ return out;
+ }
+ function sample(layer,time){
+ var kfs=layer.keyframes.slice().sort(function(a,b){return a.time-b.time;});
+ if(!kfs.length)return merge(layer.base);
+ if(time<=kfs[0].time)return merge(layer.base,kfs[0].props);
+ var last=kfs[kfs.length-1];
+ if(time>=last.time)return merge(layer.base,last.props);
+ for(var i=0;i=a.time&&time<=b.time){
+ var raw=(time-a.time)/(b.time-a.time);
+ var t=easing(b.easing,raw);
+ return lerpProps(merge(layer.base,a.props),merge(layer.base,b.props),t);
+ }
+ }
+ return merge(layer.base);
+ }
+
+ function loadImages(assets){
+ var imgs={};
+ var list=assets.filter(function(a){return a.kind==='image';});
+ return Promise.all(list.map(function(a){
+ return new Promise(function(resolve){
+ var img=new Image();
+ img.onload=function(){imgs[a.id]=img;resolve();};
+ img.onerror=function(){resolve();};
+ img.src=a.src;
+ });
+ })).then(function(){return imgs;});
+ }
+
+ function effectiveEnd(scene){
+ var max=0;
+ for(var i=0;imax) max=kfs[j].time; }
+ }
+ if(max<=0) return scene.duration;
+ return Math.min(max,scene.duration);
+ }
+ function mount(container,scene,opts){
+ if(!container)return;
+ opts=opts||{};
+ var loop=opts.loop!==false;
+ var sceneEnd=effectiveEnd(scene);
+ var cs=getComputedStyle(container);
+ if(cs.position==='static')container.style.position='relative';
+ var canvas=document.createElement('canvas');
+ canvas.style.cssText='display:block;width:100%;height:100%;';
+ container.appendChild(canvas);
+ var ctx=canvas.getContext('2d');
+ var dpr=window.devicePixelRatio||1;
+ var size={w:0,h:0};
+ function resize(){
+ var r=container.getBoundingClientRect();
+ size.w=r.width;size.h=r.height;
+ canvas.width=Math.max(1,Math.round(r.width*dpr));
+ canvas.height=Math.max(1,Math.round(r.height*dpr));
+ }
+ var ro=new ResizeObserver(resize);
+ ro.observe(container);
+ resize();
+ var assetMap={};
+ scene.assets.forEach(function(a){assetMap[a.id]=a;});
+ var imgs={};
+ var start=0;
+ function frame(now){
+ if(!start)start=now;
+ var t=(now-start)/1000;
+ if(sceneEnd<=0){ draw(0); requestAnimationFrame(frame); return; }
+ if(loop) t=t%sceneEnd;
+ else if(t>sceneEnd) t=sceneEnd;
+ draw(t);
+ requestAnimationFrame(frame);
+ }
+ function draw(time){
+ var lw=scene.canvas.width,lh=scene.canvas.height;
+ var cw=size.w,ch=size.h;
+ if(!cw||!ch)return;
+ var s=Math.min(cw/lw,ch/lh);
+ var ox=(cw-lw*s)/2;
+ var oy=(ch-lh*s)/2;
+ ctx.setTransform(dpr,0,0,dpr,0,0);
+ ctx.clearRect(0,0,cw,ch);
+ ctx.save();
+ ctx.translate(ox,oy);
+ ctx.scale(s,s);
+ ctx.beginPath();
+ ctx.rect(0,0,lw,lh);
+ ctx.clip();
+ ctx.fillStyle=scene.canvas.background;
+ ctx.fillRect(0,0,lw,lh);
+ for(var i=0;i
+
+
+
+
+Animatr — Beispiel
+
+
+
+ Animatr Export
+ Diese HTML-Datei enthält Runtime + Szene inline — einfach im Browser öffnen.
+
+
+
+
+
+
+
Einbinden in eine eigene Seite
+
<div style="width: 800px; height: 450px;">
+ <script src="animation.js"></script>
+</div>
+
+
+
+`
+}
+
+export function downloadExampleHtml(scene: Scene, filename = 'animation-example.html') {
+ const source = generateExampleHtml(scene)
+ triggerDownload(source, filename, 'text/html')
+}
+
+function triggerDownload(source: string, filename: string, mime: string) {
+ const blob = new Blob([source], { type: mime })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
diff --git a/src/lib/fileLoader.ts b/src/lib/fileLoader.ts
new file mode 100644
index 0000000..a81c708
--- /dev/null
+++ b/src/lib/fileLoader.ts
@@ -0,0 +1,53 @@
+export function readAsDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => resolve(reader.result as string)
+ reader.onerror = () => reject(reader.error)
+ reader.readAsDataURL(file)
+ })
+}
+
+export function loadImage(src: string): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+ img.onload = () => resolve(img)
+ img.onerror = () => reject(new Error(`failed to load image: ${src.slice(0, 60)}…`))
+ img.src = src
+ })
+}
+
+export interface Dimensions {
+ width: number
+ height: number
+}
+
+export function parseSvgDimensions(svgText: string): Dimensions | null {
+ const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml')
+ const svg = doc.documentElement
+ if (!svg || svg.nodeName !== 'svg') return null
+
+ const parseLen = (attr: string | null) => {
+ if (!attr) return 0
+ const num = parseFloat(attr)
+ return Number.isFinite(num) ? num : 0
+ }
+
+ let w = parseLen(svg.getAttribute('width'))
+ let h = parseLen(svg.getAttribute('height'))
+
+ if (!w || !h) {
+ const vb = svg.getAttribute('viewBox')?.trim().split(/[\s,]+/).map(Number)
+ if (vb && vb.length === 4 && vb.every((n) => Number.isFinite(n))) {
+ if (!w) w = vb[2]
+ if (!h) h = vb[3]
+ }
+ }
+
+ if (!w || !h) return null
+ return { width: w, height: h }
+}
+
+export function isSvgFile(file: File): boolean {
+ return file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
+}
diff --git a/src/lib/interpolate.ts b/src/lib/interpolate.ts
new file mode 100644
index 0000000..e43714a
--- /dev/null
+++ b/src/lib/interpolate.ts
@@ -0,0 +1,75 @@
+import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
+
+const easings: Record number> = {
+ linear: (t) => t,
+ easeIn: (t) => t * t,
+ easeOut: (t) => 1 - (1 - t) ** 2,
+ easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
+}
+
+function merge(base: AnimatableProps, partial: Partial): AnimatableProps {
+ return { ...base, ...partial }
+}
+
+function lerp(a: number, b: number, t: number) {
+ return a + (b - a) * t
+}
+
+function parseHex(hex: string): [number, number, number] | null {
+ const h = hex.trim().replace('#', '')
+ const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
+ if (full.length < 6) return null
+ const n = parseInt(full.slice(0, 6), 16)
+ if (Number.isNaN(n)) return null
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
+}
+
+function formatHex(r: number, g: number, b: number): string {
+ const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)))
+ const h = (v: number) => clamp(v).toString(16).padStart(2, '0')
+ return `#${h(r)}${h(g)}${h(b)}`
+}
+
+export function lerpColor(a: string, b: string, t: number): string {
+ const pa = parseHex(a)
+ const pb = parseHex(b)
+ if (!pa || !pb) return t < 0.5 ? a : b
+ return formatHex(lerp(pa[0], pb[0], t), lerp(pa[1], pb[1], t), lerp(pa[2], pb[2], t))
+}
+
+function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): AnimatableProps {
+ const out: AnimatableProps = {
+ x: lerp(a.x, b.x, t),
+ y: lerp(a.y, b.y, t),
+ scaleX: lerp(a.scaleX, b.scaleX, t),
+ scaleY: lerp(a.scaleY, b.scaleY, t),
+ rotation: lerp(a.rotation, b.rotation, t),
+ opacity: lerp(a.opacity, b.opacity, t),
+ }
+ if (a.fill && b.fill) out.fill = lerpColor(a.fill, b.fill, t)
+ else if (b.fill) out.fill = b.fill
+ else if (a.fill) out.fill = a.fill
+ return out
+}
+
+export function sortedKeyframes(layer: Layer): Keyframe[] {
+ return [...layer.keyframes].sort((a, b) => a.time - b.time)
+}
+
+export function sampleLayer(layer: Layer, time: number): AnimatableProps {
+ const kfs = sortedKeyframes(layer)
+ if (!kfs.length) return { ...layer.base }
+ if (time <= kfs[0].time) return merge(layer.base, kfs[0].props)
+ const last = kfs[kfs.length - 1]
+ if (time >= last.time) return merge(layer.base, last.props)
+ for (let i = 0; i < kfs.length - 1; i++) {
+ const a = kfs[i]
+ const b = kfs[i + 1]
+ if (time >= a.time && time <= b.time) {
+ const raw = (time - a.time) / (b.time - a.time)
+ const t = easings[b.easing](raw)
+ return lerpProps(merge(layer.base, a.props), merge(layer.base, b.props), t)
+ }
+ }
+ return { ...layer.base }
+}
diff --git a/src/lib/render.ts b/src/lib/render.ts
new file mode 100644
index 0000000..d33dfd8
--- /dev/null
+++ b/src/lib/render.ts
@@ -0,0 +1,86 @@
+import type { Asset, Scene } from '@/types/scene'
+import { sampleLayer } from './interpolate'
+
+export async function loadSceneImages(scene: Scene): Promise> {
+ const result: Record = {}
+ const images = scene.assets.filter(
+ (a): a is Extract => a.kind === 'image',
+ )
+ await Promise.all(
+ images.map(
+ (a) =>
+ new Promise((resolve) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+ img.onload = () => {
+ result[a.id] = img
+ resolve()
+ }
+ img.onerror = () => resolve()
+ img.src = a.src
+ }),
+ ),
+ )
+ return result
+}
+
+export function drawSceneFrame(
+ ctx: CanvasRenderingContext2D,
+ scene: Scene,
+ time: number,
+ images: Record,
+) {
+ const lw = scene.canvas.width
+ const lh = scene.canvas.height
+
+ ctx.save()
+ ctx.beginPath()
+ ctx.rect(0, 0, lw, lh)
+ ctx.clip()
+ ctx.fillStyle = scene.canvas.background
+ ctx.fillRect(0, 0, lw, lh)
+
+ const assetMap = new Map()
+ for (const a of scene.assets) assetMap.set(a.id, a)
+
+ for (const layer of scene.layers) {
+ if (!layer.visible) continue
+ const asset = assetMap.get(layer.assetId)
+ if (!asset) continue
+ const p = sampleLayer(layer, time)
+
+ ctx.save()
+ ctx.globalAlpha *= p.opacity
+ ctx.translate(p.x, p.y)
+ ctx.rotate((p.rotation * Math.PI) / 180)
+ ctx.scale(p.scaleX, p.scaleY)
+
+ if (asset.kind === 'rect') {
+ const offX = layer.offsetX ?? asset.width / 2
+ const offY = layer.offsetY ?? asset.height / 2
+ ctx.fillStyle = p.fill ?? asset.fill
+ ctx.fillRect(-offX, -offY, asset.width, asset.height)
+ } else if (asset.kind === 'circle') {
+ ctx.fillStyle = p.fill ?? asset.fill
+ ctx.beginPath()
+ ctx.arc(0, 0, asset.radius, 0, Math.PI * 2)
+ ctx.fill()
+ } else if (asset.kind === 'text') {
+ ctx.fillStyle = p.fill ?? asset.fill
+ ctx.font = `${asset.fontSize}px sans-serif`
+ ctx.textBaseline = 'top'
+ ctx.fillText(asset.text, 0, 0)
+ } else if (asset.kind === 'image') {
+ const img = images[asset.id]
+ if (img) {
+ const offX = layer.offsetX ?? asset.width / 2
+ const offY = layer.offsetY ?? asset.height / 2
+ ctx.drawImage(img, -offX, -offY, asset.width, asset.height)
+ }
+ }
+
+ ctx.restore()
+ }
+
+ ctx.restore()
+}
diff --git a/src/lib/serverExport.ts b/src/lib/serverExport.ts
new file mode 100644
index 0000000..13d1f86
--- /dev/null
+++ b/src/lib/serverExport.ts
@@ -0,0 +1,119 @@
+import type { Scene } from '@/types/scene'
+
+const DEFAULT_SERVER = 'http://localhost:6000'
+const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
+
+export interface ServerRenderOptions {
+ onProgress?: (progress: number) => void
+ signal?: AbortSignal
+ fps?: number
+ crf?: number
+ preset?: string
+}
+
+export interface ServerRenderResult {
+ blob: Blob
+ extension: string
+}
+
+export function getServerUrl(): string {
+ return SERVER_URL
+}
+
+export async function renderOnServer(
+ scene: Scene,
+ opts: ServerRenderOptions = {},
+): Promise {
+ const baseUrl = SERVER_URL
+ const startRes = await fetch(`${baseUrl}/render`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ scene,
+ options: {
+ fps: opts.fps,
+ crf: opts.crf,
+ preset: opts.preset,
+ },
+ }),
+ signal: opts.signal,
+ })
+ if (!startRes.ok) {
+ const body = await startRes.text().catch(() => '')
+ throw new Error(`Server start fehlgeschlagen (${startRes.status}): ${body.slice(0, 200)}`)
+ }
+ const { jobId } = (await startRes.json()) as { jobId: string }
+ if (!jobId) throw new Error('Server lieferte keine jobId')
+
+ const downloadUrl = await new Promise((resolve, reject) => {
+ const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
+ const onAbort = () => {
+ try {
+ es.close()
+ } catch {
+ /* noop */
+ }
+ fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {})
+ reject(new DOMException('aborted', 'AbortError'))
+ }
+ if (opts.signal) {
+ if (opts.signal.aborted) return onAbort()
+ opts.signal.addEventListener('abort', onAbort, { once: true })
+ }
+ es.onmessage = (evt) => {
+ let data: {
+ progress?: number
+ done?: boolean
+ error?: string | null
+ url?: string | null
+ }
+ try {
+ data = JSON.parse(evt.data)
+ } catch {
+ return
+ }
+ if (typeof data.progress === 'number') opts.onProgress?.(data.progress)
+ if (data.done) {
+ es.close()
+ opts.signal?.removeEventListener('abort', onAbort)
+ if (data.error) reject(new Error(data.error))
+ else if (data.url) resolve(`${baseUrl}${data.url}`)
+ else reject(new Error('Server meldete fertig ohne Download-URL'))
+ }
+ }
+ es.onerror = () => {
+ es.close()
+ opts.signal?.removeEventListener('abort', onAbort)
+ reject(new Error('Verbindung zum Render-Server verloren'))
+ }
+ })
+
+ const fileRes = await fetch(downloadUrl, { signal: opts.signal })
+ if (!fileRes.ok) throw new Error(`Download fehlgeschlagen (${fileRes.status})`)
+ const blob = await fileRes.blob()
+ return { blob, extension: 'mp4' }
+}
+
+export async function downloadFromServer(
+ scene: Scene,
+ opts: ServerRenderOptions = {},
+): Promise {
+ const { blob, extension } = await renderOnServer(scene, opts)
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `animation.${extension}`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+export async function isServerReachable(): Promise {
+ try {
+ const res = await fetch(`${SERVER_URL}/health`, { method: 'GET' })
+ return res.ok
+ } catch {
+ return false
+ }
+}
diff --git a/src/lib/videoExport.ts b/src/lib/videoExport.ts
new file mode 100644
index 0000000..72eaf59
--- /dev/null
+++ b/src/lib/videoExport.ts
@@ -0,0 +1,135 @@
+import type { Scene } from '@/types/scene'
+import { drawSceneFrame, loadSceneImages } from './render'
+
+function effectiveEnd(scene: Scene): number {
+ let max = 0
+ for (const layer of scene.layers) {
+ for (const kf of layer.keyframes) {
+ if (kf.time > max) max = kf.time
+ }
+ }
+ if (max <= 0) return scene.duration
+ return Math.min(max, scene.duration)
+}
+
+export interface VideoExportResult {
+ blob: Blob
+ mimeType: string
+ extension: string
+}
+
+export interface VideoExportOptions {
+ onProgress?: (progress: number) => void
+ signal?: AbortSignal
+ bitsPerSecond?: number
+}
+
+const MIME_CANDIDATES = [
+ 'video/webm;codecs=vp9',
+ 'video/webm;codecs=vp8',
+ 'video/webm',
+ 'video/mp4;codecs=h264',
+ 'video/mp4',
+]
+
+export function pickMimeType(): string | null {
+ if (typeof MediaRecorder === 'undefined') return null
+ for (const mime of MIME_CANDIDATES) {
+ if (MediaRecorder.isTypeSupported?.(mime)) return mime
+ }
+ return null
+}
+
+export async function renderSceneToVideo(
+ scene: Scene,
+ opts: VideoExportOptions = {},
+): Promise {
+ const mimeType = pickMimeType()
+ if (!mimeType) {
+ throw new Error('MediaRecorder wird von diesem Browser nicht unterstützt.')
+ }
+
+ const canvas = document.createElement('canvas')
+ canvas.width = scene.canvas.width
+ canvas.height = scene.canvas.height
+ const ctx = canvas.getContext('2d')
+ if (!ctx) throw new Error('2D context unavailable')
+
+ const images = await loadSceneImages(scene)
+
+ drawSceneFrame(ctx, scene, 0, images)
+
+ const stream = canvas.captureStream(scene.fps)
+ const recorder = new MediaRecorder(stream, {
+ mimeType,
+ videoBitsPerSecond: opts.bitsPerSecond ?? 8_000_000,
+ })
+
+ const chunks: Blob[] = []
+ recorder.ondataavailable = (e) => {
+ if (e.data && e.data.size > 0) chunks.push(e.data)
+ }
+
+ const stopped = new Promise((resolve) => {
+ recorder.onstop = () => resolve()
+ })
+
+ recorder.start()
+ const startedAt = performance.now()
+ const renderDuration = effectiveEnd(scene)
+
+ const finished = new Promise((resolve, reject) => {
+ let raf = 0
+ const step = () => {
+ if (opts.signal?.aborted) {
+ cancelAnimationFrame(raf)
+ try {
+ recorder.stop()
+ } catch {
+ /* ignore */
+ }
+ stream.getTracks().forEach((t) => t.stop())
+ reject(new DOMException('aborted', 'AbortError'))
+ return
+ }
+ const elapsed = (performance.now() - startedAt) / 1000
+ const t = Math.min(elapsed, renderDuration)
+ drawSceneFrame(ctx, scene, t, images)
+ opts.onProgress?.(renderDuration > 0 ? Math.min(1, t / renderDuration) : 1)
+ if (elapsed >= renderDuration) {
+ try {
+ recorder.stop()
+ } catch {
+ /* ignore */
+ }
+ stream.getTracks().forEach((t) => t.stop())
+ resolve()
+ return
+ }
+ raf = requestAnimationFrame(step)
+ }
+ raf = requestAnimationFrame(step)
+ })
+
+ await finished
+ await stopped
+
+ const blob = new Blob(chunks, { type: mimeType })
+ const extension = mimeType.includes('mp4') ? 'mp4' : 'webm'
+ return { blob, mimeType, extension }
+}
+
+export async function downloadSceneAsVideo(
+ scene: Scene,
+ opts: VideoExportOptions = {},
+): Promise {
+ const { blob, extension } = await renderSceneToVideo(scene, opts)
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `animation.${extension}`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..a73c994
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,11 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import VueKonva from 'vue-konva'
+import App from './App.vue'
+import './composables/useTheme'
+import './styles/global.css'
+
+const app = createApp(App)
+app.use(createPinia())
+app.use(VueKonva)
+app.mount('#app')
diff --git a/src/stores/scene.ts b/src/stores/scene.ts
new file mode 100644
index 0000000..d05b072
--- /dev/null
+++ b/src/stores/scene.ts
@@ -0,0 +1,227 @@
+import { defineStore } from 'pinia'
+import type { AnimatableProps, Asset, Easing, Keyframe, Layer, Scene } from '@/types/scene'
+import { DEFAULT_BASE_PROPS } from '@/types/scene'
+import { sampleLayer } from '@/lib/interpolate'
+
+const uid = () => Math.random().toString(36).slice(2, 10)
+
+type AssetInit = Asset extends infer A ? (A extends Asset ? Omit : never) : never
+
+const KEYFRAME_EPSILON = 1e-3
+
+interface State {
+ scene: Scene
+ selectedLayerId: string | null
+ playhead: number
+ playing: boolean
+}
+
+export const useSceneStore = defineStore('scene', {
+ state: (): State => ({
+ scene: {
+ version: 1,
+ canvas: { width: 1280, height: 720, background: '#ffffff' },
+ duration: 10,
+ fps: 60,
+ assets: [],
+ layers: [],
+ },
+ selectedLayerId: null,
+ playhead: 0,
+ playing: false,
+ }),
+ getters: {
+ lastKeyframeTime(state): number {
+ let max = 0
+ for (const layer of state.scene.layers) {
+ for (const kf of layer.keyframes) {
+ if (kf.time > max) max = kf.time
+ }
+ }
+ return Math.min(state.scene.duration, max)
+ },
+ effectiveEnd(state): number {
+ let max = 0
+ for (const layer of state.scene.layers) {
+ for (const kf of layer.keyframes) {
+ if (kf.time > max) max = kf.time
+ }
+ }
+ if (max <= 0) return state.scene.duration
+ return Math.min(max, state.scene.duration)
+ },
+ },
+ actions: {
+ addAsset(asset: AssetInit): Asset {
+ const created = { ...asset, id: uid() } as Asset
+ this.scene.assets.push(created)
+ return created
+ },
+ addLayer(assetId: string, name?: string): Layer {
+ const asset = this.scene.assets.find((a) => a.id === assetId)
+ const base: AnimatableProps = {
+ ...DEFAULT_BASE_PROPS,
+ x: this.scene.canvas.width / 2,
+ y: this.scene.canvas.height / 2,
+ }
+ if (asset && 'fill' in asset) base.fill = asset.fill
+ const layer: Layer = {
+ id: uid(),
+ name: name ?? asset?.name ?? 'Layer',
+ assetId,
+ visible: true,
+ base,
+ keyframes: [
+ {
+ id: uid(),
+ time: 0,
+ props: { ...base },
+ easing: 'linear',
+ },
+ ],
+ }
+ if (asset && (asset.kind === 'rect' || asset.kind === 'image')) {
+ layer.offsetX = asset.width / 2
+ layer.offsetY = asset.height / 2
+ }
+ this.scene.layers.push(layer)
+ this.selectedLayerId = layer.id
+ return layer
+ },
+ removeLayer(id: string) {
+ this.scene.layers = this.scene.layers.filter((l) => l.id !== id)
+ if (this.selectedLayerId === id) this.selectedLayerId = null
+ },
+ selectLayer(id: string | null) {
+ this.selectedLayerId = id
+ },
+ setPlayhead(t: number) {
+ this.playhead = Math.max(0, Math.min(this.scene.duration, t))
+ },
+ setDuration(seconds: number) {
+ this.scene.duration = Math.max(0.1, seconds)
+ if (this.playhead > this.scene.duration) this.playhead = this.scene.duration
+ },
+ setFps(fps: number) {
+ this.scene.fps = Math.max(1, Math.round(fps))
+ },
+ setCanvasSize(width: number, height: number) {
+ this.scene.canvas.width = Math.max(1, Math.round(width))
+ this.scene.canvas.height = Math.max(1, Math.round(height))
+ },
+ setCanvasBackground(color: string) {
+ this.scene.canvas.background = color
+ },
+ upsertKeyframe(layerId: string, time: number, props: Partial) {
+ const layer = this.scene.layers.find((l) => l.id === layerId)
+ if (!layer) return
+ const existing = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
+ if (existing) {
+ existing.props = { ...existing.props, ...props }
+ return
+ }
+ const snapshot = sampleLayer(layer, time)
+ const kf: Keyframe = {
+ id: uid(),
+ time,
+ props: { ...snapshot, ...props },
+ easing: 'linear',
+ }
+ layer.keyframes.push(kf)
+ layer.keyframes.sort((a, b) => a.time - b.time)
+ },
+ removeKeyframeAt(layerId: string, time: number) {
+ const layer = this.scene.layers.find((l) => l.id === layerId)
+ if (!layer) return
+ layer.keyframes = layer.keyframes.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON)
+ },
+ setKeyframeEasing(layerId: string, time: number, easing: Easing) {
+ const layer = this.scene.layers.find((l) => l.id === layerId)
+ if (!layer) return
+ const kf = layer.keyframes.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
+ if (kf) kf.easing = easing
+ },
+ setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
+ const layer = this.scene.layers.find((l) => l.id === layerId)
+ if (!layer) return
+ const kf = layer.keyframes.find((k) => k.id === keyframeId)
+ if (!kf) return
+ kf.time = Math.max(0, Math.min(this.scene.duration, newTime))
+ layer.keyframes.sort((a, b) => a.time - b.time)
+ },
+ shiftLayerPivot(
+ layerId: string,
+ dWorldX: number,
+ dWorldY: number,
+ dLocalX: number,
+ dLocalY: number,
+ ) {
+ const layer = this.scene.layers.find((l) => l.id === layerId)
+ if (!layer) return
+ layer.offsetX = (layer.offsetX ?? 0) + dLocalX
+ layer.offsetY = (layer.offsetY ?? 0) + dLocalY
+ layer.base.x += dWorldX
+ layer.base.y += dWorldY
+ for (const kf of layer.keyframes) {
+ if (kf.props.x !== undefined) kf.props.x += dWorldX
+ if (kf.props.y !== undefined) kf.props.y += dWorldY
+ }
+ },
+ duplicateKeyframe(layerId: string, keyframeId: string): string | null {
+ const layer = this.scene.layers.find((l) => l.id === layerId)
+ if (!layer) return null
+ const source = layer.keyframes.find((k) => k.id === keyframeId)
+ if (!source) return null
+ const fps = this.scene.fps > 0 ? this.scene.fps : 60
+ const offset = Math.max(3 / fps, 0.1)
+ const duration = this.scene.duration
+ const occupied = (t: number) =>
+ layer.keyframes.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
+ let target = source.time + offset
+ while (occupied(target) && target < duration) target += offset
+ if (target > duration) target = duration
+ if (occupied(target)) {
+ target = source.time - offset
+ while (occupied(target) && target > 0) target -= offset
+ if (target < 0) target = 0
+ if (occupied(target)) return null
+ }
+ const newKf: Keyframe = {
+ id: uid(),
+ time: target,
+ props: { ...source.props },
+ easing: source.easing,
+ }
+ layer.keyframes.push(newKf)
+ layer.keyframes.sort((a, b) => a.time - b.time)
+ return newKf.id
+ },
+ play() {
+ if (this.playing) return
+ this.playing = true
+ },
+ pause() {
+ this.playing = false
+ },
+ togglePlay() {
+ if (this.playing) this.pause()
+ else {
+ const end = this.effectiveEnd
+ if (end <= 0) return
+ if (this.playhead >= end) this.playhead = 0
+ this.play()
+ }
+ },
+ tick(deltaSeconds: number) {
+ if (!this.playing) return
+ const end = this.effectiveEnd
+ const next = this.playhead + deltaSeconds
+ if (next >= end) {
+ this.playhead = end
+ this.playing = false
+ } else {
+ this.playhead = next
+ }
+ },
+ },
+})
diff --git a/src/styles/global.css b/src/styles/global.css
new file mode 100644
index 0000000..dc79f20
--- /dev/null
+++ b/src/styles/global.css
@@ -0,0 +1,140 @@
+:root {
+ --bg: #17171b;
+ --panel: #1f1f24;
+ --panel-alt: #272730;
+ --panel-hover: #2f2f3a;
+ --border: #2e2e37;
+ --border-strong: #3a3a46;
+ --text: #ececf1;
+ --text-dim: #9a9aa6;
+ --text-dim-strong: #c7c7d1;
+ --accent: #6ea8ff;
+ --accent-soft: rgba(110, 168, 255, 0.14);
+ --accent-soft-strong: rgba(110, 168, 255, 0.22);
+ --danger: #ff7a7a;
+ --danger-soft: rgba(255, 122, 122, 0.12);
+ --radius: 6px;
+ --radius-sm: 4px;
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
+ --shadow-md: 0 4px 18px rgba(0, 0, 0, 0.35);
+ --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
+ color-scheme: dark;
+}
+
+:root[data-theme='light'] {
+ --bg: #f3f3f6;
+ --panel: #ffffff;
+ --panel-alt: #f5f5f8;
+ --panel-hover: #eaeaf0;
+ --border: #e2e2e7;
+ --border-strong: #c9c9d0;
+ --text: #1c1c20;
+ --text-dim: #72727a;
+ --text-dim-strong: #3c3c42;
+ --accent: #2b6af5;
+ --accent-soft: rgba(43, 106, 245, 0.1);
+ --accent-soft-strong: rgba(43, 106, 245, 0.18);
+ --danger: #d63c3c;
+ --danger-soft: rgba(214, 60, 60, 0.09);
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 8px 24px rgba(20, 25, 40, 0.12);
+ color-scheme: light;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: 13px;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overflow: hidden;
+ user-select: none;
+ -webkit-user-select: none;
+ transition: background 180ms var(--ease), color 180ms var(--ease);
+}
+
+input,
+textarea,
+[contenteditable='true'] {
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+button {
+ font: inherit;
+ color: inherit;
+ background: var(--panel-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ padding: 5px 10px;
+ cursor: pointer;
+ transition: background 140ms var(--ease), border-color 140ms var(--ease),
+ color 140ms var(--ease), transform 120ms var(--ease);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+button:hover:not(:disabled) {
+ background: var(--panel-hover);
+ border-color: var(--border-strong);
+}
+
+button:active:not(:disabled) {
+ transform: translateY(1px);
+}
+
+button:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+input,
+select,
+textarea {
+ transition: border-color 140ms var(--ease), background 140ms var(--ease);
+}
+
+input:focus,
+select:focus,
+textarea:focus {
+ outline: none;
+ border-color: var(--accent) !important;
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 10px;
+ border: 2px solid var(--panel);
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--border-strong);
+}
+
+::selection {
+ background: var(--accent-soft-strong);
+ color: var(--text);
+}
diff --git a/src/types/scene.ts b/src/types/scene.ts
new file mode 100644
index 0000000..2b7bccc
--- /dev/null
+++ b/src/types/scene.ts
@@ -0,0 +1,90 @@
+export type Easing = 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
+
+export interface AnimatableProps {
+ x: number
+ y: number
+ scaleX: number
+ scaleY: number
+ rotation: number
+ opacity: number
+ fill?: string
+}
+
+export interface Keyframe {
+ id: string
+ time: number
+ props: Partial
+ easing: Easing
+}
+
+export type AssetKind = 'image' | 'rect' | 'circle' | 'text'
+
+export interface ImageAsset {
+ id: string
+ kind: 'image'
+ name: string
+ src: string
+ width: number
+ height: number
+}
+
+export interface RectAsset {
+ id: string
+ kind: 'rect'
+ name: string
+ width: number
+ height: number
+ fill: string
+}
+
+export interface CircleAsset {
+ id: string
+ kind: 'circle'
+ name: string
+ radius: number
+ fill: string
+}
+
+export interface TextAsset {
+ id: string
+ kind: 'text'
+ name: string
+ text: string
+ fontSize: number
+ fill: string
+}
+
+export type Asset = ImageAsset | RectAsset | CircleAsset | TextAsset
+
+export interface Layer {
+ id: string
+ name: string
+ assetId: string
+ visible: boolean
+ base: AnimatableProps
+ keyframes: Keyframe[]
+ offsetX?: number
+ offsetY?: number
+}
+
+export interface Scene {
+ version: 1
+ canvas: {
+ width: number
+ height: number
+ background: string
+ }
+ duration: number
+ fps: number
+ assets: Asset[]
+ layers: Layer[]
+}
+
+export const DEFAULT_BASE_PROPS: AnimatableProps = {
+ x: 0,
+ y: 0,
+ scaleX: 1,
+ scaleY: 1,
+ rotation: 0,
+ opacity: 1,
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..323c78a
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1,7 @@
+///
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1f94304
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "noEmit": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "types": ["vite/client"],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "vite.config.ts"]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..a52797c
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+})