From d6f9ddc30c585144948c7447d6ca6da5254f1023 Mon Sep 17 00:00:00 2001 From: unaisshemim Date: Sun, 9 Nov 2025 18:28:19 +0530 Subject: [PATCH 1/2] feat: add delete model functionality to ModelDownloader and API --- electron/app/components/ModelDownloader.tsx | 58 +++++++++++++++++--- electron/bun.lockb | Bin 296975 -> 302885 bytes electron/lib/preload/api.ts | 6 ++ electron/lib/preload/index.d.ts | 1 + rust/src/bin/core.rs | 7 +++ rust/src/types.rs | 13 +++++ rust/src/whisper.rs | 47 ++++++++++++++++ 7 files changed, 125 insertions(+), 7 deletions(-) diff --git a/electron/app/components/ModelDownloader.tsx b/electron/app/components/ModelDownloader.tsx index acd8f9b..63f79cc 100644 --- a/electron/app/components/ModelDownloader.tsx +++ b/electron/app/components/ModelDownloader.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' import { toast } from 'sonner' -import { Download } from 'lucide-react' +import { Download, Trash } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip' import { ModelInfo, WhisperModel } from '@/lib/preload' @@ -79,6 +79,37 @@ export function ModelDownloader({ onSelectModel(model.name) } + const handleDelete = async (modelName: WhisperModel, e: React.MouseEvent) => { + e.stopPropagation() + + // Check if model is currently selected + if (selectedModel === modelName) { + const confirmed = window.confirm( + `The ${modelName} model is currently selected. Deleting it will switch to the whisper-1 model. Continue?` + ) + if (!confirmed) return + } else { + const confirmed = window.confirm( + `Are you sure you want to delete the ${modelName} model? This will free up disk space but you'll need to download it again to use it.` + ) + if (!confirmed) return + } + + try { + await window.rust.deleteModel(modelName) + setModels((prev) => prev.map((m) => (m.name === modelName ? { ...m, downloaded: false } : m))) + + // If deleted model was selected, switch to whisper-1 + if (selectedModel === modelName) { + onSelectModel('whisper-1') + } + + toast.success(`${modelName} model deleted successfully`) + } catch (error: any) { + toast.error(`Failed to delete ${modelName} model: ${error.message || 'Unknown error'}`) + } + } + return (
@@ -134,12 +165,25 @@ export function ModelDownloader({
)} - {!canSelect && !isDownloading && ( -
handleDownload(model.name, e)} - className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer hover:text-primary" - > - + {!isDownloading && ( +
+ {model.downloaded && model.name !== 'whisper-1' && ( +
handleDelete(model.name as WhisperModel, e)} + className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer hover:text-destructive" + title={`Delete ${model.name} model`} + > + +
+ )} + {!canSelect && ( +
handleDownload(model.name as WhisperModel, e)} + className="opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer hover:text-primary" + > + +
+ )}
)}
diff --git a/electron/bun.lockb b/electron/bun.lockb index ee006bac350fee1d6313fea7a07f2468c613f326..4b9d6669f30bddc91e6daf362f36d928104dfc9e 100755 GIT binary patch delta 43037 zcmc(|2V7J~7dC!(7i6!B?ka+~s9-}n80zu&j<6Jyf%S#}C9t0B7(}t1)?L8F5jQi}Q)KOHSRon-IQ$>mC zIe{5j=@~U}PZeA@@(L>9z9jC)K#1g0@jps0E9BxwjH2R|84&UUJ`%h<_)u_Lj?OF3 z9U4^x@Jz=O=0TWRl8~L9Gd3ehxeVL~_f|Bg7Pxm=MF{}^4fp=wKHzje%A_dOz`Xe2e`7Kcn;fJ9B$@fMj)MZ?eJSBgHNXSPmm5?wDoHXtY zP6ba!I^CxTIoaY)5T=5{z%AfX;i|Z&7Pdnobwy5gO7aj`^$6}saVt2LTOGVYe_Z^l zGVi<60LTJfD1b8dMupUZSW&UGR6X32TvAF>LQYnCS|`{Wjm&=+oVxBQIC?36xh>w{ z)3*wGg*?OrPSw8%;OhMhPWZ6&j6`UsgjMHuy9-XWp93em&#J-gw;%T~X}+~4*Y=L> zoWZAVP51<%cTgw!-y(1-H8E+($YCfoH5>oKC)j3o^T{h$him8oPIZjH_!^p=k|e!; zSbB~o{qMTG?tb;SX}98@OgaUrl&@obUQQRJYq}zxEalUHoA!W^-;aB0^K#t7CtBoB z4CLyj0HA~n+!L3A3AiUywn93YvM=tbIT<5UGf1;{ZFk+O=H-R(YG*-;dUBlLgTN{G zl2D#|4!DYYD{v~Ug+I@?ER5fejUPEK1*KZT`F%NXl1I?cx}Df7mNJ?-l?kR58#07OsT6|i1GK`g!J!E8ZN}>|b zihFAraPpWxTMGXKrzr5PxIX|+(O@e$$-M(k9`rUi$z_0(U-ttixi;cH7(CAhfUig> z0Zs)xYr!+z0H*>@iu+H&sepChBsUM7DjFx!6GeKQxNizh`8{;Jfq$dB$i*|Vl5%pA zld|rheht{$kw<|ho%m=E1t-BlaeN%_1*a%*yfep#re_6W_8R_F-1~x)1($%6iJNud z^26h^(?_Q%O5biAUyF3|9a&HR?yx_3vjPFKL>*L20uy`i78;O&S{&Y!J5!ucHm!pK$wXKB@QT{@<@Zm( zDPy;Oyk+fd?;ES;MWR@0=Vhc)^l)l7uigf|+%JF+(NQF?9EM)*Ob zlX@m`{}Sn>#cgmJGbh0-f){{OJ=?({pO?P?7i9tDfm21v;NIYk;`u$S9r#w#*zEqm zsUtdrQ^iIh_ctmgd*1>t3w{Bd+4?>Kl_8mzr>K`l55L25x(Qg(XEs3fKDTRbXs0jDmC1up|Gr-O5-fbQ?7@QQDO zliY|@u3%DD7CH5pa%o)AjO4_etfVAm6BJh=cL{i9sxT&3N2Oq-1P)D!A2t=GmBM|wY@RfR9~xx zN<*pCaR*Q}sqZ$8+v~|xZlCOU7zUr4xTj`!nxWY{FJA)3kw6`>b|&Xos>1^nWy&nx z3N*ziu<_vJYx};wB7ZromX`sx1)VCkd+)b0#2DRGHq-^dSX&ZsNsoH zU!r+by|l{(G@I^M+IX)R7xP+Q;GVRZ3r_BFQQ#ABPxeh*!tpfR!=8Eh^+mx$Qbr~X zNzO_ff&?mf%RAg23p(-wUpFF04^K`&3{fJcbNQj!82vd}@p4@$*OSYTP94}6<&&MG z#wBM24zZOfTd!{6!MhYHktIJ~!BuI9J||0ew8fXL+BNh&Zjk_>3Q)(AUz11OUBxYO&33M=Pu>RHky?L4ioE=u;Obhgb`0*RwAAFZ zP4({ni$3Ap2Kd&$XHePWF!iPCO-k+}OZT@hR<54*EBb zc9ThI7*weMNKLuekm~N($Opz6a4H~S6L01_xTg-60fZFHh|kV;4!C6ZxsamngmWV?|j0Qn<03E zo!lxB;=T?zbzeeOLST59qVz^Svhwj=9RC!YM*C)P!WlSq-wbdneT!C+3{If@nhk~B zpAnb>2hCA_*~cv)-CsJiblf8-kXm>bk+%Z)ceeKBtLE+coTJ_cInPc{cq9!x7ER^6kl-CWy>j^o?v_G6`Uu>%XoAEl_dwfhy?aPAjbz*W=V4uaPQ_s4xH!5^TUs<=N5UPWoF ztMY$ zIb<2V(&ZD?=R0rPM}bD0|4!AUSCm#r)*+2P2HVq!DD`uJ8f=;2!J6oeK%?1Uz&r_A zB=V0fGbl>!4YV=P@=VuUA7xver5b^RI}rbKYAGQ@*=!-fAt=(#Mn$S_I4O0iUa@5c zN14|FjdenewoF@;xskh~GzAK#ee|}_uqg8wfqDqkj~Zb96etX7mn}_b>H9!i03}h| zMt@-{#-&vk&PSx^PSp`m03H{0MVsu2mHNGM}l6BtE}aPNn@->?O? z3AbBn2N1H9RLVnB>R%)W)JR3rhZ>*;jy#dPUp*mQ}SLZiGSJ2B<7k2bYR6=L4lCN)-Jb z8D-uLG=xeJH9=GK@ExFifC^CY(9q@>YHfka1{iIjBce>xfl^z`3t`(wo1;y@8UmAb zaSeV4+R-ULyb1Z|AP|mlLTyhXtmY_;?!iu=rj|PcI*{Eo>C4*&Usq*&O}_$m_Gnv% z&1zbTz%<0E1lyC?XcMr3P8e@&AH;-SE-1P4cA#Wzhhbnu(@UUaT4@4XM!4173c-^+ zLxNgsO*4S@cS1GM+W@J<9HKkYqs(EjowH$d zrCvN%Eb46x*D8%%(J3v0R~&>WNTS~-JA-r zJ;{wW0i&*!?c#3G1R>pdcye?cP*+*Bz;qla>1wwYbTuVcD(r^AR`YRyt_>hn{V~1t zaM6HD%>n9Sfs7!l`2av|hzoo3p*R!skgKxfofm+HIja>CW>q~fMQO&e2U>FnK+fGl zb54}W29!)?Zxs3VBY@7{q6JWMohrOlQWT~h5oO*8)LAKb1|rODpj4TZ)qKFW0p|A( z&K3~OqYz)4qL^tkB%FQP7ShJ59tKE_ag$+LRs9tu8b~#a8OOZY52)3tQUts3D0MT? z=0JJeM+h+m@Zl^c4c@voKpR6$5g$)2`b`4rJi(OEM#Cfj88@Ks5n$b{RfF{{qyxBQ#f5 z!)oxLuCq@T{>@YqK#Io5TC4##+1%dsmug0wG^>vs;a#*dd@a!y06FP;aQOfRd}b!8W3jeAQH2Q5pc$ z+Y3Oin+E`;VkOFL#DEs{kTU``+M8ppQinT@tX>Pw=7B()QT+mjuiK*3_kdEN_BNR= z03oj_A>?c!5z(f)e0Ikqt9`K6!Z>*ew5yY(=CbCI^{|tn5{0bh3+9tR+X9vT#bwLa z=R*PERr{2*VRG^812k5aYYXi}*3wpjre#QpLW;C3^|JXaKq}f%At_fIW^D&l+E8;7 zH619quYHlEo&eGch{0jMvVq)1JzQF_2q<}&tVVND<1L_+o!z{u(^fdNQgro8&fSLnx)jI@hD{?7!7Y|JV8UoZ{A0M>DdLJkis$(Bi)Z5+J{XPjh zFF;ib@={Mbh8MD%y8xv^Wjo!q_%I6)jT&ir#rCwd)dY}i?CxwR)f6fQ1)PLe?>}Ax zHKpvLnQvH0lG-Xw#r^!I$S+IP3~jPt>^R%S7BV{81WX&s_Cjg+Z2;;#dP-`M^g2*2 z>|yd3MhQ_(K*-Z%e!cDK$5HBVpxO*#?-g||ATo+0l#!{<3qdcD-)zfV7-cREo7&wM zDdlOZQ9A)8TeRuf#=IUew#KM^Q} zIi12k7;cC%05Kx$p@%QsJdm5F0wJmRVgyB*!&~v->x7Q6ngF&bBDx{4#% zVyg*Y(E@5CH%cy~LMqUr#qI)BRNX&{m5ps*Ty1lK7Om|Yphb;S8BkFjl7JR%#g1ZS z{{UK4-}W8ETq^nkiy7=CrUIq8#7&^KkP%k%HvlP+%2^L{VMnV8)jLb-w3vGyXfG#G zETFLp>ClO{*Wu7KLoEb~foFGfA6sa4ly62{@f(bCa)Y5BhXh&AWx`fXSc8#1I{S2G z?hJ_Z7Hv`Z1Taks3RrL3k!q#f&O=UHn3;UKxeiYoHg@VzfLitTiqvg@Xl%M3=iDfi zn=e4CA#l@7BWia-k8)+_!iqRr_R=yxv4Ai+fwvUcR8-W$d?1fNCT_Kg> zfvzJ^=ghpwX+X*2r7RU`0tPp>V_3GgvKsq{?4l<&sDu=9f(3wRq{z~EG=2t@CS4~K zhpBxrJvhs1TB=Gvp^x+0z}z1wd5m6qm$unkDp42NZ-GX*$kP7C_+}9lGXlVdE}~e< zs98WY_jLH&c0gKpaELa==t5ZD-X_hOYA>KP`o*{yJX19SAzM1Oex}=nveK|Ob;dM` zp=K|S4#Fy|1c=aX2Q>rQLf@{honZcmRA&{mU5^?)Q1p{yd50Y^Kvz++$uXeLJ)n(4 za|KM!(9m8fip8--CQx*vGn$POfMS%`QSF$^=r@?FVE1riFCdN)M>}c&&Mv0y{muUd z>O5eyeSq2_UYO9Oo0kB!I#sO=ZSyIhG{?)3s-2^l%MNk1qo;N%(F3S+ZD{^ps}W#x zD%Pd4J3vW)8I%=@ziJQ`axF;K;V8L)wCo&OJw-JF(K^)5DCTx2G;@5Ed)NqA&ACi<3=rxmmjf8{ZjuA47e%5O zaC7x7S6}m{(_G^ipsUo%wid0F!kteRY0}h821-%NT~wq!iBaDJ z+6t)sXhi)5h^xVZQh6;7naMzk_Q(%Fod<~a1kGGIjrWtN76;R`I!Fdefl#85&9RyQ zYE=tVn^H}8fVOpNBt1uPZ<`LC_%jDXQ4ptCm-q|Ak<%uP4eJq)gzOSOZzY~_W?q-kbS^|#R7pk`ipk5M}bn+ zQr1md!{eSlI!-4PGOxkEFRL|wez{4Ed~9MV&{(W_>=E_hTeD$#oZ&Q7F7 zM$b(^siM+kCCmW!U9h=s4lhMZL1JmLl;>MOsm3Dm>V6^bboexQWZ@k>_BCD$fVMyp zR3m+=fNU8T8feed9wMzX00RsRTKSHSzjwM>*xTUVq3 z)dsMD`Or89lm>xI&1QGxB@JECen9&;p<2t;y+Ff&I=T5nK+TH?22SGLUJ3_k+>bpC_{yWQ*wRM_>`rc|Bkunnd`p)c~7^N~VsP)V2qsSBfwm8S6_X}7L9 z4M-g1R3WF$0OkWggMjJ;iYFVGLmmL7<%>(1=Cm1#k^;1%ki`=!Jb(m9q7IN&!hL4) zCc2`Z18wG18C11JnWJX$14*euN!!!YRue!no&+_$)MG%K6Y9KFGODw=ZS2#N+7<}O z(NtsiL3KLNLN5FRpcn|26;0yt!)FffQ&~Bd%`i$=pd>3q6`VO>*EJ6)nN`a21@d#C zt(;K48fh|@tL0c!nidJjO_WNGtiA+Dj%;7TsXgZLCK?eFw0BX=i-3@UN=S7y9lsZ- zjECC1ta{Dovi96DiyFOr~-+3F`q$S|%+QAZkUGOi2PZg3~n2!MRc?v&;afLIB!SBPP!z5*Hs)aiIC zUo|e}x}ihGgIFpNAoZZ*h#FYZy8t5A^3EX)dL_|fCm;D zB>*U+;}M?!Fb!-7&`q3WG*L~n3AI znJcVxji_2oR`Y<8uerXzcnm0&AV(M47GX85ay_!qLcQ+@ppBg>ld9McY)#uC5dsP6 z=vt%y07$c*qlTxQtRAbmGQ5GS5342sWCU3#**MBvAW%nM3UrTwHiE3X-C%0nHGGAH z=d_OQHXi}n!li23WK~?t{Z|nm9#94XjfJe6gtbVw3Mi>16+leGfz)?ETL9GySss;Z zt#d`W(+&q34p~}ja7V=3peBH>t)-Q~H$W+-$;vceR72K_<6Y;NW}XMs)vDCAivpDs z1K)R7+2Gm;zJKfmlp5h=#if8KV5u%uodnv7T3~N29rc?w@`2+nQ2y>&f1sg|Eh$hp zJ~IIA0aP}OTgI}9H^Idr)ij{wrXobp2-^mznN#*s+PvVtnY*K-Dzag7pk!I;g1mFb z040x+QZ+^GPJB)l7N2q=wcdQ;5N?4N;>;bb4-U7-(noB4|-c<+e- zE!4?5*Ze*Zsz9~|mMpNEL_P^(;)HKi-_A>O6B=qA<2wo{P1>@%q&HR9rc+q8Gz6j` zpe5Kscag&UmFjy)A%$fY6gVQv*z`kgNqdx4Cj%kxLAsbLSGQG70D}O!SW*2MC>dO? zw(`}oAMwo6acmipR&%ld?MhRGR@VsBz9KRI2!w{7lZI`gj6OR!YG0?AdIAZCoTC!F z!}vBp=TWC>)4(pE)EJi{%@2V(Pp#CmQRb>2b6MFBJa8-qNw%K9y6i&KJ9bpQs7Av)YA3g-)Ip1?>PVnef@AHo2M9Tm)C!J-3Ae;9 zR|9GTpbt+ebpnRqD4--Oy@GZEQRcHiN_La6865}Ly9ZQ@h4%JQysfpDJDG<)vXYI{fNJB|dF!R_ z1lk4V(rEIrH9uI#Y}h9hwQEId*|tEVAy>+|htzza+JxhR9so*~be!QBUkSl?n)8?Y zjWPTAGKr7Q)eTh>0BW|hnzn~>zd)$t{2z}|4se@c;tR3|Rr5=r-JCLGjylvrs1Qk_Ce7# z_HdmEB+RJ}?X>~(2SA$umD3DgO56n+0@U#^%Iy0kFHq;G1|u&4D1{D}aA4jFl-x?n z^4a;ZK=q;oK9k2D;(aJU1?b07=5at>OCTRQAW-aY9g~;Y^DExpvNpbW>j0D*Cd7u=^#@MlSl&wE8ztnte=meW8PS%wg^lvK8?8wpJrR>KVt&af9De zO#n69NENhIPQ$NVBZU?d)agLUXYE1JbXFkt%=D^gnIo=ErD-k7+z}|%E>+@&od?u; zk;O;+DWK#9e8aRlT{Rx%`Z&ggngXbWT?WI{eLIk*&Oyig00?=A!*6In`W@q_)62Xa z0dkeBWkN@fXIoRI%hW^D$N0z{}vgg ziD5Xrw$|u04|q~L%`4OlpjxD{FI3H&g`gZinn#+i2|*_muI7D)TgMe`2b7HNGCR0$ z1=_?GIKnr*6qT!TKjF0--Mfk&+;cDrR-LxCS(DPM7bt`V%!7W1eC^bNxLld z+by6J-=${U9jcvkjRO3mYH)@Cyw8fDx14>cATcBkHugsUDt5qWeNPXG^i7>xe zEp?u2PQrMiW&gUJ+3g$dbRMD@FKy>E7ibrhAyI@Sc+wT1ot;oGnn=}p-||B>50{r% zjiUim5#&!5ZC_lhWBwXQmQ!~AyvBM#QPPT_8-ca}T3)E7z5Qjn1C*BHZUWU_Bk6IG zM-Ug#^1ai`pwwo1;bbzv_`=zLEDmgS$?1hnnSC9=0fn75<69s!Cpcolj$>Bi zb*`sln!2%2B~X*JuM~2C(4tKCyEes@1sLN5YVQ|%+~9{;GRBnCrnoquq>T)3d_tZE zlng43R7I1$aFchFD;ji*dx-<3$07g|iWKk!XtE3X^qAH3qw5#Ls%S^=0Lgb8g;Lz8 zaJ#rt#{eBvMD{zN16tkM5PV*gZ~piDNssTWy;f=1y674uGiA7 zFF0q^KXwN{SVnKr^ZRT)9+0~kTln*@ku#H*K7{3a=*JMuj9&Uyx^}FSmp(zatzd_j z{%4){PG(<6eIQD^-9masIAjJ1goVbcYJ=;+qv7?k`6mf!?%erj<9?UvG3D zl!C9Y6E!bllArpiAj(xYHffIdp6ocP|HzKkh60nXzJbo2%&?Pnt%Yw?LsFH0-sWQ#TT?$iI9mzj*%`+(eqr*R;PzSbK8yF6K-H*dT6(z`Mn&ZV`zzOD-uY$-4{*Xw7M`~C)Z z!o?3cFWH3oAJ>Oee))=jsCna%L|Vx?bsWn)t}mZA9xz1#1L%%B{)bNQGrbp7%jNY32ZvY$|x$p2T+W3o59Q{pwmntPfLYx z5PfC=!$11V1d5I-#Aglwx`bycABb`niM%kU@>u9@6BteR4+XXu82%|H&?AAp!{zem z4;m=lNg;fg$XpKBU4W5MmWs?Y!!8qY%Y~d5uwR5SD+E>n*d2krD==DvJQmn{0`md( z#E^&oaybg248Py!C+P8t_+m$e|7Zq`s~X>Lm*NQeUAV&`My}%md`if}CC4n^ob{QD?(+>i}+C%vZU6L0-i2$jun}YrXNZ4&) zq+v5q0y0+vzbi5~$8|KY>frYT7K>|tQSp6&wE$KPSPk$80&9uueN-?$h@g3z`CH+p z0|e;vNC>vZwNYS?1=a?bpTM37tSzu=0(&YjY%7%N0(&N~_P}Zg>}P?!f%=u20{lgQ z9UwRfRv@qa6&Nn*?IItUD>Q+*OoF3Xyat}}kT#$o$03)Tb1lIbU z9Iqq*(xDgV11ba``U@+01oj4P1xQX-S_t;R^)?~sDX_l4=x<8Xr;Nb*;rc2ta=fwv zdlT0tVB};bf%V6=Szu;?4M6=$c>$IKh)a5XP;nQSw-6iztPCaM;{y!;l)<1ExS&cZ zfs+;DK|cw(s=%mmLqPWh<}a`WU=KA6_74zXBET*HX%tighJW;JJf*w9Y6`ibz(R3G7QuTSS)yLfepv?eqb%Z>j*3v*PGFe^r=f2^*@%4%4C#6PF7C{zJ==@XcGBE zeSxLmI)j>tPXk~S+fzYffsvnt3Ar>}j~7_Dz|w(b3M@ik8Nl+!i2@@9m{0Q&J|d+z;b|15LhFDjRcl1u*L!#1&jg`963*EBEZoADbSE7#|Uf; zu8)h%O$9a<*oTlKPi_Vb|CDi{UBKwmLSW-@{cm98w5>(nTwLb^gX1Y}Gz|8ifSWY{ z$;sLRq!v#Ek%oi8+l$Q9RlY)*Hw2as%pX`hcn6Vp60UzkUix$ra+7iG4Y?uUodq@p z*9mzLOvFVO0ltmv5Ro}fU{irnxFv_`DzIs|UM`gBCa~$i)(EV-z-9nj1dM#Ahrnjy zda1zjdJ1qBz=Zd} z1hxd&UV$YD>>XhH+^AFWNfaRVJIV|QlA|O6!#`yyXcI8WG zptB&_V4MP-038FJ1kncLIH(nf#u$w$8ba+rG;C;W(^#gl+l)niudkiA1%Yk@Xd`G7 zXfx=2PynLY2jJDgKLq~>M1QUSW6&p{ouFNyFi;~k1$rP(_dtC!LjeZ$^GBi;kmMXv>e z>VqOcp`Z{@7^nd#925i!EWtjztgocbMxL3V-As1{yXgtI{1UVWl!EemfO>*xQ_~wn z8<~-yaUj~j(1v9cXf$XnC>xXm$^>PB=7HV?tpL&fWF}}9=m*%Z2dFD(Fo^af*n{-a zHXXfi6Ay|7jfC(h&`r>{xIYU@#dUp915hC7IxyNZJcrVQ!5ia#6?kt@A5bP6dqr<4 zmxasyNTStyIIbhy3QAnnm(Y2(f}vZ2+JIU!-)k7<{h{wb&>+xY&=e3YHFH6<7M=~7 z3Yr0W8?+d-1hfD&AM_q57IicQEdb30%>yk2jR(C4S_w*mF0?#O28{rH4*Jp^njFT( z*PtVyuRsSu?LniF=>yOf&~^|lac_ZWS$iJz4d`2t2dE6Id|mIOTg~QP(_3_rEc&{B zP(K?qTLfwWfl(-s7GzluccOf&V}_=zGv5 zPz-R|`ThwSf_s`x<3TiA(q@$wv%Vm0p8@NPrYfv=AioT!Rc2ubs8k>MSJC%99qFlR&h%B%R4bCvojuX#uc3 zAX+d|MYJBI_1|XDb!NVeHZ{a0S#~qBt^<+Ds6OdyHsbN`k1}qz0)=d2#Qj6+pHoC5XPDg9Ut+dKuW6+xh@_g;MbNw!W_}vN|p| zAr(a_kfgtVs~y}CPMG9$m?y7;*}*%Q%?f9&?<68cC8raQ!t%6)lIgURDlT1C>#U;4 zW9I8Gr;N=1Cw)kb!o|oUs5MbSn&teY_pkUmNwy8CUWHTXp4~tVtqP}5L&8ARh~gSb z@>IKXBUz6-`u;{)65D-8@7F-y$-a>Jjq(>ce$ja8-*ZY;;4B<_J3cdwW zf~FhI+X|jP)t@a@@~D>q07B8H?EDLTE&A~hIz!LT-7uhB!?pF^H|hq4289G+-AyMX zW~2l*Djzv7VD|*1LFZ)bEN-3l)4DL6#)~=Xnqi!4!705jB(%_XU)HF}3-u)z% z7L7~|mE|n{rQTP!g^hoy57!-FAL1_ZG{ik2zUH$L?abZB*MxXDq_Ob7EK(v@AFVm* zLYZzgm|&SNe*USV(w7!t}?7xt{-SUmX&m&>}QK>Agf}@YSU( zu^4;2XIE>0U@5KjHG3VO~eJXz5`=*v!y!naUqH zrp=jWx9>F;DOmAdUh#7`j7y+2zIKkXZ|`vm?a7lWcMkbgzSsGdF7-iA~aNK$x?NOaNQubhMJPf_Ua7v ztJAW;AL2j5dem*>D>nW!d3ukbCY?-K#8kbZb#>Y~L>DV4Q*N_Ny}`Hq-w>pJ=xFXR zZ}Y2kH@)r|dk+bI9`HZdpqQ#W+}U}(p`#uPcz=VzU$>ma8Vuon@|Wtqso8$m4fbOu znnL{rhpgnm78nc_^))=$7K0&77tF32&?YPU9r<+aS$#L$X{C6T2(_Yzud_jbEb=$- zPWSq|`u>1FS82ux3xfT}Fq@m9lWr)xNm)m-zlhCX72HwYJl39AOE$qBU{7|?+fczB zEm3x`9fS;LCx}gC552$^vsZMtmCdgVF%+#FVgW`VSJ+K6yyH3CyeiD_L8qLVPa=<8 z)$2loH0My{&&RD@pP2FW-ZAAU1^=PFRu*eQ3G-N253uFzObK+|KsZ8BvG$ZHzp=`Y z*L#;`ah`^XJbQgrrfQ|tFDY+dBjwawmv-oSv8U9kQ7oOr~-OuMht` zBe_H!+@3fC1%_!0@E&tH*EVim4z*2Z$ zL;W5S6pg901)ES6!@Uzrtz+#_BU*7NA9}lRwzIY?wbf_zqj)Zy08I>d${v zCNpMN>AM+=nxdd6zJ^`SDhFtCT?3HoXZ9AD--`gg0k9nKjCuIW#4jO^jSek&P1V@y z0L<}Ks^JI%QQy{Uldjzc-9F775W(p9-`pB1JqR#Vvh1(Uw;&h%zPQqT=U3}BDXpAy zET9_dyUV(^1DnOt=}s^!+tCIkctsm3cxb{kYq0~>3`6xEby&tMtY)#2C|8FyuWqOq zDSxQf`o{4`VJj+)apQZ!hFGlD4Q|Ah=A3A_|f}KpbM2o3k(_o}ofvmHjNfCLEzJ z>rxx_avMb^;5dNx!#I80cH~j-?GF9q4-@35eim8_Rd6|$Sj!LrZ(UZ)(8(fyw>Y(Kl_6Ue59y-UiJg`*hv`VICCpM= zDj9B(KU3^qCDFV6l*RMV`C%9xupV!%pZvYzRtqcduh(ShJ1__BWzaL0aCW{nqEtub zUdIq#>_Ee8j0o#8&>%j&4)$kL*!+6%&_{Kk`W#lht|7d9;i2lKdxs6`VQ7rEYFG4t z%3rWEJq#;YPCW!zp4~D9yE5AB_8k1+)t?LEfAhk|(myxXe_+HDcBq~qJW~G1@Ln(P zas%cso9w2G2#U~RuKXF|U9TQID_QAyO-D)ut6v`uBY(H}V%XkSbD#CrIRw(#AP7Xt zA3FY|rrUs!J5$R$1h!#Xp`D@i$@ng1Ow-RfQckcBD69PKl3(A6qkgU95RgCd z+~eBkYo`A)sgfh*0-J!W4IklrqB8WZJg$rGU%40Z9Rl)KqbGM*_169OOLlam)Qn~) z8(>iu%l_2>ZcsCpN44yk?OyKOP$O4!YIqDD{IR__8PPRmodVH}xE4IF9rcJ~ZSJIg z>88W^O(aFXL^cTm2pua(ZXV<+K&6$Ikk7uko6yf8w~n2GK&1TfWlx`0cUNv7b;Kbc zf1deE0Cn`Qe~LphQa^z1O}!al|8I&GU{F-l*^$kJwxY`^lfJeiT<@|A4PEJ3yF%5MoB1PV8)3 zLj^aC4?f9RDp&P6~0`Rmrz^V-z?`GG$< zLugPWEs^tB)ks)H{v7u8wA(8}s$Z?A31|+mimhsl^0u>7l9Ruqz4_Ug>3x<36liid z5)M&LvNaIU-DZ2i{N(R&e{*m2_dizZ@}VdX6`JDMlSnLL;Ehur5l~KXs2~nnjSQ8glZUgpF&M{U-0B{)6^%6e!smT; zf3h>Ui;VBZhtj?H?d~x-8!remqjvdA@}+JyzSqIF&{Io+E8$5is|=U3yx#q1v#t=J ziu0Re6lf;()p1X?qrxH&8XGD}jQKagN`(6Xd)|R3P|OjSy(wHoW|N-78z|yc1=cvm z5T)lM(c2KNaBKKo>BDCcP&iDGNj-L1m zDdg}$O9SfOi>(&qNV&`co55$bFcZ$Z)Y}bx_k?}e3>6Gw$C_aU+pZs9L99ML%=GXx zw?W8H>uanGy0OsaP9DO?k2I^aw&nHVRt#B3&r)Z}{DnJ>n@6@<gRBfRMUiVa`W?RaeSnz0Ql3f+n+&l{sr4lC{r($SE> zvpG%4!c&|lQf*vtM|)kW80A7Jl+}k*N}uDi*gu3NR~HpS?M7>iC?|t!O7K1xW0VbP z;}DU3A&F~YAxfYzILJrk;DT^jti5Ws+vBU}`yDd_EnJ(ZaDc>(qYyiUwTAA4(@ zrRAE$Qf$}=q33q9*}@L!B3)A>O+^n!O&w46YLZoFrf3twy{n_Y zPLMZV>q1_lkV_ft4x55K^loAyqEF`dVRNirRpX4q<&IAQ(ov5+r^BZ?)dnT6V zTrTqU0F8cP4w4+(^&m5!z{*zQi&ifFrorF<>!lL!4olt~K-QYUrd_Gg<$x zSj)?wi0>Tyos4E3t}O!ub2i{aY^?z(I2 z;8L{X6uffLd)-vx_~YoX1(I z-e7lG&EDwgH0C)FW*9w&du003Gi4{v=t#$85kX;aa-1xEKEO~RIx;9)Spp%PZs*_M znzUi%`Bk5Cr6^^?Se`w6QNp-`@2y7kTMC2@(sr=RsLgU%+!j;#27hK6u6TK+?4wA_W$;s`!JB6sf6XSp9=5;=<2$xO)2h72-zyJTuN7!NXzvlD|+*afy`3fN8dite5;-Ec#BqjqYexJ@gF zh&Qa%SvGa#epyw2Yhc$$kH|0aA`&@f1pEALM>7l?Z=7g3Pqa$@nX|MRLsTic}$gYSO)N6Jx(2 zRVdW+rvA-Np*($=8Ejr6hM9{Q0%kD3;Q$-5%T&nmseF3cU13f1wkmsWn{*T*gF?fU zPONMaj;$}SbHkz3O?DrQEl4t)wN#tQn>T-1+u_eFTY@!r(FXP=HlCWr?WnImlYKN4 zRf!^mJ&SF60qX#bEm0_$ChKD zy)KMThe~nQ(3V*Ww}zz*zBE-HWp5vw-#R-m_E$1jB+aDQjloTP^{$qIM|GiwcVfxI zVTPrw!#v!%Sc4muP9~}*Bi_A)^0Y!*G-&j`%=Hhp!7SoQM9EpKM>1X#^O?ogCS$U$ zKZ{)ik4(XLw&;DcTGei?|6*gz+nRr9h8&F)D(lnfYj(FzpRtjY)pmp!%rPTisXeo~ zJC1#K=!`iN+iyVD5V#iH@d(R=fbJ}tO>)=f@PZ!X)EO5&yJjcI!E4El?y=7y5Scrd zFBoD6HEZj(b;V<$H3X(1g>-n4qWdVLS}O{;n9nHdc{XUQ!Mh4|IL+Q9HkH*M3l_)b(;ZKi4HDa2VDr?nr(;nW%v#W1m zVm-plDe}!W@2aS71|A>M!v4~XS}&WvIYUJbtTm|o@SXzc5qR^ww zkm=ak;}NIEu_S$#ffd?R)+`fM{mJ@fVzu=#zCTDk`iEz=T@{+Iexir#uF5p@#3QO- zGqLJ)eO5{ZVTm_73o0LDTZ#2z*RxPuU-k#x<*>kPFzq}%+_E3v1Ej(qnXk2aIiy)O zbSJyRG@5`f4%T67Z#Q(-2KJ*pqc01|F?8mI&`B(kuQAU>sO2vEB*(DJF9t)N%B?%) z8{O#h)681mz}FFj*0Q%pVp#dEV^?MyDzbYcVU)`2ng1v(geR`&JvDZHlkIMog6|7; zP<$%uJ_>5LXVXU+Ch75pZOPFv>|7Qz8Xa8BF5T1@I#_u~2tx`D$k38c@7HVN<)+tF z0YU?gk6%l%Awi2KT1fDXEM~ugH4QmIF3MwQ_S3z4xy|pZXj;~0A__Ou zBD|*b7qPckg>i;SG7`^XJH`PNb-R;;cqPYp4p<+G;~p!qCTisX7jjy5ZM>osaxxoL-0C`jz1 zc|i@&Lcj!pd6()Q3ryeJ69N>XP|)jiz;VkbY~Dl+;s{oC3aZjd3b)MoL{xSD-$OQM z@1z)}9siSym(4@CYOs^l%fpb+{!m52SJh!1pvi6c^o;x*&;=lR zyd%_K8-B6#sttccY0u}iA+(Q$PU7l!oCNjXWqEY#wTCUIThW@ElMK=Fnp%ebj-1Rl z@${a^|Jb(ZDb8zzqH9nOzA#(M9#1jsDsB$)wqmk*{WO$@d57Z0_Y?jYzN@Cs3B7L7 zXS^51qHzL?rFmyAdy^PnExAla<%J3t*`cZM`HSrLsZd!9=-%{nvM(Dn4R{W-5!=L$ zOe=mu(1N))8#EoyF@9karlU3<*x3iu4R8Gy(R%p|!#&He1KbaW|B;Jt4(w_RUlj2$ zi>;c8<;iytD2Ikut5E;19b23Kp*Kzj(i4dMvt65Z4IFL#<0%aYauk?RYf1<`kP)vp zt!0g8*}J%+Zdbv~Sq6hnx0}tK4Tnk_!{gzTuO?)?``t-8PQ*(bBZJYm80{pR@Gu52Kzb|I2S5m1=(&cR+3e!vD~?a<+^a@ zGY{RSty07F`mYM|=Na_6e`s1bdk?Dem?s{9#;|h)iAJQ$HNHZ^DnO{vuP0r;D#x%tF|>$5CE_ab`=8>+9CWIO-V0 zG8Y;q{X^*1mKQ4`iu33Qjhb}TlW`G>ul*eW|u zpyk=EC5ALw5_NkA-`IKmVx{6aw)!0`vZi47M9Y8q9V#+JF|R;bihsq7S|?5veb2Kd z%uumQv-AAT&szT8QB`w$UPXa8v(rwXkV31M{FeuOLN-kE*6W4=p@rcL@N(eO+Bfa6 z?w1)acplpOEobW()?k|;KyRCi^PLd9q1B!}W$^VkSgEBj?ci_tVlL)uW1a5CBfIpv zdT1DZMzLlP;KvPKCA5xoDn+{#uy$M_Mw0UprLW6@C0_4Asid|1a+wL;NGr?`>{C;5 znyRmPkqul1zi7xtFT>esJGO2a&ck05C=ugq?D{hJWh>a_j_#Ixv?W&D3XPC zVB?Y1k^%ubbXw-w%lBZrpUXJ}=CTi|><=MO9Rhbdy2m}#jrhVLa9*SwYdm3a$jIs4 z9Vx%E7t7(nhgj$e*!~Rb0A{&{FVj)DX=zGra^udFsx4@xMQ^n#k60cABK7#Q9NO~s z{OtyRo*BDK6Tn_KL^*`4Wb$jT7G51z^9+AEBZ5Zy8Fq@QxW;~3fywX@i+mSs44d*U z)`7e`EIT84=83yUW*@a)z#E@9WzoVMKmEo*7`^3X`TaWAZ}{V#kGn;xk9eROYz~ zWv_*R1p*V!z4~T!=9%FRf&DBN0yw*W6Rh^qPYv*2yePYy^O8-nlTt?|Wd*e!larK{ z7N62RCo4H^7+bo^P=_SgfmK*~#jvNV3{|_9>c|Ibhs?yE*h?SptdVJf*$}!sLH%*a znODmyi}a&gp8PezbH;)@NTcX7E~xRJx6PmY6pZwWxZkO3xUGHr&ptcA;#Z^C0XV`U z�HZr^bQ{r{<^PzC7-KuiAEHjmaO?8@Yx@YoB}PjaSeAc4ivxshB6D8`V6W@l&6b zBHj1$)Q<)qZn^nGc5bzyS-+SLyyM@tMxNA-Df9BbE=E+drS5tEfTjC_h6|HTSjk4L zF|-ZxuT{%`NPPBi|Dd4Y?1Zf3jGXM?#Prc=De3WvfkTI;W+V*@LMq W;qYv=FFKhbLZaV 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 diff --git a/electron/lib/preload/api.ts b/electron/lib/preload/api.ts index 67cf2e3..bd585af 100644 --- a/electron/lib/preload/api.ts +++ b/electron/lib/preload/api.ts @@ -42,6 +42,12 @@ const rust = { params: model }) }, + deleteModel: (model: string) => { + return ipcRenderer.invoke('core:call', { + method: 'deleteModel', + params: { model } + }) + }, } export default api diff --git a/electron/lib/preload/index.d.ts b/electron/lib/preload/index.d.ts index ab51057..bd461c7 100644 --- a/electron/lib/preload/index.d.ts +++ b/electron/lib/preload/index.d.ts @@ -31,6 +31,7 @@ declare global { getFilePath: (file: File) => string | null downloadModel: (model: WhisperModel) => Promise checkModelExists: (model: WhisperModel) => Promise + deleteModel: (model: WhisperModel) => Promise } } } diff --git a/rust/src/bin/core.rs b/rust/src/bin/core.rs index 77e56d5..4b8d549 100644 --- a/rust/src/bin/core.rs +++ b/rust/src/bin/core.rs @@ -76,6 +76,13 @@ async fn handle_request(r: RpcRequest) { Err(e) => write_err(e.to_string()), } } + "deleteModel" => { + let p: core::types::DeleteModelParams = serde_json::from_value(r.params).unwrap(); + match core::whisper::delete_model_rpc(&id, p, &mut emit).await { + Ok(v) => write_ok(serde_json::to_value(v).unwrap()), + Err(e) => write_err(e.to_string()), + } + } _ => write_err("Unknown method".into()), } } diff --git a/rust/src/types.rs b/rust/src/types.rs index a911a91..1bea160 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -161,3 +161,16 @@ pub struct DownloadModelResult { pub path: String, // Path where model was saved pub size: u64, // Downloaded file size in bytes } + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DeleteModelParams { + pub model: String, // Model name: "tiny", "base", "small", "medium", "large" +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DeleteModelResult { + pub model: String, // Model name that was deleted + pub path: String, // Path where model was deleted from +} diff --git a/rust/src/whisper.rs b/rust/src/whisper.rs index 02af762..060d3a5 100644 --- a/rust/src/whisper.rs +++ b/rust/src/whisper.rs @@ -690,6 +690,53 @@ pub fn check_model_exists(model_name: &str) -> anyhow::Result { Ok(model_path.exists()) } +/// Public RPC method to delete a whisper model +pub async fn delete_model_rpc( + id: &str, + params: crate::types::DeleteModelParams, + mut emit: impl FnMut(crate::rpc::RpcEvent) +) -> anyhow::Result { + let model_filename = match params.model.as_str() { + "tiny" => "ggml-tiny.bin", + "base" => "ggml-base.bin", + "small" => "ggml-small.bin", + "medium" => "ggml-medium.bin", + "large" => "ggml-large-v3.bin", + _ => return Err(anyhow::anyhow!("Unknown model: {}. Supported: tiny, base, small, medium, large", params.model)) + }; + + let models_dir = get_models_dir() + .map_err(|e| anyhow::anyhow!("Cannot access models directory: {}. Please check app permissions.", e))?; + let model_path = models_dir.join(model_filename); + + // Safety check: ensure the path is within the models directory + if !model_path.starts_with(&models_dir) { + return Err(anyhow::anyhow!("Invalid model path: security check failed")); + } + + if !model_path.exists() { + return Err(anyhow::anyhow!("Model {} does not exist at {}", params.model, model_path.display())); + } + + emit(crate::rpc::RpcEvent::Log { + id: id.into(), + message: format!("Deleting {} model from {}", params.model, model_path.display()) + }); + + tokio::fs::remove_file(&model_path).await + .map_err(|e| anyhow::anyhow!("Failed to delete model file at {}: {}. Check app permissions.", model_path.display(), e))?; + + emit(crate::rpc::RpcEvent::Log { + id: id.into(), + message: format!("Successfully deleted {} model", params.model) + }); + + Ok(crate::types::DeleteModelResult { + model: params.model, + path: model_path.to_string_lossy().to_string(), + }) +} + /// Get the models directory path fn get_models_dir() -> anyhow::Result { // Priority 1: Check if we're in development (project exists) From a96bc7c867d9c59aa2f1c08544fdad23bb0467f2 Mon Sep 17 00:00:00 2001 From: unaisshemim Date: Tue, 11 Nov 2025 12:17:04 +0530 Subject: [PATCH 2/2] feat: implement video upload and processing UI components - Added new components for video upload, including UploadArea, VideoList, and FileCard. - Introduced ColorControls and QuickSettings for user customization of captions. - Created Sidebar to organize settings and templates. - Implemented DragOverlay for enhanced user experience during file uploads. - Added error handling utilities for better user feedback during processing. - Updated existing files to integrate new features and improve overall functionality. --- .cursor/rules/electron.mdc | 1 + electron/app/app.tsx | 953 +++--------------- electron/app/components/ColorControls.tsx | 37 + electron/app/components/ColorPicker.tsx | 54 + electron/app/components/DragOverlay.tsx | 33 + electron/app/components/ExportFormats.tsx | 38 + electron/app/components/FileCard.tsx | 53 + electron/app/components/QuickSettings.tsx | 116 +++ electron/app/components/SettingsModal.tsx | 90 ++ electron/app/components/Sidebar.tsx | 78 ++ .../app/components/TemplatePreviewCard.tsx | 61 ++ electron/app/components/TemplatesSection.tsx | 31 + electron/app/components/UploadArea.tsx | 39 + electron/app/components/VideoList.tsx | 60 ++ electron/app/constants/templates.ts | 42 + electron/app/types/index.ts | 78 ++ electron/app/utils/errors.ts | 73 ++ electron/app/utils/video.ts | 8 + electron/lib/main/sidecar.ts | 18 +- rust/.gitignore | 3 +- rust/src/captions.rs | 74 +- 21 files changed, 1118 insertions(+), 822 deletions(-) create mode 100644 electron/app/components/ColorControls.tsx create mode 100644 electron/app/components/ColorPicker.tsx create mode 100644 electron/app/components/DragOverlay.tsx create mode 100644 electron/app/components/ExportFormats.tsx create mode 100644 electron/app/components/FileCard.tsx create mode 100644 electron/app/components/QuickSettings.tsx create mode 100644 electron/app/components/SettingsModal.tsx create mode 100644 electron/app/components/Sidebar.tsx create mode 100644 electron/app/components/TemplatePreviewCard.tsx create mode 100644 electron/app/components/TemplatesSection.tsx create mode 100644 electron/app/components/UploadArea.tsx create mode 100644 electron/app/components/VideoList.tsx create mode 100644 electron/app/constants/templates.ts create mode 100644 electron/app/types/index.ts create mode 100644 electron/app/utils/errors.ts create mode 100644 electron/app/utils/video.ts diff --git a/.cursor/rules/electron.mdc b/.cursor/rules/electron.mdc index de8d90d..e4d0010 100644 --- a/.cursor/rules/electron.mdc +++ b/.cursor/rules/electron.mdc @@ -6,3 +6,4 @@ alwaysApply: false - we use bun, shadcn, tailwind, react, typescript, vite, etc. - we use linear.app design aesthetic: best in the world ux, modern, clean, minimal, etc. +- whenever you change in any files in rust folder run this command `cargo build` diff --git a/electron/app/app.tsx b/electron/app/app.tsx index 52683b0..560eeb2 100644 --- a/electron/app/app.tsx +++ b/electron/app/app.tsx @@ -1,388 +1,19 @@ -import React, { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' import { Button } from '@/app/components/ui/button' import { toast, Toaster } from 'sonner' -import { Switch } from '@/app/components/ui/switch' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/ui/select' -import { Upload, Film, Download, Cog, Trash, Zap, Settings, FileVideo, Key, Palette } from 'lucide-react' -import { cn } from '@/lib/utils' +import { Cog, Zap } from 'lucide-react' import { TitleBar } from './components/TitleBar' -import { ModelDownloader } from './components/ModelDownloader' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogFooter, - DialogClose, -} from '@/app/components/ui/dialog' -import { Input } from './components/ui/input' -import { ModelInfo } from '@/lib/preload' - -const getVideoPath = (filename: string) => { - if (import.meta.env.DEV) { - return `./assets/${filename}` - } - - return `res://videos/${filename}` -} - -// Function to display user-friendly errors -const showErrorToast = (error: any, count: number = 1) => { - const countText = count > 1 ? ` (${count} videos)` : '' - - switch (error.name) { - case 'API_KEY_MISSING': - toast.error('🔑 API Key Not Configured', { - description: 'Add OpenAI API key in settings for better transcription quality.', - action: { - label: 'Settings', - onClick: () => { - // Will open API key settings - }, - }, - }) - break - - case 'API_KEY_INVALID': - toast.error('🔑 Invalid API Key', { - description: 'Please check your OpenAI API key in settings.', - }) - break - - case 'NO_LOCAL_MODELS': - toast.warning('📥 Using Online Transcription', { - description: 'Local models not found. Transcription performed via OpenAI API.', - }) - break - - case 'BINARY_NOT_FOUND': - toast.error('⚙️ System Error', { - description: 'Application components not found. Try reinstalling CapSlap.', - }) - break - - case 'BINARY_DEP_MISSING': - toast.error('🧩 Missing System Libraries', { - description: 'Media tools require static ffmpeg/ffprobe builds. Please reinstall or contact support.', - }) - break - - case 'NETWORK_ERROR': - toast.error('🌐 Internet Connection Problem', { - description: 'Check your network connection and try again.', - }) - break - - case 'RATE_LIMIT': - toast.error('⏰ Rate Limit Exceeded', { - description: 'Too many requests to OpenAI API. Try again later.', - }) - break - - case 'QUOTA_EXCEEDED': - toast.error('💳 API Quota Exhausted', { - description: 'Top up your OpenAI balance or use local models.', - }) - break - - case 'FILE_NOT_FOUND': - toast.error('📁 File Not Found', { - description: 'Make sure the video file exists and is accessible.', - }) - break - - default: - toast.error(`❌ Error${countText}`, { - description: error.message || 'An unexpected error occurred. Please try again.', - }) - } -} - -interface Template { - id: 'oneliner' | 'karaoke' | 'vibrant' - captionStyle: 'karaoke' | 'oneliner' | 'vibrant' - name: string - src: string | null - textColor: string - highlightWordColor: string - outlineColor: string - glowEffect: boolean - font: string - position: 'bottom' | 'center' -} - -interface Settings { - selectedTemplate: Template['id'] - exportFormats: string[] - selectedFont: string - selectedModel: ModelInfo['name'] - textColor: string - highlightWordColor: string - outlineColor: string - glowEffect: boolean - captionStyle: Template['captionStyle'] - captionPosition: 'bottom' | 'center' - selectedLanguage: string -} - -const defaultSettings: Settings = { - selectedTemplate: 'karaoke', - exportFormats: ['9:16'], - selectedFont: 'montserrat-black', - selectedModel: 'whisper-1', - textColor: '#ffffff', - highlightWordColor: '#ffff00', - outlineColor: '#000000', - glowEffect: false, - captionStyle: 'karaoke', - captionPosition: 'bottom', - selectedLanguage: 'en', -} - -const templates: Template[] = [ - { - id: 'oneliner', - captionStyle: 'oneliner', - name: 'Oneliner', - src: getVideoPath('oneliner.mp4'), - textColor: '#ffffff', - highlightWordColor: '#ffff00', - outlineColor: '#000000', - glowEffect: true, - font: 'montserrat-black', - position: 'bottom', - }, - { - id: 'karaoke', - captionStyle: 'karaoke', - name: 'Karaoke', - src: getVideoPath('karaoke.mp4'), - textColor: '#ffffff', - highlightWordColor: '#00f924', - outlineColor: '#000000', - glowEffect: false, - font: 'komika-axis', - position: 'bottom', - }, - { - id: 'vibrant', - captionStyle: 'vibrant', - name: 'Vibrant', - src: getVideoPath('vibrant.mp4'), - textColor: '#898284', - highlightWordColor: '#7ef1c5', - outlineColor: '#000000', - glowEffect: false, - font: 'roboto-bold', - position: 'center', - }, -] - -const availableExportFormats = [ - { id: '9:16', name: '9:16', description: 'Perfect for TikTok, Instagram Stories, YouTube Shorts' }, - { id: '16:9', name: '16:9', description: 'Standard for YouTube, desktop viewing' }, - { id: '1:1', name: '1:1', description: 'Instagram posts, Facebook' }, - { id: '4:5', name: '4:5', description: 'Instagram feed posts' }, -] - -const availableFonts = [ - { id: 'komika-axis', name: 'Komika Axis' }, - { id: 'montserrat-black', name: 'Montserrat Black' }, - { id: 'theboldfont', name: 'THEBOLDFONT' }, - { id: 'kanit-bold', name: 'Kanit Bold' }, - { id: 'poppins-black', name: 'Poppins Black' }, - { id: 'oswald-bold', name: 'Oswald Bold' }, - { id: 'bangers-regular', name: 'Bangers Regular' }, - { id: 'worksans-bold', name: 'WorkSans Bold' }, - { id: 'roboto-bold', name: 'Roboto Bold' }, -] - -const FONT_NAMES = { - 'montserrat-black': 'Montserrat Black', - 'komika-axis': 'Komika Axis', - theboldfont: 'THEBOLDFONT', - 'kanit-bold': 'Kanit Bold', - 'poppins-black': 'Poppins Black', - 'oswald-bold': 'Oswald Bold', - 'bangers-regular': 'Bangers Regular', - 'worksans-bold': 'WorkSans Bold', - 'roboto-bold': 'Roboto Bold', -} as const - -const getFontName = (fontId: string): string => { - return FONT_NAMES[fontId as keyof typeof FONT_NAMES] || 'Montserrat Black' -} - -function TemplatePreviewCard({ - template, - isSelected, - onSelect, -}: { - template: Template - isSelected: boolean - onSelect: () => void -}) { - const [isHovered, setIsHovered] = React.useState(false) - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
- {/* Video Preview with 9:16 aspect ratio */} -
-
- - {isSelected && ( -
-
-
- )} -
- ) -} - -function SettingsModal({ - onSave, - isOpen, - onOpenChange, - apiKey, -}: { - onSave: (apiKey: string) => void - isOpen: boolean - onOpenChange: (open: boolean) => void - apiKey: string -}) { - const [apiKeyState, setApiKeyState] = useState(apiKey) - - useEffect(() => { - setApiKeyState(apiKey) - }, [apiKey]) - - return ( - - - - - - - - -
-
- -
-

API Settings

-
-
-
- -
-
- - setApiKeyState(e.target.value)} - /> -
-
- - - - - - - - - -
-
- ) -} - -function FileCard({ path, onRemove }: { path: string; onRemove: () => void }) { - const fileName = path.split('/').pop() || '' - - return ( -
-
-
- -
- -
-

{fileName}

-
- - -
-
- ) +import { Sidebar } from './components/Sidebar' +import { DragOverlay } from './components/DragOverlay' +import { UploadArea } from './components/UploadArea' +import { VideoList } from './components/VideoList' +import { Template, Settings, defaultSettings, getFontName } from './types' +import { templates } from './constants/templates' +import { showErrorToast } from './utils/errors' + +type ProgressData = { + progress: number + status: string } export default function App() { @@ -392,9 +23,11 @@ export default function App() { const [isLoaded, setIsLoaded] = useState(false) const [isGenerating, setIsGenerating] = useState(false) const [isDragOver, setIsDragOver] = useState(false) - const [_, setDragCounter] = useState(0) + const [_dragCounter, setDragCounter] = useState(0) const [isApiKeySettingsOpen, setIsApiKeySettingsOpen] = useState(false) const [shouldGenerateAfterApiKey, setShouldGenerateAfterApiKey] = useState(false) + const [progressMap, setProgressMap] = useState>(new Map()) + const requestIdToVideoPathRef = useRef>(new Map()) useEffect(() => { const savedSettings = localStorage.getItem('settings-v1') @@ -491,28 +124,101 @@ export default function App() { try { setIsGenerating(true) + // Clear previous progress + setProgressMap(new Map()) + requestIdToVideoPathRef.current = new Map() + + // Track pending videos that don't have request IDs yet + const pendingVideos = new Set(selectedVideos) + + // Register progress handler BEFORE starting async operations + const unsubscribe = window.rust.onProgress((ev: any) => { + console.log('[APP] Progress event received:', ev) + if (ev.event === 'progress' && ev.id) { + let videoPath = requestIdToVideoPathRef.current.get(ev.id) + + // If we don't have a mapping yet, try to match to first pending video + if (!videoPath && pendingVideos.size > 0) { + // Get the first pending video (in order) + const firstPending = Array.from(pendingVideos)[0] + videoPath = firstPending + pendingVideos.delete(firstPending) + + // Update the mapping + requestIdToVideoPathRef.current.set(ev.id, videoPath) + console.log('[APP] Mapped request ID', ev.id, 'to video:', videoPath) + } + + // Update progress if we have a video path + if (videoPath) { + console.log('[APP] Updating progress for', videoPath, ':', Math.round(ev.progress * 100), '%', ev.status) + setProgressMap((prev) => { + const newMap = new Map(prev) + newMap.set(videoPath!, { + progress: Math.round(ev.progress * 100), + status: ev.status || 'Processing...', + }) + return newMap + }) + } else { + console.log('[APP] No video path found for request ID:', ev.id) + } + } + }) - const results = await Promise.allSettled( - selectedVideos.map(async (video) => - window.rust.call('generateCaptions', { - inputVideo: video, - exportFormats: videoSettings.exportFormats, - karaoke: videoSettings.captionStyle === 'karaoke', - fontName: getFontName(videoSettings.selectedFont), - splitByWords: true, - model: videoSettings.selectedModel, - language: videoSettings.selectedLanguage, - prompt: null, - textColor: videoSettings.textColor, - highlightWordColor: videoSettings.highlightWordColor, - outlineColor: videoSettings.outlineColor, - glowEffect: videoSettings.glowEffect, - position: videoSettings.captionPosition, - apiKey: apiKey, - }) - ) + // Initialize progress for all videos + setProgressMap((prev) => { + const newMap = new Map(prev) + selectedVideos.forEach((video) => { + if (!newMap.has(video)) { + newMap.set(video, { + progress: 0, + status: 'Starting...', + }) + } + }) + return newMap + }) + + // Start all requests + const requestPromises = selectedVideos.map((video) => + window.rust.call('generateCaptions', { + inputVideo: video, + exportFormats: videoSettings.exportFormats, + karaoke: videoSettings.captionStyle === 'karaoke', + fontName: getFontName(videoSettings.selectedFont), + splitByWords: true, + model: videoSettings.selectedModel, + language: videoSettings.selectedLanguage, + prompt: null, + textColor: videoSettings.textColor, + highlightWordColor: videoSettings.highlightWordColor, + outlineColor: videoSettings.outlineColor, + glowEffect: videoSettings.glowEffect, + position: videoSettings.captionPosition, + apiKey: apiKey, + }) ) + // Wait for all requests to complete + const results = await Promise.allSettled(requestPromises) + + // Clean up progress handler + unsubscribe() + + // Mark all videos as complete (only after promise resolves) + setProgressMap((prev) => { + const newMap = new Map(prev) + // Update all videos to show 100% with "Complete" status + selectedVideos.forEach((video) => { + newMap.set(video, { + progress: 100, + status: 'Complete', + }) + }) + return newMap + }) + const successful = results.filter((r) => r.status === 'fulfilled') const failed = results.filter((r) => r.status === 'rejected') @@ -521,21 +227,24 @@ export default function App() { } if (failed.length > 0) { - // Группируем ошибки по типам для лучшего UX const errors = failed.map((f) => f.reason) const uniqueErrors = [...new Set(errors.map((err) => err.name || 'UNKNOWN_ERROR'))] if (uniqueErrors.length === 1) { - // Одинаковый тип ошибки для всех файлов const sampleError = errors[0] showErrorToast(sampleError, failed.length) } else { - // Different error types toast.error(`Failed to process ${failed.length} videos`, { description: 'Mixed errors occurred. Check settings and try again.', }) } } + + // Clear progress after a short delay to show completion + setTimeout(() => { + setProgressMap(new Map()) + requestIdToVideoPathRef.current = new Map() + }, 2000) } catch (error: any) { showErrorToast(error, 1) } finally { @@ -627,420 +336,32 @@ export default function App() { > - {isDragOver && ( -
-
- -
-
-
-
- -
-
- -
-

Drop your video to upload

-
- Supported: -
- {['MP4', 'MOV', 'MKV', 'AVI'].map((format) => ( - - {format} - - ))} -
-
-
-
-
- )} - - {/* Sidebar */} -
- {/* Header */} -
-
-
-
- -
-

CapSlap

-
- -
-

Lightning-fast AI captions

-
-
- {/* Templates */} -
-

- - Templates -

-
- {templates.map((template) => ( - selectTemplate(template)} - /> - ))} -
-
- - {/* Quick Settings */} -
-
-

- - Quick Settings -

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- {/*
*/} - -
- - updateSettings({ glowEffect: checked })} - /> -
-
-
-
- - {/* Color Controls */} -
-

- - Color Controls -

-
-
- -
-
- updateSettings({ textColor: e.target.value })} - className="w-12 h-8 rounded-sm border border-border bg-transparent cursor-pointer overflow-hidden opacity-0 absolute inset-0" - /> -
-
-
- { - const value = e.target.value - if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') { - updateSettings({ textColor: value }) - } - }} - onBlur={(e) => { - let value = e.target.value - if (value && !value.startsWith('#')) value = '#' + value - if (/^#[0-9A-Fa-f]{6}$/.test(value)) { - updateSettings({ textColor: value }) - } else if (value.length > 0) { - updateSettings({ textColor: videoSettings.textColor }) - } - }} - className="w-full h-8 px-2 py-1.5 border border-border rounded-sm bg-background/50 focus:outline-none hover:border-primary/50 focus:border-primary/70 ring-0 focus-visible:ring-0 focus:ring-0 text-foreground transition-all duration-200" - placeholder="#ffffff" - maxLength={7} - /> -
-
-
- -
- -
-
- updateSettings({ highlightWordColor: e.target.value })} - className="w-12 h-8 rounded-sm border border-border bg-transparent cursor-pointer overflow-hidden opacity-0 absolute inset-0" - /> -
-
-
- { - const value = e.target.value - if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') { - updateSettings({ highlightWordColor: value }) - } - }} - onBlur={(e) => { - let value = e.target.value - if (value && !value.startsWith('#')) value = '#' + value - if (/^#[0-9A-Fa-f]{6}$/.test(value)) { - updateSettings({ highlightWordColor: value }) - } else if (value.length > 0) { - updateSettings({ highlightWordColor: videoSettings.highlightWordColor }) - } - }} - className="w-full h-8 px-2 py-1.5 border border-border rounded-sm bg-background/50 focus:outline-none hover:border-primary/50 focus:border-primary/70 ring-0 focus-visible:ring-0 focus:ring-0 text-foreground transition-all duration-200" - placeholder="#ffffff" - maxLength={7} - /> -
-
-
- -
- -
-
- updateSettings({ outlineColor: e.target.value })} - className="w-12 h-8 rounded-sm border border-border bg-transparent cursor-pointer overflow-hidden opacity-0 absolute inset-0" - /> -
-
-
- { - const value = e.target.value - if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') { - updateSettings({ outlineColor: value }) - } - }} - onBlur={(e) => { - let value = e.target.value - if (value && !value.startsWith('#')) value = '#' + value - if (/^#[0-9A-Fa-f]{6}$/.test(value)) { - updateSettings({ outlineColor: value }) - } else if (value.length > 0) { - updateSettings({ outlineColor: videoSettings.outlineColor }) - } - }} - className="w-full h-8 px-2 py-1.5 border border-border rounded-sm bg-background/50 focus:outline-none hover:border-primary/50 focus:border-primary/70 ring-0 focus-visible:ring-0 focus:ring-0 text-foreground transition-all duration-200" - placeholder="#ffffff" - maxLength={7} - /> -
-
-
-
-
- - {/* Export Formats */} -
-
-

- - Export Formats -

-
- {availableExportFormats.map((format) => ( -
handleExportFormatToggle(format.id)} - > - {format.name} -
- ))} -
-
-
- - {/* Download Models */} -
- updateSettings({ selectedModel: model })} - apiKey={apiKey} - onOpenApiKeySettings={() => setIsApiKeySettingsOpen(true)} - /> -
-
-
- {/* Upload sectino */} + {isDragOver && } + + setIsApiKeySettingsOpen(true)} + onCloseApiKeySettings={setIsApiKeySettingsOpen} + />
- {selectedVideos.length === 0 && ( -
-
-
-
- -
-

Upload your videos

-

Click to select or drag and drop files

-
- Supported: - {['MP4', 'MOV', 'MKV', 'AVI'].map((format) => ( - - {format} - - ))} -
-
-
-
- )} - - {selectedVideos.length > 0 && ( -
-
-
-

Uploaded files

-
- {selectedVideos.length > 1 && ( - - )} -
-
- -
- {selectedVideos.map((path, index) => ( - setSelectedVideos((prev) => prev.filter((p) => p !== path))} - /> - ))} -
-
-
+ {selectedVideos.length === 0 ? ( + + ) : ( + setSelectedVideos((prev) => prev.filter((p) => p !== path))} + onClearAll={() => setSelectedVideos([])} + /> )}
diff --git a/electron/app/components/ColorControls.tsx b/electron/app/components/ColorControls.tsx new file mode 100644 index 0000000..3cc00d9 --- /dev/null +++ b/electron/app/components/ColorControls.tsx @@ -0,0 +1,37 @@ +import { Palette } from 'lucide-react' +import { ColorPicker } from './ColorPicker' +import { Settings } from '../types' + +interface ColorControlsProps { + settings: Settings + onUpdate: (updates: Partial) => void +} + +export function ColorControls({ settings, onUpdate }: ColorControlsProps) { + return ( +
+

+ + Color Controls +

+
+ onUpdate({ textColor: value })} + /> + onUpdate({ highlightWordColor: value })} + /> + onUpdate({ outlineColor: value })} + /> +
+
+ ) +} + diff --git a/electron/app/components/ColorPicker.tsx b/electron/app/components/ColorPicker.tsx new file mode 100644 index 0000000..37de374 --- /dev/null +++ b/electron/app/components/ColorPicker.tsx @@ -0,0 +1,54 @@ +import { Input } from './ui/input' + +interface ColorPickerProps { + label: string + value: string + onChange: (value: string) => void +} + +export function ColorPicker({ label, value, onChange }: ColorPickerProps) { + return ( +
+ +
+
+ onChange(e.target.value)} + className="w-12 h-8 rounded-sm border border-border bg-transparent cursor-pointer overflow-hidden opacity-0 absolute inset-0" + /> +
+
+
+ { + const val = e.target.value + if (/^#[0-9A-Fa-f]{0,6}$/.test(val) || val === '') { + onChange(val) + } + }} + onBlur={(e) => { + let val = e.target.value + if (val && !val.startsWith('#')) val = '#' + val + if (/^#[0-9A-Fa-f]{6}$/.test(val)) { + onChange(val) + } else if (val.length > 0) { + onChange(value) + } + }} + className="w-full h-8 px-2 py-1.5 border border-border rounded-sm bg-background/50 focus:outline-none hover:border-primary/50 focus:border-primary/70 ring-0 focus-visible:ring-0 focus:ring-0 text-foreground transition-all duration-200" + placeholder="#ffffff" + maxLength={7} + /> +
+
+
+ ) +} + diff --git a/electron/app/components/DragOverlay.tsx b/electron/app/components/DragOverlay.tsx new file mode 100644 index 0000000..dc84b9e --- /dev/null +++ b/electron/app/components/DragOverlay.tsx @@ -0,0 +1,33 @@ +import { Upload } from 'lucide-react' + +export function DragOverlay() { + return ( +
+
+ +
+
+
+
+ +
+
+ +
+

Drop your video to upload

+
+ Supported: +
+ {['MP4', 'MOV', 'MKV', 'AVI'].map((format) => ( + + {format} + + ))} +
+
+
+
+
+ ) +} + diff --git a/electron/app/components/ExportFormats.tsx b/electron/app/components/ExportFormats.tsx new file mode 100644 index 0000000..f2d602f --- /dev/null +++ b/electron/app/components/ExportFormats.tsx @@ -0,0 +1,38 @@ +import { Download } from 'lucide-react' +import { cn } from '@/lib/utils' +import { Settings, availableExportFormats } from '../types' + +interface ExportFormatsProps { + settings: Settings + onToggleFormat: (formatId: string) => void +} + +export function ExportFormats({ settings, onToggleFormat }: ExportFormatsProps) { + return ( +
+
+

+ + Export Formats +

+
+ {availableExportFormats.map((format) => ( +
onToggleFormat(format.id)} + > + {format.name} +
+ ))} +
+
+
+ ) +} + diff --git a/electron/app/components/FileCard.tsx b/electron/app/components/FileCard.tsx new file mode 100644 index 0000000..70cc2c6 --- /dev/null +++ b/electron/app/components/FileCard.tsx @@ -0,0 +1,53 @@ +import { Button } from '@/app/components/ui/button' +import { Trash, FileVideo } from 'lucide-react' + +interface FileCardProps { + path: string + onRemove: () => void + progress?: number + status?: string +} + +export function FileCard({ path, onRemove, progress, status }: FileCardProps) { + const fileName = path.split('/').pop() || '' + const showProgress = progress !== undefined + + return ( +
+
+
+ +
+ +
+

{fileName}

+ {showProgress && ( +
+
+ {status || 'Processing...'} + {progress}% +
+
+
+
+
+ )} +
+ + +
+
+ ) +} + diff --git a/electron/app/components/QuickSettings.tsx b/electron/app/components/QuickSettings.tsx new file mode 100644 index 0000000..1770262 --- /dev/null +++ b/electron/app/components/QuickSettings.tsx @@ -0,0 +1,116 @@ +import { Settings } from 'lucide-react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/ui/select' +import { Switch } from '@/app/components/ui/switch' +import { Settings as SettingsType } from '../types' +import { availableFonts } from '../types' + +interface QuickSettingsProps { + settings: SettingsType + onUpdate: (updates: Partial) => void +} + +const languages = [ + { value: 'en', label: 'English' }, + { value: 'ar', label: 'العربية (Arabic)' }, + { value: 'es', label: 'Español (Spanish)' }, + { value: 'fr', label: 'Français (French)' }, + { value: 'de', label: 'Deutsch (German)' }, + { value: 'ru', label: 'Русский (Russian)' }, + { value: 'zh', label: '中文 (Chinese)' }, + { value: 'ja', label: '日本語 (Japanese)' }, + { value: 'ko', label: '한국어 (Korean)' }, + { value: 'pt', label: 'Português (Portuguese)' }, + { value: 'it', label: 'Italiano (Italian)' }, + { value: 'hi', label: 'हिन्दी (Hindi)' }, + { value: 'tr', label: 'Türkçe (Turkish)' }, + { value: 'nl', label: 'Nederlands (Dutch)' }, +] + +export function QuickSettings({ settings, onUpdate }: QuickSettingsProps) { + return ( +
+
+

+ + Quick Settings +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + onUpdate({ glowEffect: checked })} /> +
+
+
+
+ ) +} + diff --git a/electron/app/components/SettingsModal.tsx b/electron/app/components/SettingsModal.tsx new file mode 100644 index 0000000..a84adba --- /dev/null +++ b/electron/app/components/SettingsModal.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from 'react' +import { Button } from '@/app/components/ui/button' +import { Key } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogClose, +} from '@/app/components/ui/dialog' +import { Input } from './ui/input' + +interface SettingsModalProps { + onSave: (apiKey: string) => void + isOpen: boolean + onOpenChange: (open: boolean) => void + apiKey: string +} + +export function SettingsModal({ onSave, isOpen, onOpenChange, apiKey }: SettingsModalProps) { + const [apiKeyState, setApiKeyState] = useState(apiKey) + + useEffect(() => { + setApiKeyState(apiKey) + }, [apiKey]) + + return ( + + + + + + + + +
+
+ +
+

API Settings

+
+
+
+ +
+
+ + setApiKeyState(e.target.value)} + /> +
+
+ + + + + + + + + +
+
+ ) +} + diff --git a/electron/app/components/Sidebar.tsx b/electron/app/components/Sidebar.tsx new file mode 100644 index 0000000..92d2903 --- /dev/null +++ b/electron/app/components/Sidebar.tsx @@ -0,0 +1,78 @@ +import { Zap } from 'lucide-react' +import { cn } from '@/lib/utils' +import { SettingsModal } from './SettingsModal' +import { TemplatesSection } from './TemplatesSection' +import { QuickSettings } from './QuickSettings' +import { ColorControls } from './ColorControls' +import { ExportFormats } from './ExportFormats' +import { ModelDownloader } from './ModelDownloader' +import { Template, Settings } from '../types' +import { ModelInfo } from '@/lib/preload' + +interface SidebarProps { + templates: Template[] + settings: Settings + apiKey: string + isApiKeySettingsOpen: boolean + onSelectTemplate: (template: Template) => void + onUpdateSettings: (updates: Partial) => void + onToggleExportFormat: (formatId: string) => void + onSaveApiKey: (apiKey: string) => void + onOpenApiKeySettings: () => void + onCloseApiKeySettings: (open: boolean) => void +} + +export function Sidebar({ + templates, + settings, + apiKey, + isApiKeySettingsOpen, + onSelectTemplate, + onUpdateSettings, + onToggleExportFormat, + onSaveApiKey, + onOpenApiKeySettings, + onCloseApiKeySettings, +}: SidebarProps) { + return ( +
+
+
+
+
+ +
+

CapSlap

+
+ +
+

Lightning-fast AI captions

+
+
+ + + + +
+ onUpdateSettings({ selectedModel: model })} + apiKey={apiKey} + onOpenApiKeySettings={onOpenApiKeySettings} + /> +
+
+
+ ) +} + diff --git a/electron/app/components/TemplatePreviewCard.tsx b/electron/app/components/TemplatePreviewCard.tsx new file mode 100644 index 0000000..2ae9787 --- /dev/null +++ b/electron/app/components/TemplatePreviewCard.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { Template } from '../types' + +interface TemplatePreviewCardProps { + template: Template + isSelected: boolean + onSelect: () => void +} + +export function TemplatePreviewCard({ template, isSelected, onSelect }: TemplatePreviewCardProps) { + const [isHovered, setIsHovered] = React.useState(false) + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+
+
+ + {isSelected && ( +
+
+
+ )} +
+ ) +} + diff --git a/electron/app/components/TemplatesSection.tsx b/electron/app/components/TemplatesSection.tsx new file mode 100644 index 0000000..7d0f5eb --- /dev/null +++ b/electron/app/components/TemplatesSection.tsx @@ -0,0 +1,31 @@ +import { Film } from 'lucide-react' +import { TemplatePreviewCard } from './TemplatePreviewCard' +import { Template, Settings } from '../types' + +interface TemplatesSectionProps { + templates: Template[] + settings: Settings + onSelectTemplate: (template: Template) => void +} + +export function TemplatesSection({ templates, settings, onSelectTemplate }: TemplatesSectionProps) { + return ( +
+

+ + Templates +

+
+ {templates.map((template) => ( + onSelectTemplate(template)} + /> + ))} +
+
+ ) +} + diff --git a/electron/app/components/UploadArea.tsx b/electron/app/components/UploadArea.tsx new file mode 100644 index 0000000..3201ba8 --- /dev/null +++ b/electron/app/components/UploadArea.tsx @@ -0,0 +1,39 @@ +import { Upload } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface UploadAreaProps { + isDragOver: boolean + onSelect: () => void +} + +export function UploadArea({ isDragOver, onSelect }: UploadAreaProps) { + return ( +
+
+
+
+ +
+

Upload your videos

+

Click to select or drag and drop files

+
+ Supported: + {['MP4', 'MOV', 'MKV', 'AVI'].map((format) => ( + + {format} + + ))} +
+
+
+
+ ) +} + diff --git a/electron/app/components/VideoList.tsx b/electron/app/components/VideoList.tsx new file mode 100644 index 0000000..6e8b038 --- /dev/null +++ b/electron/app/components/VideoList.tsx @@ -0,0 +1,60 @@ +import { Button } from '@/app/components/ui/button' +import { cn } from '@/lib/utils' +import { FileCard } from './FileCard' + +type ProgressData = { + progress: number + status: string +} + +interface VideoListProps { + videos: string[] + progressMap?: Map + onRemove: (path: string) => void + onClearAll: () => void +} + +export function VideoList({ videos, progressMap, onRemove, onClearAll }: VideoListProps) { + return ( +
+
+
+

Uploaded files

+
+ {videos.length > 1 && ( + + )} +
+
+ +
+ {videos.map((path, index) => { + const progressData = progressMap?.get(path) + return ( + onRemove(path)} + progress={progressData?.progress} + status={progressData?.status} + /> + ) + })} +
+
+
+ ) +} + diff --git a/electron/app/constants/templates.ts b/electron/app/constants/templates.ts new file mode 100644 index 0000000..fdbc0bd --- /dev/null +++ b/electron/app/constants/templates.ts @@ -0,0 +1,42 @@ +import { Template } from '../types' +import { getVideoPath } from '../utils/video' + +export const templates: Template[] = [ + { + id: 'oneliner', + captionStyle: 'oneliner', + name: 'Oneliner', + src: getVideoPath('oneliner.mp4'), + textColor: '#ffffff', + highlightWordColor: '#ffff00', + outlineColor: '#000000', + glowEffect: true, + font: 'montserrat-black', + position: 'bottom', + }, + { + id: 'karaoke', + captionStyle: 'karaoke', + name: 'Karaoke', + src: getVideoPath('karaoke.mp4'), + textColor: '#ffffff', + highlightWordColor: '#00f924', + outlineColor: '#000000', + glowEffect: false, + font: 'komika-axis', + position: 'bottom', + }, + { + id: 'vibrant', + captionStyle: 'vibrant', + name: 'Vibrant', + src: getVideoPath('vibrant.mp4'), + textColor: '#898284', + highlightWordColor: '#7ef1c5', + outlineColor: '#000000', + glowEffect: false, + font: 'roboto-bold', + position: 'center', + }, +] + diff --git a/electron/app/types/index.ts b/electron/app/types/index.ts new file mode 100644 index 0000000..acf67c6 --- /dev/null +++ b/electron/app/types/index.ts @@ -0,0 +1,78 @@ +import { ModelInfo } from '@/lib/preload' + +export interface Template { + id: 'oneliner' | 'karaoke' | 'vibrant' + captionStyle: 'karaoke' | 'oneliner' | 'vibrant' + name: string + src: string | null + textColor: string + highlightWordColor: string + outlineColor: string + glowEffect: boolean + font: string + position: 'bottom' | 'center' +} + +export interface Settings { + selectedTemplate: Template['id'] + exportFormats: string[] + selectedFont: string + selectedModel: ModelInfo['name'] + textColor: string + highlightWordColor: string + outlineColor: string + glowEffect: boolean + captionStyle: Template['captionStyle'] + captionPosition: 'bottom' | 'center' + selectedLanguage: string +} + +export const defaultSettings: Settings = { + selectedTemplate: 'karaoke', + exportFormats: ['9:16'], + selectedFont: 'montserrat-black', + selectedModel: 'whisper-1', + textColor: '#ffffff', + highlightWordColor: '#ffff00', + outlineColor: '#000000', + glowEffect: false, + captionStyle: 'karaoke', + captionPosition: 'bottom', + selectedLanguage: 'en', +} + +export const FONT_NAMES = { + 'montserrat-black': 'Montserrat Black', + 'komika-axis': 'Komika Axis', + theboldfont: 'THEBOLDFONT', + 'kanit-bold': 'Kanit Bold', + 'poppins-black': 'Poppins Black', + 'oswald-bold': 'Oswald Bold', + 'bangers-regular': 'Bangers Regular', + 'worksans-bold': 'WorkSans Bold', + 'roboto-bold': 'Roboto Bold', +} as const + +export const getFontName = (fontId: string): string => { + return FONT_NAMES[fontId as keyof typeof FONT_NAMES] || 'Montserrat Black' +} + +export const availableFonts = [ + { id: 'komika-axis', name: 'Komika Axis' }, + { id: 'montserrat-black', name: 'Montserrat Black' }, + { id: 'theboldfont', name: 'THEBOLDFONT' }, + { id: 'kanit-bold', name: 'Kanit Bold' }, + { id: 'poppins-black', name: 'Poppins Black' }, + { id: 'oswald-bold', name: 'Oswald Bold' }, + { id: 'bangers-regular', name: 'Bangers Regular' }, + { id: 'worksans-bold', name: 'WorkSans Bold' }, + { id: 'roboto-bold', name: 'Roboto Bold' }, +] + +export const availableExportFormats = [ + { id: '9:16', name: '9:16', description: 'Perfect for TikTok, Instagram Stories, YouTube Shorts' }, + { id: '16:9', name: '16:9', description: 'Standard for YouTube, desktop viewing' }, + { id: '1:1', name: '1:1', description: 'Instagram posts, Facebook' }, + { id: '4:5', name: '4:5', description: 'Instagram feed posts' }, +] + diff --git a/electron/app/utils/errors.ts b/electron/app/utils/errors.ts new file mode 100644 index 0000000..6a01b7a --- /dev/null +++ b/electron/app/utils/errors.ts @@ -0,0 +1,73 @@ +import { toast } from 'sonner' + +export const showErrorToast = (error: any, count: number = 1) => { + const countText = count > 1 ? ` (${count} videos)` : '' + + switch (error.name) { + case 'API_KEY_MISSING': + toast.error('🔑 API Key Not Configured', { + description: 'Add OpenAI API key in settings for better transcription quality.', + action: { + label: 'Settings', + onClick: () => { + // Will open API key settings + }, + }, + }) + break + + case 'API_KEY_INVALID': + toast.error('🔑 Invalid API Key', { + description: 'Please check your OpenAI API key in settings.', + }) + break + + case 'NO_LOCAL_MODELS': + toast.warning('📥 Using Online Transcription', { + description: 'Local models not found. Transcription performed via OpenAI API.', + }) + break + + case 'BINARY_NOT_FOUND': + toast.error('⚙️ System Error', { + description: 'Application components not found. Try reinstalling CapSlap.', + }) + break + + case 'BINARY_DEP_MISSING': + toast.error('🧩 Missing System Libraries', { + description: 'Media tools require static ffmpeg/ffprobe builds. Please reinstall or contact support.', + }) + break + + case 'NETWORK_ERROR': + toast.error('🌐 Internet Connection Problem', { + description: 'Check your network connection and try again.', + }) + break + + case 'RATE_LIMIT': + toast.error('⏰ Rate Limit Exceeded', { + description: 'Too many requests to OpenAI API. Try again later.', + }) + break + + case 'QUOTA_EXCEEDED': + toast.error('💳 API Quota Exhausted', { + description: 'Top up your OpenAI balance or use local models.', + }) + break + + case 'FILE_NOT_FOUND': + toast.error('📁 File Not Found', { + description: 'Make sure the video file exists and is accessible.', + }) + break + + default: + toast.error(`❌ Error${countText}`, { + description: error.message || 'An unexpected error occurred. Please try again.', + }) + } +} + diff --git a/electron/app/utils/video.ts b/electron/app/utils/video.ts new file mode 100644 index 0000000..b9523b9 --- /dev/null +++ b/electron/app/utils/video.ts @@ -0,0 +1,8 @@ +export const getVideoPath = (filename: string) => { + if (import.meta.env.DEV) { + return `./assets/${filename}` + } + + return `res://videos/${filename}` +} + diff --git a/electron/lib/main/sidecar.ts b/electron/lib/main/sidecar.ts index 7dfed4c..a2443d2 100644 --- a/electron/lib/main/sidecar.ts +++ b/electron/lib/main/sidecar.ts @@ -10,7 +10,7 @@ export class Sidecar { private proc: ReturnType | null = null private rl: readline.Interface | null = null private pending = new Map void; reject: (e: any) => void }>() - private progressCb: ((p: Progress) => void) | null = null + private progressCallbacks = new Map void>() private writeLock = Promise.resolve() constructor() { @@ -96,20 +96,27 @@ export class Sidecar { console.log('[SIDECAR] Raw response:', line) const msg = JSON.parse(line) - if (msg.event === 'progress' && this.progressCb) { + if (msg.event === 'progress' && msg.id) { console.log('[SIDECAR] Progress event:', msg) - this.progressCb(msg) + const callback = this.progressCallbacks.get(msg.id) + if (callback) { + callback(msg) + } return } if (msg.result !== undefined && msg.id) { console.log('[SIDECAR] Success response for:', msg.id, msg.result) this.pending.get(msg.id)?.resolve(msg.result) this.pending.delete(msg.id) + // Clean up progress callback + this.progressCallbacks.delete(msg.id) } else if (msg.error && msg.id) { console.log('[SIDECAR] Error response for:', msg.id, msg.error) const friendlyError = this.createFriendlyError(msg.error) this.pending.get(msg.id)?.reject(friendlyError) this.pending.delete(msg.id) + // Clean up progress callback + this.progressCallbacks.delete(msg.id) } } catch (err) { console.error('[SIDECAR] Failed to parse response:', line, err) @@ -194,7 +201,9 @@ export class Sidecar { const id = randomUUID() console.log('[SIDECAR] Calling method:', method, 'with params:', params, 'id:', id) - if (onProgress) this.progressCb = onProgress + if (onProgress) { + this.progressCallbacks.set(id, onProgress) + } const req = JSON.stringify({ id, method, params }) + '\n' return new Promise((resolve, reject) => { @@ -204,6 +213,7 @@ export class Sidecar { this.writeWithLock(req).catch((err) => { console.error('[SIDECAR] Failed to write to process:', err) this.pending.delete(id) + this.progressCallbacks.delete(id) reject(err) }) }) diff --git a/rust/.gitignore b/rust/.gitignore index d64470d..8fbed13 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -1,2 +1,3 @@ target -/**/*.rs.bk \ No newline at end of file +/**/*.rs.bk +RUST_GUIDE.md \ No newline at end of file diff --git a/rust/src/captions.rs b/rust/src/captions.rs index 5d3fd62..1b6f280 100644 --- a/rust/src/captions.rs +++ b/rust/src/captions.rs @@ -20,13 +20,46 @@ pub async fn generate_captions_single_pass( mut emit: impl FnMut(RpcEvent) ) -> Result { + // Progress ranges for each step (0.0 to 1.0 overall) + const PROBE_START: f32 = 0.0; + const PROBE_END: f32 = 0.05; // 0-5% + const AUDIO_START: f32 = 0.05; + const AUDIO_END: f32 = 0.15; // 5-15% + const TRANSCRIBE_START: f32 = 0.15; + const TRANSCRIBE_END: f32 = 0.65; // 15-65% (longest step) + const ENCODE_START: f32 = 0.65; + const ENCODE_END: f32 = 1.0; // 65-100% + + emit(RpcEvent::Progress { + id: id.into(), + status: "Starting...".into(), + progress: PROBE_START, + }); + let temp_dir = std::env::temp_dir().join(format!("capslap_captions_{}", id)); if let Err(e) = fs::create_dir_all(&temp_dir) { return Err(anyhow!("Failed to create temp directory: {}", e)); } + // Step 1: Probe (0-5%) + emit(RpcEvent::Progress { + id: id.into(), + status: "Analyzing video...".into(), + progress: PROBE_START, + }); let probe_result = probe(id, ¶ms.input_video, &mut emit).await?; + emit(RpcEvent::Progress { + id: id.into(), + status: "Video analyzed".into(), + progress: PROBE_END, + }); + // Step 2: Extract audio (5-15%) + emit(RpcEvent::Progress { + id: id.into(), + status: "Extracting audio...".into(), + progress: AUDIO_START, + }); let audio_filename = format!("audio_{}.mp3", id); let temp_audio_path = temp_dir.join(&audio_filename); let audio_params = ExtractAudioParams { @@ -35,8 +68,18 @@ pub async fn generate_captions_single_pass( out: Some(temp_audio_path.to_string_lossy().to_string()), }; let audio_result = audio::extract_audio(id, audio_params, &mut emit).await?; + emit(RpcEvent::Progress { + id: id.into(), + status: "Audio extracted".into(), + progress: AUDIO_END, + }); - + // Step 3: Transcribe (15-65%) + emit(RpcEvent::Progress { + id: id.into(), + status: "Transcribing audio...".into(), + progress: TRANSCRIBE_START, + }); let transcribe_params = TranscribeSegmentsParams { audio: audio_result.audio.clone(), model: params.model, @@ -47,7 +90,18 @@ pub async fn generate_captions_single_pass( video_file: Some(params.input_video.clone()), }; let transcription = whisper::transcribe_segments_with_temp(id, transcribe_params, Some(&temp_dir), &mut emit).await?; + emit(RpcEvent::Progress { + id: id.into(), + status: "Transcription complete".into(), + progress: TRANSCRIBE_END, + }); + // Step 4: Encode videos (65-100%) + emit(RpcEvent::Progress { + id: id.into(), + status: "Encoding videos...".into(), + progress: ENCODE_START, + }); let captioned_videos = optimized_multi_format_encode( id, ¶ms.input_video, @@ -64,6 +118,11 @@ pub async fn generate_captions_single_pass( params.position, &mut emit ).await?; + emit(RpcEvent::Progress { + id: id.into(), + status: "Complete".into(), + progress: ENCODE_END, + }); Ok(GenerateCaptionsResult { probe_result, @@ -89,6 +148,9 @@ async fn optimized_multi_format_encode( position: Option, emit: &mut impl FnMut(RpcEvent) ) -> Result> { + // Progress ranges for encoding step (65-100% overall) + const ENCODE_START: f32 = 0.65; + const ENCODE_END: f32 = 1.0; if export_formats.is_empty() { return Err(anyhow!("No export formats specified")); } @@ -169,10 +231,20 @@ async fn optimized_multi_format_encode( } // Wait for all tasks to complete and collect results + let total_formats = tasks.len(); let mut captioned_videos = Vec::new(); for (idx, task) in tasks.into_iter().enumerate() { let result = task.await.map_err(|e| anyhow!("Concurrent task failed: {}", e))??; captioned_videos.push(result); + + // Emit progress for encoding step (65-100% overall) + // Each format completion moves us forward in the encoding range + let encode_progress = ENCODE_START + ((idx + 1) as f32 / total_formats as f32) * (ENCODE_END - ENCODE_START); + emit(RpcEvent::Progress { + id: id.into(), + status: format!("Encoding format {}/{}...", idx + 1, total_formats), + progress: encode_progress.min(ENCODE_END), + }); } Ok(captioned_videos)