From 4308c72441d9d8a5c9a25b613386d5594630d1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Tue, 8 Sep 2026 19:35:30 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20criada=20a=20aba=20An=C3=A1lises=20no?= =?UTF-8?q?=20painel=20CEP=20para=20ler=20do=20banco=20(anali?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - criada a aba Análises no painel CEP para ler do banco (analises.db) e copiar/abrir o J-SOM de som (transcrição por cena) e o de imagem (evidências visuais), com novo módulo engine/gerar_relatorio_de_analises.py e testes - criado o módulo engine/editor (leitura do plano de edição em JSON, tradução de tempos de origem para a timeline ativa e aplicação de cortes/zooms/marcadores no Premiere via MCP) e o ponto de entrada engine/aplicar_plano_de_edicao.py, com testes; corrigido engine/integracoes/premiere/cliente_mcp.py para levantar os erros específicos já declarados em erros_mcp.py em vez de RuntimeError genérico Resumo: - 15 arquivos alterados - 8 novos - 5 modificados - 2 removidos 7 files changed, 172 insertions(+), 8 deletions(-) Arquivos: - .jhonny/analises.db - .jhonny/analises.db-shm - .jhonny/analises.db-wal - code/cep-plugin/index.html - code/cep-plugin/main.js - code/cep-plugin/styles.css - code/engine/integracoes/premiere/cliente_mcp.py - code/engine/aplicar_plano_de_edicao.py - code/engine/editor/ - code/engine/gerar_relatorio_de_analises.py - code/engine/testes/duplos_de_premiere.py - code/engine/testes/test_aplicador_de_plano_de_edicao.py - code/engine/testes/test_gerar_relatorio_de_analises.py - code/engine/testes/test_leitor_de_plano_de_edicao.py - code/engine/testes/test_mapeador_de_tempo.py --- .jhonny/analises.db | Bin 520192 -> 864256 bytes .jhonny/analises.db-shm | Bin 32768 -> 0 bytes .jhonny/analises.db-wal | 0 code/cep-plugin/index.html | 22 ++ code/cep-plugin/main.js | 93 ++++++++ code/cep-plugin/styles.css | 11 + code/engine/aplicar_plano_de_edicao.py | 85 +++++++ code/engine/editor/__init__.py | 35 +++ .../editor/aplicador_de_plano_de_edicao.py | 223 ++++++++++++++++++ code/engine/editor/erros.py | 22 ++ code/engine/editor/escrita/__init__.py | 5 + .../editor/escrita/escrita_no_editor.py | 91 +++++++ code/engine/editor/leitura/__init__.py | 5 + code/engine/editor/leitura/leitor_de_plano.py | 88 +++++++ code/engine/editor/mapeamento/__init__.py | 5 + .../editor/mapeamento/mapeador_de_tempo.py | 81 +++++++ code/engine/editor/modelos.py | 69 ++++++ code/engine/gerar_relatorio_de_analises.py | 180 ++++++++++++++ .../integracoes/premiere/cliente_mcp.py | 54 ++++- code/engine/testes/duplos_de_premiere.py | 182 ++++++++++++++ .../test_aplicador_de_plano_de_edicao.py | 97 ++++++++ .../test_gerar_relatorio_de_analises.py | 90 +++++++ .../testes/test_leitor_de_plano_de_edicao.py | 92 ++++++++ code/engine/testes/test_mapeador_de_tempo.py | 69 ++++++ 24 files changed, 1591 insertions(+), 8 deletions(-) delete mode 100644 .jhonny/analises.db-shm delete mode 100644 .jhonny/analises.db-wal create mode 100644 code/engine/aplicar_plano_de_edicao.py create mode 100644 code/engine/editor/__init__.py create mode 100644 code/engine/editor/aplicador_de_plano_de_edicao.py create mode 100644 code/engine/editor/erros.py create mode 100644 code/engine/editor/escrita/__init__.py create mode 100644 code/engine/editor/escrita/escrita_no_editor.py create mode 100644 code/engine/editor/leitura/__init__.py create mode 100644 code/engine/editor/leitura/leitor_de_plano.py create mode 100644 code/engine/editor/mapeamento/__init__.py create mode 100644 code/engine/editor/mapeamento/mapeador_de_tempo.py create mode 100644 code/engine/editor/modelos.py create mode 100644 code/engine/gerar_relatorio_de_analises.py create mode 100644 code/engine/testes/duplos_de_premiere.py create mode 100644 code/engine/testes/test_aplicador_de_plano_de_edicao.py create mode 100644 code/engine/testes/test_gerar_relatorio_de_analises.py create mode 100644 code/engine/testes/test_leitor_de_plano_de_edicao.py create mode 100644 code/engine/testes/test_mapeador_de_tempo.py diff --git a/.jhonny/analises.db b/.jhonny/analises.db index 0e884699b09d685eac598d192f5cc76b2ac50f57..4acc632aa6406919a761cf3e539ce37fc718b047 100644 GIT binary patch delta 71288 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%0DC64ZSTL#2?kXpNtwc=4CA~41r|2QEyr% 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 Ezr z!D*fB+CIYw>&e8S=$L}`CRv9Ek2={1Ct1i?4D6|5HGdx{^Q6wOP`Y8pT)W?4KeBU9j`N9e6@dQp1k6&Qxx8Gj0+Q=u%w-Lm#rdk%P}oMom4U z6|jXVk)I=D!moy#h87|fr)vf5LZg5kDq}Mo>uD31H(HRp67`o}_5{(wMpe|p0!1)~ zEZU!Q^vd4d-M=T$!uEexc`a;YMJ=pKEhe-un(R)a|2y^OvT)f5b*z`|hQ=N2XD8x< zm<#PHhTDc;PeRj%7zpk4XYD{r4Lo<+i-Aok>apMfZ=Q{KV%mF{4E;6FhEW#lYuCIRXmIE@V~7F0UEdS+Bsw_4HH7p#~fa#RX^4x7NRA#}dUJI`TR zJbMndrDl4q{g7mf39v|gd?sgG+tuN;T=6zbkUOfEq%jlJVC9*_uIgp%t2|`ZXet}T z>n)62(&ghm7f-92`Wgf?jUQjrpXKcD@{zsmxR>jRQ%md&@kVl-6|1wJw3|!7R$G$< z@|m;jxu(d#KQ4%7dsGq}R?DJc~73iSg&B?!h^VA|v!v1R0{9F`e9pF{K782d=_Au6qtbAao8(LMO7-z;Hoz z)Wt7Pw{`aMhflX_{XrUz3h5~?a+SoSPd9JQadOtdF8#%w|9mGa;jZ^(*PTgij?NHw+$0L zkPPPe<+}&k?L(T+P~K*QtwL(;KFXouHFwGj2HD%9YVbUwJ&%mEYrD%5lo3C;h)Jm| z##(MRim!7oicJ&+q2U*QHytUs~=j(1N=DzqDHJsw^F0?S&AmToU7YpIm=LKsfvu6Hk znlP>E^k;DeCE8Kd#{(z-d$yfx8Fc33amShfJ4KtwW_}qiqUZn4u{Pl{l#g#l00YN9;uQ0{ zq_tm|%TAmCQFKy{WUy=<6`=-RZo5^zMxPa}pM@4H`YI#`s^+MHb8sQz*UeK8Ok~yg zu2cWr9;6milOKxM;u~X(2o7MA5rN<6Unn8j6KoIKAkYS-8os+^nn0!Cjt|0Ts~g8M zNb5?hcoPJk1OWzzp7<=3PI&ydCm=8wrP~EFVPo(EkfzWKI*1S;uWp{|eW~3@GSc;T z7nCGefhH-w666xTwx7q^4LqG*`wX;44+t3yNe?Yq(qqB3vlu75d|*r*$$yp{L^^)0q&7GGbx-(|+YxSJ~iMqCR zx0(Dpc_uwtAXykLoJoIgej5IwcQ`rf^Q-JUH-Z-{RYG)#cR(KD3{I2$5?nx2?=F5` za`Rq;!lh5v_vXl%j1OErPY>K#2QlDDjiKR%0M~qLHUfW!R;C|D5<@X%!LctwQtO{<8CHCiR>h9f>2A% zRcQP*`q6|webZTE?(OVN z3^;ZSh8!S?q1U&bNB?!kX0+F)s^MJ5@dFX*Wjim`nEI@xI`EKo9S!gnymI!T=P)X$dL>byZxn zYp&-}T@hkmmba$;!+M!Pk+Z0MB#($fs>|a88pDZJP0Knq0Ldi!Os{znd4a1K`FBeDj+>?vyMXO#ZZ zjQgu^<(Z4Nf%x{nm4k`z?&)qvR1H9S>5Ie(0`c8~q#nO1oSP*P`Qm$$pWCTMuMkfI zTRNVJmLPmAtP(~)-XFKp@ZGj{#?`-pb)`uPLLAR#L>Nq98vs}+3>@Xv)VSN2OsFI7KV`0LJpQC0hqq zs8h?YHURHL0eH3Uo}u0l1$tk14(5N)NBeyy4q$O{ncZ3l;?yXZ&(-&n9kL&P1G%tn zncYh8-gbI<+@=@kmyF{R~ch=BK z&$ae|jIzm|<~`eJ-jA)JD`N(*Y{?gq*A|2ZRX{`s_Aj-23tZUCP;=5d|(9_ zp@ljlzh-4xn0hAAsosQ1B#I54Drv|a|9PSg0;iN?D>Xa|VJtUrj;M0Ya# z3{(5yH{g5*#Wcc`yAt1xSHW=1V;;rzVaPCSO5jl_A2REO%ZrO8UVjzNbf`Kb& z&Jh3q!IhsYK!;2dhtFZhkVxz?4Fqxp)O*OT<)z;dZIjpd9b9=bOb71^ znHX0POP+V4$^>RiKrFTst~puWr9xVNIPs!mo9r6%+&R?g1khPf7bMd*8w_P5@FF=0 zG425-WfKLGKuBOEh8xEgOI&12;33Vn5~GK)MOH`%j9Fq5Zznb|ir=V*Z70}cId8F& zi9G@lBSl0BCCvR@l}Z^%hcZJ)E>P>A%1o0>~2ymNclC>LB$Jpn~Dd#3o+M zi6L7V`Y8|`+cfY2Fy&4`J;dl{Oi&^izI?V*aDvfy$ZLVdh!2jPm3S+PqGU6}odSkdf4F-W{SuQ^pH$KU>x*Z z$4cpZ{)g7v$r(u4SBQM#{6t_fS&2RpC&O=K=K?cgP|QIkP51L}kw6IvP8SUll`zmW zi<(0Ydb}_}(69VwL|+nKF}N!KqX2j<^tr8{+jd)&QHBX>iSY2qdYO2J&46ko+7uW# zcDWvfh8uz>JWWZVZtxi794j@@6~;uC6Vpy22OPrwYhgztc4$jz|4AdKsn@Ioe$nJC z=5{je1qT2?&J_KG`if_3`?v5)g11CWqwALdYwU+Vv~QiA z9mtLZcTs}RByvXrR0H25>^q@lgFeGK5Qx{^ID9;S5+*X6nOzX23AG0^gudzvFq>O`#^~&f8&+ub_Wf*dc`NN@Q_xDPl|grdz(E= zJJi(XZFYv=wwQFh9261Z!_=FS6@G#QAB_byy*J_Vby3qhp9L8?zbiDS?a$geXy1rH zYyfmo(w#`wCi3bER64appQq@X(D56KHo$2aG=H#lIj&KN1eSv?vG+#e@@H>m89(&2 zomV^Ach5#aq1A+E_0-e!_t64uA6*MX&|1~@30k_pgc5{oT|>%Mr#``bumt*;R1}Xt zi5YPHifAmKbC{FJ@O{l-A-N?_1p1*wRH{aj_XCzd4HpjgAWb!?oOeX}0U%sk?hTNx z75Y~hFNsKL24_t2tyTnP5m=~TI---q%pj)%BdcZ4&~wQp(nL;j)?pCPl}#wcmC(`| zL2`VP@T9+LBwwP-mCfl54e698?O@sBYTKRzI?X7XD$6g=aW?lb%dhndc=vtyaMq55 zZ;J(wV8nt(bp^KvuUrW-_K)G%nP*%ERHxCxwy%kU>jnEZi9cBk4T}}`zaM^GA(FxmM9`e{qzbi z$4Q=}*8EGZF2T+n^%Vi&555SNdEFZj38qzNaxdIY)3@$Mi?>Fq~SwE#jjzz|Xw})GVZb`Z0 zj_P;j=f5eL?XNOs`9=R0*Z0f>-k7=cZTSx~0+{2mncWPG4y4ik`_Uirs}837%L8QRLA4Zv zQxruEYQRSgAxaJdRsJ7Z)(LdZ&atvw`n9;P05n83K~$-~NW2Ve#q_1Sq*2M;+V3}$ zy2c20fEnTpu)@%S#g(UYGKK3t2F%roe{^|`H6PPO9mGTJn;doeyEM%42#KYmo#w|Z zp-PFS2eN|F-cgb31)y;oKGV790~H_skkOS`y>v#zbt* z5Z-v7A9Se=o!Frj-&*0sLk;mM;*> z85keU6qp9o44o?I<0Cdx&e-;V>{97CGkT^qRhEdUbNRfHKdNUw4Bojw;>Qm zLda$F3lf|G4&q;~Y#g`ES~024%v(k`ERIG)*7Qhrd7bqrnrRRL1fRBkcg z#WP%qE#T5J#R>t(k zt|YMl_#;0j9uj<=dFNGPRpq;x|NitT)#JZje{cVvWk_*7-0=b+FlvWj}uL|{mdA3<~3kAlFkED z@D%h={36)34RSG{uu8>@V$`vPdBK*?lcxjOiELuWfRlmB$}NRod7oGKg)wqX8(2ah zAEZLWq`{mumYMlH)FR1WiLQee0WwKqNVrdmSfCw)HX%X+pNHB=iX31CzMAt!cEr3d z>>5e0p=K2G6Oe|T`X;DwH9)KWF z&*9kzI)KK`#B+kmq_G_RX9Ri^`a5+9csjW5IA>Uq5^BeU0Y%g4uMQ-SsHBBq9|QHW zeDT_FOQVB!b+f=H9kgqnlW0gjO`F)rSuA!p>#G@3a1r{AK+?I!H9+Nlk$BDo>jaL% z_<`?C=EtVF>=G=0F=0?rnMs-*!xAygZsy7#6U8*Q{_Qhxz`$$e#AiGNJ)z&hU>VLsTp+?f!V+e% z58grjZm}AQt6s9<*14)T2Y4>XON#wiP{?5U@qd03VlsbZavq+&3PHp+S*UO=0COWxKisXZRtKQFqldkHI zVnL91VK8*fD)v-+2T|E#WLZxhyLK|LHDiSZ6~0X?L@3BLs@l}l*X&+P--y^%`a0$T z`u4kal_pQmx)=0TTL_$xDkigw`PCcgd8yZA>>X{NsLF{;!SPj&LW z<5hZ;Zk}M5=KX%V2i)cJ`iz-;$$%--#-2NN>Ws0|rl;pDPdO`PV@l5W=zG!i(JQ0f za!$z!W$(yd5zUP3irgQWmVIe80s*+S_Ss?04;TaOt|FO-k#O=A^!3r&~L%`>d&{Yf~p%U9F7hkosY5x~HV5yn{ z$GcczE{a0Sy^FiOi)G%0QjK;;3nSw~OTCM`yo)8?#hr3-a&&BI=!wMD-_2ENQK&p| zwJmY=c;f1BrlE16$GnS2y^BZmMR;6D#Vz@!OF~;zC`Ueav-|g9_wOe2$VH)tyo(3D zi;cRa@^)Fyj{oJ;{g=sq%0*r%F$0=mZ*JELLHx^f=0inLNHK ze;5HVUYgGT!{}?GS@JvEop8?dcM%|9j_4K@=k(8(i0P=HGF2WAfrJty)1+m0!j9^p z)LA{N@_9PL=yP+x?b!iNtD*utG~n5Itk9Ch=YOItKvL)iXmupywf}hj;uYtwo$^we z#)T^9@2LO%X@sWy$twBdR(c5^5e|Iio6%kK(DXm~qCZ)Y&yJv`(VsSz`@2;WIQ>k` z;K!O@H|_Q1pTngH6x*#U7ivpk>)S5pZ1`=LQw~ z5m#BgU+t3gL|&z7YR^%-kNWE|duryG>0`%Fx$?3x(;8!;gpS)&!ew(^D%x?VXq0xo z*1Y-5J-m78No8|9(W%zpg!>;RL>#MMT*Pn=ke!%Um?@qFj295kAX(!KzXdBfIgyM} zCtLmQE`QBcc()o;41$t@>orPPddsXc9n7hI~g`io*$BrzwdU~-06~5%eqx8>$ zNKaAktJvO{v5}r$3XF;aF9t}i_?eSuOGdPpde)9Qu?oMOxQ9g@A^Zxjw~AdH_#oC? zW&4(3cWN(Ir5}rL2>1Fw4Lj2e8P~<9t2!W?!+lOP7O?_IL&E(L3JOdDtwU%5nscJZ z{$LAv`Jt-5dl(pUTvH&viY-3!lhspgezD4vUGz*9Ci}>4yYGqmmHRs}kt9S{hx4kX zhT5cLe4TM_T8q@B(S?zHp=UyEKt`&CBFIuTbv0~*mbp%b>RQ`5C%hn4YFKwrPxD`5 zqd)!h?vLkR(%t<#TaB5Io0GIqc`CP#a;peJ=M65(^wVMb%?VY>a++ia9kf` zU1x08O_>X9SI#f)r{X`_1?7wDIn*bwq@-V98A<=?w{n6+<%B>=G@DlaY zcO;?MHqffC9{LW~&fM?uSjRpgd*`O_e9t+YIFt(smj!DqZm*vF&h9F7<(APj$&a42w9v8gLM_ZnXqI|sbV1~^(37DStb~}m5{`#UREt~&pi4_x z2{E;}r87P-Bm-MHZ4KjEe_UECj1BT{)Kkk_IX9XF7#Fs73RH*IOkKDY%xppj`G6dE z>ROwr%T9cNJ9TZUGId#I>gXp^M}non4jCnKY4z*EXr`*3=d@P)a~(24=Q&fPv-Q*& zc}`q4tLB`fo|z9L<@TwBcfOX#r!3eTuB%2?a|VSMWF%Hsruw#;bG|IG^yGf3u1%fB zRb?BP|FmLf3ei5pID2z z>S!tZ%!Sp#{SlIIjse}FW2#5y<6`4;9JRiaqZg~W5qK29nUsi-1xee+ku}+A6vt_W zl?HkW3eA6Jv5xG_?wYqZ+(?ylcCvk!EJ!lgsvzmCc1aY)YOP`B_vcb4w{F1421Nli zhL{h@i3tX;T6SW@GduAOX7Sc^2ez}B`x?n(TE6(ys?2?LciZO6+*gw*-wNB-R)ZTm z!8)ykf#Ht^yT&aI-W=GPuQo!`{L+Y>DoZ#Qw+B8dVW#57YF8xwhie-%JhIPYa6NEt z!>fXJu0zAKoA48(QeVky{5UURmkPlZdTU3uvysy@I9AY(@#5hZ!A*xx580=8N1hp> zRZYwY06 zm$1!e*U4VDRLE62qk_32s;>hfH_w%(Fz%&H2c%y-sn4}Sz75z*5Z60HjHEGlk)m3> z8&XLDM;WY+wY{DEmQeF?W#MncqXjjZk#X-P4x8))NEN)+2pw-=+L(cfpJxW9b%lZ1 zdS0amhUMPMeEP`Y^64$jn-2{)Z*EcH&9{xH^qXBxqNQ2#o=*8xs3l__*T-6kS~FJm zS+O~tFCB+)A)#oC=8`lSW<0)iG$tQI5z@WXwGiPU3&>rBu#vUz9w#=7M+VPbgi_Or z5e3j;<#OhT4%tGo_K3ez=#3>p=`c6L3BX&9(-~GG+7&^PzIOKRI49S$h{(Z;M-l0a zsg4V{y^1|g=J#*MIdO>$yK5X8MX&Kr4PRp6Ua#Q-D&M*P58}YeTL1t6 delta 803 zcmYk3TTE0}6o&WO`<%1)KDRw!OCVCpOsbifjxgg4K_V5EB5k746cW=H7^Gqof`XQt zCJ-NxC`q*k*#=8vQ?9Lbw9p=-Nli;7#tY>En?k8QUfQT3#J;qBXhIJ@_2EzcWPMqe ze|38CPVd!It5uE=>NuUd8TjSuvJ^$L*+7cHNvfI7jg)|_@q4MfYPQ2}@Hx~f%>eYP z?^V?vsFHWnAS{;yT(}Pp;vUOw$N>k0PhsJ#z>VUTaGZd5(Cy5HLR-$PgKTGZ>AK?A zcFNsnSWNBl?GE{85Tb0m<@+qoB+Z8nn`=pyi6pJ| ziBQRuA(!hQd^}tazOXOihU=BNcQn6K8Lo7w*Y>~B?(DP@fes!(rO zv0ig@DmI|lbAL$jX#VD=0U#XgFGPz2YFOJ>17ExAsYu+)O}-sO@hgS1usHfwac1E={QkaxVNUJFh7!rC6X_KcPAuUL);>p zCc%FPgq^gdh^z>~-*V=AB~Sj|#|E4Si&1Fl@{}%5{l?QEx5G40Il6?5pN%1-$=Ga| z`kX$jAJR*7i_h~Bewc6JHv5H*vJco+Cg=hkqs??14QRh=pKC2zsg|bRSHDnO)i>32 zqxG@%zExn6L^2ZJ5Zf53*?Z Retakes + @@ -333,6 +336,25 @@
+ + + diff --git a/code/cep-plugin/main.js b/code/cep-plugin/main.js index 32bbbc1..21df6eb 100755 --- a/code/cep-plugin/main.js +++ b/code/cep-plugin/main.js @@ -206,6 +206,7 @@ function switchTab(name) { { key: "silence", panel: "tabSilence", btn: "tabBtnSilence" }, { key: "scanner", panel: "tabScanner", btn: "tabBtnScanner" }, { key: "retakes", panel: "tabRetakes", btn: "tabBtnRetakes" }, + { key: "analises", panel: "tabAnalises", btn: "tabBtnAnalises" }, { key: "bridge", panel: "tabBridge", btn: "tabBtnBridge" }, { key: "models", panel: "tabModels", btn: "tabBtnModels" }, { key: "settings", panel: "tabSettings", btn: "tabBtnSettings" }, @@ -224,6 +225,98 @@ function switchTab(name) { if (name === "settings") renderEditorPersonalities(); if (name === "scanner") { renderScannerTranscriptionOptions(); loadScannerProfile(); renderScannerHuggingFaceStatus(); } if (name === "retakes") retakesAoAbrir(); + if (name === "analises") analisesAoAbrir(); +} + +// ---- Análises --------------------------------------------------------------- +// Aba que lê o banco de análises (.jhonny/analises.db) já produzido pelo +// Scanner e mostra/copiar os dois J-SOM: som (transcrição por cena) e +// imagem (evidências visuais). A leitura acontece na engine, via script +// dedicado; a aba nunca escreve no banco. +var ANALISES_ENGINE_SCRIPT = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code/engine/gerar_relatorio_de_analises.py"; +var ANALISES_DB_PATH = "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/.jhonny/analises.db"; +var analisesRelatorioPath = null; + +function analisesAoAbrir() { + document.getElementById("analisesCaminho").textContent = ANALISES_DB_PATH; +} + +function analisesFormatarTempo(segundos) { + var s = Number(segundos) || 0; + var min = Math.floor(s / 60), resto = (s - min * 60).toFixed(1); + return min + ":" + (resto < 10 ? "0" : "") + resto; +} + +function analisesCarregar() { + var stamp = new Date().toISOString().replace(/[:.]/g, "-"); + analisesRelatorioPath = path.join(os.tmpdir(), "jhonny-analises-" + stamp + ".json"); + setBusy("btnAnalisesCarregar", true); taskStart("Análises", "Lendo o banco de análises", null); + var child = childProcess.spawn(SCANNER_PYTHON_BIN, [ANALISES_ENGINE_SCRIPT, ANALISES_DB_PATH, analisesRelatorioPath], + { cwd: "/Volumes/Merongo/SISTEMAS/GENIAL SISTEMAS/Jhonny/code", stdio: ["ignore", "pipe", "pipe"] }); + var stderr = "", stdout = ""; + child.stdout.on("data", function (chunk) { stdout += chunk.toString(); }); + child.stderr.on("data", function (chunk) { stderr += chunk.toString(); }); + child.on("error", function (error) { setBusy("btnAnalisesCarregar", false); taskEnd(false, "Análises indisponíveis", error.stack || error.message); }); + child.on("close", function (code) { + setBusy("btnAnalisesCarregar", false); + if (code !== 0 || !fs.existsSync(analisesRelatorioPath)) { + taskEnd(false, "Leitura do banco falhou", stderr || stdout || "Não foi possível gerar o relatório."); + return; + } + taskEnd(true, "Relatório carregado do banco"); + analisesRender(JSON.parse(fs.readFileSync(analisesRelatorioPath, "utf-8"))); + }); +} + +function analisesRender(relatorio) { + var video = relatorio.video || {}; + var cenas = relatorio.cenas || []; + var falas = cenas.reduce(function (n, c) { return n + (c.falas || []).length; }, 0); + var evidencias = cenas.reduce(function (n, c) { + var resumo = c.resumo_de_evidencias || {}; + return n + Object.keys(resumo).reduce(function (m, k) { return m + resumo[k]; }, 0); + }, 0); + document.getElementById("analisesStatus").textContent = "Carregado"; + document.getElementById("analisesVideoNome").textContent = video.nome || "(sem nome)"; + document.getElementById("analisesResumo").textContent = + cenas.length + " cenas · " + falas + " falas · " + evidencias + " evidências visuais"; + document.getElementById("analisesResumoCard").hidden = false; + document.getElementById("btnAnalisesAbrir").disabled = false; + document.getElementById("btnAnalisesCopiar").disabled = false; + var lista = document.getElementById("analisesCenas"); + lista.innerHTML = ""; + cenas.forEach(function (cena) { + var bloco = document.createElement("div"); + bloco.className = "analise-cena"; + bloco.setAttribute("role", "listitem"); + var titulo = cena.cena_registrada ? "" : " (sem cena gravada)"; + var tipos = Object.keys(cena.resumo_de_evidencias || {}) + .sort().map(function (k) { return k + " " + cena.resumo_de_evidencias[k]; }).join(", ") || "—"; + var falasHtml = (cena.falas || []).map(function (fala) { + return "

" + (fala.emocao || "—") + " " + + analisesFormatarTempo(fala.inicio) + "–" + analisesFormatarTempo(fala.fim) + " · " + + String(fala.texto || "").replace(/&/g, "&").replace(/"; + }).join("") || "

Sem fala neste clipe.

"; + bloco.innerHTML = + "
" + cena.clipe_id + titulo + "" + + "" + analisesFormatarTempo(cena.inicio) + "–" + analisesFormatarTempo(cena.fim) + "
" + + falasHtml + "

Imagem: " + tipos + "

"; + lista.appendChild(bloco); + }); +} + +function analisesAbrirJson() { + if (!analisesRelatorioPath) return; + childProcess.spawn("open", [analisesRelatorioPath], { detached: true, stdio: "ignore" }).unref(); +} + +function analisesCopiar() { + if (!analisesRelatorioPath) return; + try { + var copy = childProcess.spawn("pbcopy"); + copy.stdin.end(fs.readFileSync(analisesRelatorioPath, "utf-8")); + showToast("ok", "J-SOM de som e imagem copiados (JSON completo)."); + } catch (error) { showToast("err", error.message); } } // ---- Retakes --------------------------------------------------------------- diff --git a/code/cep-plugin/styles.css b/code/cep-plugin/styles.css index 574664b..7812d2c 100755 --- a/code/cep-plugin/styles.css +++ b/code/cep-plugin/styles.css @@ -612,3 +612,14 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus- .button, .path-field input, .select-field, #log, #silenceLog, #modelsLog, .status-panel, .step, .card { border-color: CanvasText; } .progress-fill { background: Highlight; } } + +/* ---- Aba Análises ------------------------------------------------------- */ +.analise-cena { border-top: 1px solid var(--border); padding: 8px 0; } +.analise-cena:first-child { border-top: 0; } +.analise-cena-cab { display: flex; justify-content: space-between; gap: 8px; color: var(--text); font-size: 11px; } +.analise-cena-cab span { color: var(--text-muted); font-variant-numeric: tabular-nums; } +.analise-fala { margin: 4px 0 0; font-size: 11px; color: var(--text); line-height: 1.35; } +.analise-fala strong { display: inline-block; min-width: 34px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; } +.analise-fala.analise-vazia { color: var(--text-muted); font-style: italic; } +.analise-evidencias { margin: 6px 0 0; font-size: 10px; color: var(--text-muted); } + diff --git a/code/engine/aplicar_plano_de_edicao.py b/code/engine/aplicar_plano_de_edicao.py new file mode 100644 index 0000000..6c0f38b --- /dev/null +++ b/code/engine/aplicar_plano_de_edicao.py @@ -0,0 +1,85 @@ +"""Entrada de linha de comando: aplica um plano de edição na sequência ativa do Premiere. + +Espelha o ``executar_scanner.py``/``executar_retakes.py``: recebe o caminho +de um JSON de ações (``kind``/``start``/``end``/``params``/``reason``, +formato das skills de seleção de trechos) e aplica cada ação na sequência +que estiver ativa no Premiere no momento da execução, via o MCP existente em +``engine.integracoes.premiere``. + +Uso:: + + python aplicar_plano_de_edicao.py "/caminho/0E6A8290_edit_actions.json" + +Pré-requisito: o Premiere precisa estar aberto com a sequência correta ativa +e o painel/plugin do MCP em execução — este script não abre o Premiere nem +troca de sequência. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.editor import AplicadorDePlanoDeEdicao, EscritaNoEditor, LeitorDePlanoDeEdicao, MapeadorDeTempoDeOrigemParaTimeline +from engine.integracoes.premiere.cliente_mcp import ClienteMCPPorStdio +from engine.integracoes.premiere.conversores import ConversorDeTimeline +from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline +from engine.integracoes.premiere.sessao_mcp import SessaoMCP + +CAMINHO_DO_SERVIDOR_MCP = CAMINHO_DO_CODIGO / "dist" / "index.js" + + +def executar(caminho_do_plano: Path) -> dict: + """Lê o plano em ``caminho_do_plano`` e o aplica na sequência ativa do Premiere. + + Devolve um dicionário pronto para virar JSON, com o resultado de cada + ação — para o painel exibir o que foi aplicado e o que falhou. + """ + plano = LeitorDePlanoDeEdicao().ler(caminho_do_plano) + + cliente = ClienteMCPPorStdio(["node", str(CAMINHO_DO_SERVIDOR_MCP)]) + with SessaoMCP(cliente): + acesso_ao_editor = AcessoAoEditor(AcessoATimeline(cliente)) + aplicador = AplicadorDePlanoDeEdicao( + acesso_ao_editor=acesso_ao_editor, + conversor_de_timeline=ConversorDeTimeline(), + escrita=EscritaNoEditor(cliente), + mapeador=MapeadorDeTempoDeOrigemParaTimeline(), + ) + resultado = aplicador.aplicar(plano) + + return { + "arquivo_de_origem": plano.arquivo_de_origem, + "todas_bem_sucedidas": resultado.todas_bem_sucedidas, + "acoes": [ + { + "tipo": item.acao.tipo.value, + "inicio": item.acao.inicio, + "fim": item.acao.fim, + "motivo": item.acao.motivo, + "sucesso": item.sucesso, + "detalhe": item.detalhe, + } + for item in resultado.resultados + ], + } + + +def main() -> None: + """Ponto de entrada da linha de comando.""" + if len(sys.argv) != 2: + print("Uso: python aplicar_plano_de_edicao.py ", file=sys.stderr) + sys.exit(2) + resultado = executar(Path(sys.argv[1])) + print(json.dumps(resultado, ensure_ascii=False, indent=2)) + if not resultado["todas_bem_sucedidas"]: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/code/engine/editor/__init__.py b/code/engine/editor/__init__.py new file mode 100644 index 0000000..c3780b8 --- /dev/null +++ b/code/engine/editor/__init__.py @@ -0,0 +1,35 @@ +"""Aplicação de planos de edição (cortes, zooms, marcadores) na timeline do Premiere. + +Lê o JSON de ações produzido pelas skills de seleção de trechos +(``kind``/``start``/``end``/``params``/``reason``), traduz os tempos de +origem para a sequência ativa do Premiere e aplica as mutações por meio do +MCP existente em ``engine.integracoes.premiere``. + +Não decide o que cortar — isso já vem pronto no plano. A responsabilidade +deste módulo é só: ler o plano, achar a posição atual de cada trecho na +timeline e executar a mutação correta, relatando o que deu certo e o que +não deu. +""" + +from .aplicador_de_plano_de_edicao import AplicadorDePlanoDeEdicao, ResultadoDaAcao, ResultadoDaAplicacao +from .erros import ErroDeEdicao, ErroDeEscritaNoEditor, ErroDeMapeamentoDeTempo, ErroDePlanoInvalido +from .escrita import EscritaNoEditor +from .leitura import LeitorDePlanoDeEdicao +from .mapeamento import MapeadorDeTempoDeOrigemParaTimeline +from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao + +__all__ = [ + "AcaoDeEdicao", + "AplicadorDePlanoDeEdicao", + "EscritaNoEditor", + "ErroDeEdicao", + "ErroDeEscritaNoEditor", + "ErroDeMapeamentoDeTempo", + "ErroDePlanoInvalido", + "LeitorDePlanoDeEdicao", + "MapeadorDeTempoDeOrigemParaTimeline", + "PlanoDeEdicao", + "ResultadoDaAcao", + "ResultadoDaAplicacao", + "TipoDeAcao", +] diff --git a/code/engine/editor/aplicador_de_plano_de_edicao.py b/code/engine/editor/aplicador_de_plano_de_edicao.py new file mode 100644 index 0000000..619302f --- /dev/null +++ b/code/engine/editor/aplicador_de_plano_de_edicao.py @@ -0,0 +1,223 @@ +"""Orquestração da aplicação de um plano de edição na sequência ativa do Premiere. + +Liga o plano já validado (:mod:`.leitura`) à tradução de tempos +(:mod:`.mapeamento`) e à escrita no editor (:mod:`.escrita`), na ordem que +preserva a validade das posições calculadas — ver docstring de +:class:`AplicadorDePlanoDeEdicao`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from ..dominio import Clipe, Faixa, Timeline +from ..integracoes.premiere.conversores import ConversorDeTimeline +from ..integracoes.premiere.leitura import AcessoAoEditor +from .erros import ErroDeEdicao, ErroDeMapeamentoDeTempo +from .escrita.escrita_no_editor import EscritaNoEditor +from .mapeamento.mapeador_de_tempo import MapeadorDeTempoDeOrigemParaTimeline +from .modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao + +_TOLERANCIA_DE_BORDA_EM_SEGUNDOS = 0.05 + + +@dataclass(frozen=True) +class ResultadoDaAcao: + """O que aconteceu ao tentar aplicar uma :class:`AcaoDeEdicao`.""" + + acao: AcaoDeEdicao + sucesso: bool + detalhe: str + + +@dataclass(frozen=True) +class ResultadoDaAplicacao: + """O resultado, ação por ação, de aplicar um :class:`PlanoDeEdicao` inteiro.""" + + resultados: tuple[ResultadoDaAcao, ...] = field(default_factory=tuple) + + @property + def todas_bem_sucedidas(self) -> bool: + """``True`` só se nenhuma ação falhou.""" + return all(resultado.sucesso for resultado in self.resultados) + + @property + def falhas(self) -> tuple[ResultadoDaAcao, ...]: + """Só as ações que não foram aplicadas.""" + return tuple(resultado for resultado in self.resultados if not resultado.sucesso) + + +class AplicadorDePlanoDeEdicao: + """Aplica um :class:`PlanoDeEdicao` inteiro na sequência ativa do Premiere. + + Ordem de aplicação, para preservar a validade das posições calculadas: + + 1. Marcadores e zooms primeiro — não mudam a duração da timeline, então + a posição calculada para cada um continua válida até o fim. + 2. Cortes por último, do fim para o começo (maior início primeiro) — + cada corte fecha o espaço que ocupava (ripple), deslocando para a + esquerda tudo que vem depois dele. Processar do fim para o começo + garante que a posição de um corte ainda não aplicado nunca é afetada + pelos cortes já aplicados. + + A cada ação a timeline ativa é lida de novo no Premiere — nunca se + reaproveita uma leitura anterior — porque a ação anterior pode ter + mudado a posição dos clipes. Uma ação que falha é registrada no + resultado e não interrompe as demais; quem chama decide o que fazer com + as falhas. + """ + + def __init__( + self, + acesso_ao_editor: AcessoAoEditor, + conversor_de_timeline: ConversorDeTimeline, + escrita: EscritaNoEditor, + mapeador: MapeadorDeTempoDeOrigemParaTimeline, + ) -> None: + self.acesso_ao_editor = acesso_ao_editor + self.conversor_de_timeline = conversor_de_timeline + self.escrita = escrita + self.mapeador = mapeador + + def aplicar(self, plano: PlanoDeEdicao) -> ResultadoDaAplicacao: + """Aplica todas as ações de ``plano`` e devolve o resultado de cada uma.""" + pontuais = [acao for acao in plano.acoes if acao.tipo is not TipoDeAcao.CORTE] + cortes = sorted(plano.acoes_do_tipo(TipoDeAcao.CORTE), key=lambda acao: acao.inicio, reverse=True) + + resultados = [self._aplicar_acao_pontual(acao, plano.arquivo_de_origem) for acao in pontuais] + resultados += [self._aplicar_corte(acao, plano.arquivo_de_origem) for acao in cortes] + return ResultadoDaAplicacao(tuple(resultados)) + + def _timeline_atual(self) -> Timeline: + """Lê a sequência ativa do Premiere agora, sem cache.""" + return self.conversor_de_timeline.converter(self.acesso_ao_editor.obter_timeline_ativa()) + + def _aplicar_acao_pontual(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao: + """Aplica um marcador ou zoom, que não precisam dividir nem remover clipes.""" + try: + if acao.tipo is TipoDeAcao.MARCADOR: + self._aplicar_marcador(acao, arquivo_de_origem) + return ResultadoDaAcao(acao, True, "marcador criado") + if acao.tipo is TipoDeAcao.ZOOM: + quantidade = self._aplicar_zoom(acao, arquivo_de_origem) + return ResultadoDaAcao(acao, True, f"zoom aplicado em {quantidade} clipe(s)") + if acao.tipo is TipoDeAcao.TEXTO: + self._aplicar_texto_como_marcador(acao, arquivo_de_origem) + return ResultadoDaAcao( + acao, True, + "texto na tela não é suportado pela API de scripting do Premiere; " + "registrado como marcador para o editor aplicar manualmente" + ) + raise ErroDeEdicao(f"Tipo de ação sem tratamento: {acao.tipo!r}.") + except ErroDeEdicao as erro: + return ResultadoDaAcao(acao, False, str(erro)) + + def _aplicar_marcador(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> None: + instante = self._instante_na_timeline(acao.inicio, arquivo_de_origem) + nome = str(acao.parametros.get("content", "MARCADOR")) + self.escrita.adicionar_marcador(instante, nome, acao.motivo) + + def _aplicar_texto_como_marcador(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> None: + instante = self._instante_na_timeline(acao.inicio, arquivo_de_origem) + conteudo = str(acao.parametros.get("content", "")) + self.escrita.adicionar_marcador(instante, "TEXTO (aplicar manualmente)", f"{conteudo} — {acao.motivo}") + + def _aplicar_zoom(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int: + fator_de_escala = float(acao.parametros.get("scale", 1.3)) + faixa_de_video = self._faixa_de_video(self._timeline_atual()) + clipes = self.mapeador.clipes_do_arquivo(faixa_de_video, arquivo_de_origem) + alvos = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)] + if not alvos: + raise ErroDeMapeamentoDeTempo( + f"Nenhum clipe de vídeo de {arquivo_de_origem!r} sobrepõe {acao.inicio}-{acao.fim}s de origem para o zoom." + ) + for clipe in alvos: + self.escrita.aplicar_zoom(clipe.identificador, fator_de_escala) + return len(alvos) + + def _aplicar_corte(self, acao: AcaoDeEdicao, arquivo_de_origem: str) -> ResultadoDaAcao: + """Remove, em cada faixa afetada, todos os clipes contidos no intervalo do corte.""" + try: + trechos_removidos = 0 + for faixa in self._timeline_atual().faixas: + trechos_removidos += self._cortar_faixa(faixa, acao, arquivo_de_origem) + if trechos_removidos == 0: + raise ErroDeMapeamentoDeTempo( + f"Nenhum clipe de {arquivo_de_origem!r} sobrepõe o intervalo {acao.inicio}-{acao.fim}s de origem." + ) + return ResultadoDaAcao(acao, True, f"{trechos_removidos} trecho(s) removido(s)") + except ErroDeEdicao as erro: + return ResultadoDaAcao(acao, False, str(erro)) + + def _cortar_faixa(self, faixa: Faixa, acao: AcaoDeEdicao, arquivo_de_origem: str) -> int: + """Corta o trecho de ``acao`` em uma única faixa e devolve quantos clipes foram removidos.""" + clipes = self.mapeador.clipes_do_arquivo(faixa, arquivo_de_origem) + afetados = [clipe for clipe in clipes if self._sobrepoe(clipe, acao.inicio, acao.fim)] + if not afetados: + return 0 + + primeiro, ultimo = afetados[0], afetados[-1] + inicio_na_timeline = self._borda_de_entrada(primeiro, acao.inicio) + fim_na_timeline = self._borda_de_saida(ultimo, acao.fim) + + self.escrita.dividir_clipe_se_necessario(inicio_na_timeline, faixa.indice, faixa.tipo) + self.escrita.dividir_clipe_se_necessario(fim_na_timeline, faixa.indice, faixa.tipo) + + clipes_apos_a_divisao = self.mapeador.clipes_do_arquivo( + next(f for f in self._timeline_atual().faixas if f.identificador == faixa.identificador), + arquivo_de_origem, + ) + alvo = [ + clipe for clipe in clipes_apos_a_divisao + if inicio_na_timeline - _TOLERANCIA_DE_BORDA_EM_SEGUNDOS <= clipe.intervalo_na_timeline.inicio + and clipe.intervalo_na_timeline.fim <= fim_na_timeline + _TOLERANCIA_DE_BORDA_EM_SEGUNDOS + ] + duracao_removida = sum(clipe.intervalo_na_timeline.duracao for clipe in alvo) + duracao_esperada = fim_na_timeline - inicio_na_timeline + if abs(duracao_removida - duracao_esperada) > _TOLERANCIA_DE_BORDA_EM_SEGUNDOS: + raise ErroDeMapeamentoDeTempo( + f"Na faixa {faixa.identificador!r}, os cortes em {inicio_na_timeline:.3f}s e " + f"{fim_na_timeline:.3f}s não isolaram o trecho esperado (esperado {duracao_esperada:.3f}s, " + f"encontrado {duracao_removida:.3f}s cobertos por clipe); nada foi removido para evitar " + "apagar o trecho errado." + ) + for clipe in alvo: + self.escrita.remover_trecho(clipe.identificador) + return len(alvo) + + def _instante_na_timeline(self, instante_de_origem: float, arquivo_de_origem: str) -> float: + """Traduz um instante de origem usando a primeira faixa de vídeo do arquivo.""" + faixa_de_video = self._faixa_de_video(self._timeline_atual()) + clipes = self.mapeador.clipes_do_arquivo(faixa_de_video, arquivo_de_origem) + clipe = self.mapeador.localizar_clipe_no_instante(clipes, instante_de_origem) + if clipe is None: + raise ErroDeMapeamentoDeTempo( + f"Nenhum clipe de vídeo de {arquivo_de_origem!r} cobre o instante {instante_de_origem}s de origem." + ) + return self.mapeador.instante_na_timeline(clipe, instante_de_origem) + + def _borda_de_entrada(self, clipe: Clipe, inicio_de_origem: float) -> float: + """Posição de entrada do corte na timeline: a borda do clipe se o corte começa antes dele.""" + if inicio_de_origem <= clipe.intervalo_na_origem.inicio: + return clipe.intervalo_na_timeline.inicio + return self.mapeador.instante_na_timeline(clipe, inicio_de_origem) + + def _borda_de_saida(self, clipe: Clipe, fim_de_origem: float) -> float: + """Posição de saída do corte na timeline: a borda do clipe se o corte termina depois dele.""" + if fim_de_origem >= clipe.intervalo_na_origem.fim: + return clipe.intervalo_na_timeline.fim + return self.mapeador.instante_na_timeline(clipe, fim_de_origem) + + @staticmethod + def _sobrepoe(clipe: Clipe, inicio: float, fim: float) -> bool: + """``True`` se o intervalo de origem do clipe tiver interseção com [inicio, fim).""" + intervalo = clipe.intervalo_na_origem + return intervalo is not None and intervalo.inicio < fim and inicio < intervalo.fim + + @staticmethod + def _faixa_de_video(timeline: Timeline) -> Faixa: + """A primeira faixa de vídeo da timeline; é onde marcadores e zooms são resolvidos.""" + for faixa in timeline.faixas: + if faixa.tipo == "video": + return faixa + raise ErroDeMapeamentoDeTempo("A sequência ativa não tem nenhuma faixa de vídeo.") diff --git a/code/engine/editor/erros.py b/code/engine/editor/erros.py new file mode 100644 index 0000000..3f6f65c --- /dev/null +++ b/code/engine/editor/erros.py @@ -0,0 +1,22 @@ +"""Erros do módulo de aplicação de planos de edição. + +Cada classe cobre uma fase da aplicação (leitura do plano, mapeamento de +tempo, escrita no editor), para que quem chama saiba exatamente em qual +etapa a operação falhou sem precisar inspecionar mensagens de texto. +""" + + +class ErroDeEdicao(Exception): + """Erro geral da aplicação de um plano de edição.""" + + +class ErroDePlanoInvalido(ErroDeEdicao): + """O arquivo de ações não segue o formato esperado (kind/start/end/params/reason).""" + + +class ErroDeMapeamentoDeTempo(ErroDeEdicao): + """Não foi possível traduzir um tempo do arquivo de origem para a timeline ativa.""" + + +class ErroDeEscritaNoEditor(ErroDeEdicao): + """O Premiere recusou ou não confirmou uma mutação solicitada na timeline.""" diff --git a/code/engine/editor/escrita/__init__.py b/code/engine/editor/escrita/__init__.py new file mode 100644 index 0000000..9b106ba --- /dev/null +++ b/code/engine/editor/escrita/__init__.py @@ -0,0 +1,5 @@ +"""Escrita de mutações na sequência ativa do Premiere.""" + +from .escrita_no_editor import EscritaNoEditor + +__all__ = ["EscritaNoEditor"] diff --git a/code/engine/editor/escrita/escrita_no_editor.py b/code/engine/editor/escrita/escrita_no_editor.py new file mode 100644 index 0000000..fd39238 --- /dev/null +++ b/code/engine/editor/escrita/escrita_no_editor.py @@ -0,0 +1,91 @@ +"""Escrita de mutações na sequência ativa do Premiere via MCP. + +Isola o nome das ferramentas MCP e o formato de argumentos externos +(``split_clip``, ``remove_from_timeline``, ``set_clip_properties``, +``add_marker``) do resto do domínio. Quem chama esta classe fala só em +segundos de timeline, identificador de clipe e fator de escala — nunca em +nomes de ferramenta ou ticks do Premiere. +""" + +from __future__ import annotations + +from ...integracoes.premiere.cliente_mcp import ClienteMCP +from ...integracoes.premiere.erros_mcp import ErroDeFerramentaMCP +from ..erros import ErroDeEscritaNoEditor + +_CORES_POR_NOME = { + "revisar": 3, # laranja: chama atenção sem parecer um erro (vermelho) + "informar": 5, # branco: marcador neutro +} + + +class EscritaNoEditor: + """Aplica, uma de cada vez, as mutações que o aplicador de plano decide fazer.""" + + def __init__(self, cliente_mcp: ClienteMCP) -> None: + self.cliente_mcp = cliente_mcp + + def dividir_clipe_se_necessario(self, instante_da_timeline: float, indice_da_faixa: int, tipo_da_faixa: str) -> bool: + """Corta, na timeline, o clipe que cobrir ``instante_da_timeline``. + + Devolve ``True`` se um corte novo foi criado e ``False`` quando o + instante já era uma borda de clipe (o Premiere recusa dividir onde + não há clipe atravessando o ponto) — esse segundo caso não é um erro, + é o resultado esperado quando o corte pedido coincide com um corte já + existente da análise de cenas. + """ + try: + self.cliente_mcp.chamar( + "split_clip", + {"time_seconds": instante_da_timeline, "track_index": indice_da_faixa, "track_type": tipo_da_faixa}, + ) + return True + except ErroDeFerramentaMCP: + return False + + def remover_trecho(self, identificador_do_clipe: str) -> None: + """Remove o clipe da timeline, fechando o espaço deixado (ripple). + + Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a + remoção. + """ + try: + self.cliente_mcp.chamar("remove_from_timeline", {"node_id": identificador_do_clipe, "ripple": True}) + except ErroDeFerramentaMCP as erro: + raise ErroDeEscritaNoEditor(f"Não foi possível remover o clipe {identificador_do_clipe!r}: {erro}") from erro + + def aplicar_zoom(self, identificador_do_clipe: str, fator_de_escala: float) -> None: + """Aplica um punch-in no clipe, escalando-o por ``fator_de_escala`` (1.0 = tamanho original). + + Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a + alteração. + """ + try: + self.cliente_mcp.chamar( + "set_clip_properties", {"node_id": identificador_do_clipe, "scale": fator_de_escala * 100} + ) + except ErroDeFerramentaMCP as erro: + raise ErroDeEscritaNoEditor( + f"Não foi possível aplicar zoom no clipe {identificador_do_clipe!r}: {erro}" + ) from erro + + def adicionar_marcador(self, instante_da_timeline: float, nome: str, comentario: str) -> None: + """Cria um marcador de sequência em ``instante_da_timeline``. + + Levanta :class:`ErroDeEscritaNoEditor` se o Premiere recusar a + criação do marcador. + """ + try: + self.cliente_mcp.chamar( + "add_marker", + { + "time_seconds": instante_da_timeline, + "name": nome, + "comments": comentario, + "color": _CORES_POR_NOME["revisar"], + }, + ) + except ErroDeFerramentaMCP as erro: + raise ErroDeEscritaNoEditor( + f"Não foi possível adicionar o marcador em {instante_da_timeline}s: {erro}" + ) from erro diff --git a/code/engine/editor/leitura/__init__.py b/code/engine/editor/leitura/__init__.py new file mode 100644 index 0000000..6462f3d --- /dev/null +++ b/code/engine/editor/leitura/__init__.py @@ -0,0 +1,5 @@ +"""Leitura e validação de planos de edição.""" + +from .leitor_de_plano import LeitorDePlanoDeEdicao + +__all__ = ["LeitorDePlanoDeEdicao"] diff --git a/code/engine/editor/leitura/leitor_de_plano.py b/code/engine/editor/leitura/leitor_de_plano.py new file mode 100644 index 0000000..021762f --- /dev/null +++ b/code/engine/editor/leitura/leitor_de_plano.py @@ -0,0 +1,88 @@ +"""Leitura e validação do arquivo JSON de plano de edição. + +Isola o formato externo (contrato ``kind``/``start``/``end``/``params``/ +``reason`` das skills de seleção de trechos) do modelo interno em +:mod:`engine.editor.modelos`. Nenhuma outra parte do sistema deve interpretar +esse JSON diretamente. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from ..erros import ErroDePlanoInvalido +from ..modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao + +_CAMPOS_OBRIGATORIOS_DA_ACAO = ("kind", "start", "end", "reason") + + +class LeitorDePlanoDeEdicao: + """Lê um arquivo de ações e devolve um :class:`PlanoDeEdicao` validado. + + Não aplica nenhuma ação nem acessa o Premiere: apenas valida a estrutura + do arquivo e converte para os tipos internos do domínio. + """ + + def ler(self, caminho: Path) -> PlanoDeEdicao: + """Lê o arquivo em ``caminho`` e devolve o plano validado. + + Levanta :class:`ErroDePlanoInvalido` se o arquivo não existir, não for + JSON válido ou não seguir o formato esperado. + """ + try: + texto = caminho.read_text(encoding="utf-8") + except OSError as erro: + raise ErroDePlanoInvalido(f"Não foi possível ler o plano de edição em {caminho}: {erro}") from erro + try: + dados = json.loads(texto) + except json.JSONDecodeError as erro: + raise ErroDePlanoInvalido(f"O plano de edição em {caminho} não é um JSON válido: {erro}") from erro + return self.ler_de_dicionario(dados) + + def ler_de_dicionario(self, dados: Any) -> PlanoDeEdicao: + """Converte um dicionário já carregado (ex.: de teste) em um plano validado.""" + if not isinstance(dados, dict): + raise ErroDePlanoInvalido("O plano de edição precisa ser um objeto JSON no nível raiz.") + arquivo_de_origem = dados.get("source") + if not isinstance(arquivo_de_origem, str) or not arquivo_de_origem.strip(): + raise ErroDePlanoInvalido("O plano de edição precisa de um campo 'source' com o nome do arquivo.") + acoes_brutas = dados.get("actions") + if not isinstance(acoes_brutas, list) or not acoes_brutas: + raise ErroDePlanoInvalido("O plano de edição precisa de uma lista não vazia em 'actions'.") + acoes = tuple(self._converter_acao(bruta, indice) for indice, bruta in enumerate(acoes_brutas)) + try: + return PlanoDeEdicao(arquivo_de_origem=arquivo_de_origem, acoes=acoes) + except ValueError as erro: + raise ErroDePlanoInvalido(str(erro)) from erro + + def _converter_acao(self, bruta: Any, indice: int) -> AcaoDeEdicao: + """Converte um item de ``actions`` para :class:`AcaoDeEdicao`, com erros que apontam o índice.""" + if not isinstance(bruta, dict): + raise ErroDePlanoInvalido(f"actions[{indice}] precisa ser um objeto JSON.") + faltando = [campo for campo in _CAMPOS_OBRIGATORIOS_DA_ACAO if campo not in bruta] + if faltando: + raise ErroDePlanoInvalido(f"actions[{indice}] está sem os campos obrigatórios: {', '.join(faltando)}.") + try: + tipo = TipoDeAcao(bruta["kind"]) + except ValueError as erro: + tipos_validos = ", ".join(tipo.value for tipo in TipoDeAcao) + raise ErroDePlanoInvalido( + f"actions[{indice}].kind = {bruta['kind']!r} é desconhecido. Válidos: {tipos_validos}." + ) from erro + try: + inicio = float(bruta["start"]) + fim = float(bruta["end"]) + except (TypeError, ValueError) as erro: + raise ErroDePlanoInvalido(f"actions[{indice}].start/end precisam ser numéricos.") from erro + parametros = bruta.get("params", {}) + if not isinstance(parametros, dict): + raise ErroDePlanoInvalido(f"actions[{indice}].params precisa ser um objeto JSON quando presente.") + motivo = bruta["reason"] + if not isinstance(motivo, str): + raise ErroDePlanoInvalido(f"actions[{indice}].reason precisa ser texto.") + try: + return AcaoDeEdicao(tipo=tipo, inicio=inicio, fim=fim, motivo=motivo, parametros=dict(parametros)) + except ValueError as erro: + raise ErroDePlanoInvalido(f"actions[{indice}]: {erro}") from erro diff --git a/code/engine/editor/mapeamento/__init__.py b/code/engine/editor/mapeamento/__init__.py new file mode 100644 index 0000000..66c8b4b --- /dev/null +++ b/code/engine/editor/mapeamento/__init__.py @@ -0,0 +1,5 @@ +"""Tradução de tempos de origem para posições na timeline ativa.""" + +from .mapeador_de_tempo import MapeadorDeTempoDeOrigemParaTimeline + +__all__ = ["MapeadorDeTempoDeOrigemParaTimeline"] diff --git a/code/engine/editor/mapeamento/mapeador_de_tempo.py b/code/engine/editor/mapeamento/mapeador_de_tempo.py new file mode 100644 index 0000000..d48833f --- /dev/null +++ b/code/engine/editor/mapeamento/mapeador_de_tempo.py @@ -0,0 +1,81 @@ +"""Tradução de tempos do arquivo de origem para posições na timeline ativa. + +O plano de edição descreve tudo em segundos do arquivo de origem (regra do +formato: nunca compensar para "depois do corte"). Só a sequência do Premiere, +lida no momento da aplicação, sabe onde cada trecho de origem está +atualmente na timeline — por isso este mapeador nunca usa tempos gravados +durante a análise (ex.: ``inicio_na_timeline`` do banco de análises), sempre +o clipe lido ao vivo via :class:`~engine.dominio.Clipe`. +""" + +from __future__ import annotations + +from ...dominio import Clipe, Faixa, Timeline +from ..erros import ErroDeMapeamentoDeTempo + +_TOLERANCIA_EM_SEGUNDOS = 0.05 + + +class MapeadorDeTempoDeOrigemParaTimeline: + """Localiza clipes pelo tempo de origem e converte instantes para a timeline.""" + + def clipes_do_arquivo(self, faixa: Faixa, arquivo_de_origem: str) -> list[Clipe]: + """Clipes da faixa que pertencem a ``arquivo_de_origem``, ordenados pelo início na origem. + + A comparação ignora maiúsculas/minúsculas porque o Premiere e o plano + de edição podem registrar a extensão do arquivo em casos diferentes + (``.MP4`` vs. ``.mp4``). + """ + alvo = arquivo_de_origem.strip().lower() + candidatos = [ + clipe for clipe in faixa.clipes + if clipe.intervalo_na_origem is not None and (clipe.nome or "").strip().lower() == alvo + ] + return sorted(candidatos, key=lambda clipe: clipe.intervalo_na_origem.inicio) + + def localizar_clipe_no_instante(self, clipes: list[Clipe], instante_de_origem: float) -> Clipe | None: + """Devolve o clipe cujo intervalo de origem contém ``instante_de_origem``, ou ``None``. + + Tolera pequenas diferenças de arredondamento (meio quadro) nas bordas + do intervalo. + """ + for clipe in clipes: + intervalo = clipe.intervalo_na_origem + if intervalo is None: + continue + if intervalo.inicio - _TOLERANCIA_EM_SEGUNDOS <= instante_de_origem <= intervalo.fim + _TOLERANCIA_EM_SEGUNDOS: + return clipe + return None + + def instante_na_timeline(self, clipe: Clipe, instante_de_origem: float) -> float: + """Converte um instante de origem, dentro do intervalo de origem de ``clipe``, para a timeline. + + Levanta :class:`ErroDeMapeamentoDeTempo` se ``clipe`` não tiver + intervalo de origem conhecido — não há como calcular o deslocamento + sem ele. + """ + if clipe.intervalo_na_origem is None: + raise ErroDeMapeamentoDeTempo( + f"O clipe {clipe.identificador!r} não tem intervalo de origem conhecido; " + "não é possível traduzir o tempo para a timeline." + ) + deslocamento = instante_de_origem - clipe.intervalo_na_origem.inicio + return clipe.intervalo_na_timeline.inicio + deslocamento + + def instante_na_timeline_por_arquivo( + self, timeline: Timeline, faixa: Faixa, arquivo_de_origem: str, instante_de_origem: float + ) -> float: + """Atalho que localiza o clipe do instante em ``faixa`` e já devolve a posição na timeline. + + Levanta :class:`ErroDeMapeamentoDeTempo` se nenhum clipe de + ``arquivo_de_origem`` cobrir ``instante_de_origem`` nessa faixa. + """ + del timeline # mantido na assinatura para leitura clara no local de chamada + clipes = self.clipes_do_arquivo(faixa, arquivo_de_origem) + clipe = self.localizar_clipe_no_instante(clipes, instante_de_origem) + if clipe is None: + raise ErroDeMapeamentoDeTempo( + f"Nenhum clipe de {arquivo_de_origem!r} na faixa {faixa.identificador!r} " + f"cobre o instante {instante_de_origem}s de origem." + ) + return self.instante_na_timeline(clipe, instante_de_origem) diff --git a/code/engine/editor/modelos.py b/code/engine/editor/modelos.py new file mode 100644 index 0000000..e5cab4f --- /dev/null +++ b/code/engine/editor/modelos.py @@ -0,0 +1,69 @@ +"""Modelo de domínio do plano de edição. + +Representa, em memória, o contrato JSON produzido pelas skills de seleção de +trechos (``kind``/``start``/``end``/``params``/``reason``) já validado e +convertido para tipos internos. Nenhuma classe aqui conhece o Premiere nem o +formato de arquivo — isso é responsabilidade de ``leitura`` e ``escrita``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + + +class TipoDeAcao(str, Enum): + """Os quatro tipos de ação que o plano de edição pode descrever.""" + + CORTE = "cut" + ZOOM = "zoom" + TEXTO = "text" + MARCADOR = "marker" + + +@dataclass(frozen=True) +class AcaoDeEdicao: + """Uma decisão de edição sobre um intervalo do arquivo de origem. + + ``inicio`` e ``fim`` são sempre segundos na mídia original — nunca na + timeline já cortada. ``motivo`` é obrigatório: é o texto que o editor lê + para decidir se aceita a decisão. + """ + + tipo: TipoDeAcao + inicio: float + fim: float + motivo: str + parametros: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.inicio < 0: + raise ValueError(f"Ação inválida: início {self.inicio}s não pode ser negativo.") + if self.fim <= self.inicio: + raise ValueError( + f"Ação inválida: fim ({self.fim}s) deve ser maior que início ({self.inicio}s)." + ) + if not self.motivo.strip(): + raise ValueError("Ação inválida: motivo não pode ser vazio.") + + @property + def duracao(self) -> float: + """Duração da ação em segundos, sempre positiva.""" + return self.fim - self.inicio + + +@dataclass(frozen=True) +class PlanoDeEdicao: + """Conjunto ordenado de ações a aplicar sobre um arquivo de origem.""" + + arquivo_de_origem: str + acoes: tuple[AcaoDeEdicao, ...] + + def __post_init__(self) -> None: + if not self.arquivo_de_origem.strip(): + raise ValueError("Plano de edição inválido: arquivo de origem não informado.") + + def acoes_do_tipo(self, tipo: TipoDeAcao) -> tuple[AcaoDeEdicao, ...]: + """Devolve, na ordem original, só as ações do tipo informado.""" + return tuple(acao for acao in self.acoes if acao.tipo is tipo) diff --git a/code/engine/gerar_relatorio_de_analises.py b/code/engine/gerar_relatorio_de_analises.py new file mode 100644 index 0000000..c89fca7 --- /dev/null +++ b/code/engine/gerar_relatorio_de_analises.py @@ -0,0 +1,180 @@ +"""Relatório dos J-SOM de análise (imagem e som) lidos do banco de análises. + +Este módulo lê o banco ``analises.db`` e monta um único documento JSON com as +duas análises já persistidas pelo Scanner: + +- **Som**: transcrição por clipe/cena, com falante, emoção e confiança. +- **Imagem**: evidências visuais por clipe (Apple Vision), com resumo por tipo. + +É a base da aba **Análises** do painel CEP, que permite abrir e copiar esses +resultados sem precisar de SQL nem de reprocessar o vídeo. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# O painel chama este arquivo com cwd ``code/engine``; injetar ``code`` (pai do +# pacote ``engine``) para que os importes funcionem, como em ``executar_retakes.py``. +CAMINHO_DO_CODIGO = Path(__file__).resolve().parent.parent +if str(CAMINHO_DO_CODIGO) not in sys.path: + sys.path.insert(0, str(CAMINHO_DO_CODIGO)) + +from engine.persistencia.consultas import ConsultasDeAnalises # noqa: E402 + + +class ErroDeBancoSemVideos(RuntimeError): + """Representa um banco de análises que ainda não tem nenhum vídeo.""" + + +class MontadorDeRelatorioDeAnalises: + """ + Monta o relatório JSON dos J-SOM de análise a partir do banco de análises. + + Reaproveita ``ConsultasDeAnalises`` (somente leitura) e organiza os dados + por clipe/cena, unindo a transcrição (som) e as evidências visuais (imagem) + de cada trecho da timeline. + + Atributos: + consultas: Consultas de leitura sobre o banco de análises. + """ + + def __init__(self, banco: str | Path) -> None: + """ + Inicializa o montador sobre um banco de análises existente. + + Parâmetros: + banco: Caminho do arquivo ``analises.db``. + """ + self.consultas = ConsultasDeAnalises(banco) + + def montar(self, video_id: str | None = None) -> dict: + """ + Monta o relatório completo de um vídeo (ou do primeiro vídeo do banco). + + Parâmetros: + video_id: Identificador do vídeo. Quando None, usa o primeiro + vídeo encontrado no banco. + + Retorna: + Dicionário com o resumo do vídeo e a lista de cenas, cada uma com + as falas (som) e as evidências visuais (imagem) daquele clipe. + + Pode gerar: + ErroDeBancoSemVideos: quando o banco não tem nenhum vídeo. + """ + video_id = video_id or self._primeiro_video() + resumo = self.consultas.consultar_resumo_do_video(video_id) + if resumo is None: + raise ErroDeBancoSemVideos(f"Vídeo '{video_id}' não existe no banco de análises.") + falas = self.consultas.listar_falas_no_intervalo(video_id, 0.0, None) + return { + "video": resumo, + "cenas": self._cenas_do_relatorio(video_id, falas), + } + + def _primeiro_video(self) -> str: + """Devolve o identificador do primeiro vídeo do banco.""" + linha = self.consultas.conexao.execute( + "SELECT id FROM videos ORDER BY criado_em LIMIT 1").fetchone() + if linha is None: + raise ErroDeBancoSemVideos( + "O banco de análises não tem nenhum vídeo. Execute o Scanner primeiro.") + return linha["id"] + + def _cenas_do_relatorio(self, video_id: str, falas: list[dict]) -> list[dict]: + """ + Reúne, por clipe, as falas e as evidências visuais do vídeo. + + A base é a tabela ``clipes`` (timeline completa), porque a tabela + ``cenas`` pode cobrir só parte dos clipes; o campo + ``cena_registrada`` indica se aquele clipe tem corte de cena gravado. + + Parâmetros: + video_id: Identificador do vídeo. + falas: Falas já consultadas (saída de ``listar_falas_no_intervalo``). + + Retorna: + Lista de entradas por clipe, ordenadas pela posição na timeline. + """ + falas_por_clipe: dict[str, list[dict]] = {} + for fala in falas: + falas_por_clipe.setdefault(fala["clipe_id"], []).append(fala) + + clipes_com_cena = { + linha["clipe_id"] for linha in self.consultas.conexao.execute( + "SELECT DISTINCT clipe_id FROM cenas WHERE video_id = ? AND clipe_id IS NOT NULL", + (video_id,), + ).fetchall() + } + + cenas: list[dict] = [] + for clipe in self.consultas.conexao.execute( + """SELECT id AS clipe_id, nome, inicio_na_timeline AS inicio, + fim_na_timeline AS fim FROM clipes + WHERE video_id = ? ORDER BY inicio_na_timeline""", + (video_id,), + ).fetchall(): + clipe_id = clipe["clipe_id"] + evidencias = self.consultas.listar_evidencias_visuais_do_clipe(video_id, clipe_id) + cenas.append({ + "clipe_id": clipe_id, + "nome": clipe["nome"], + "inicio": clipe["inicio"], + "fim": clipe["fim"], + "cena_registrada": clipe_id in clipes_com_cena, + "falas": falas_por_clipe.get(clipe_id, []), + "resumo_de_evidencias": self._resumo_de_evidencias(evidencias), + "evidencias_visuais": evidencias, + }) + return cenas + + @staticmethod + def _resumo_de_evidencias(evidencias: list[dict]) -> dict: + """Conta as evidências visuais por tipo (ex.: ``rosto: 2``).""" + resumo: dict[str, int] = {} + for evidencia in evidencias: + resumo[evidencia["tipo"]] = resumo.get(evidencia["tipo"], 0) + 1 + return resumo + + +def executar(banco: Path, saida: Path, video_id: str | None = None) -> dict: + """ + Gera o arquivo JSON do relatório e devolve o documento montado. + + Parâmetros: + banco: Caminho do arquivo ``analises.db``. + saida: Caminho do arquivo JSON a gravar. + video_id: Vídeo desejado; quando None, usa o primeiro do banco. + + Retorna: + O dicionário do relatório (mesmo conteúdo gravado em ``saida``). + """ + relatorio = MontadorDeRelatorioDeAnalises(banco).montar(video_id) + saida.parent.mkdir(parents=True, exist_ok=True) + saida.write_text(json.dumps(relatorio, ensure_ascii=False, indent=2), encoding="utf-8") + return relatorio + + +def main() -> None: + """Ponto de entrada da linha de comando usada pelo painel.""" + parser = argparse.ArgumentParser( + description="Gera o relatório JSON dos J-SOM de análise (som e imagem) do banco.") + parser.add_argument("banco", type=Path, help="Caminho do analises.db.") + parser.add_argument("saida", type=Path, help="Arquivo JSON de saída.") + parser.add_argument("--video-id", default=None, help="Vídeo desejado (opcional).") + args = parser.parse_args() + try: + relatorio = executar(args.banco, args.saida, args.video_id) + except Exception as erro: # repassa a causa para a tela decidir como exibir + print(json.dumps({"evento": "erro", "mensagem": str(erro)}, ensure_ascii=False), flush=True) + sys.exit(1) + print(json.dumps({"evento": "concluido", "arquivo": str(args.saida), + "video": relatorio["video"]["nome"]}, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/code/engine/integracoes/premiere/cliente_mcp.py b/code/engine/integracoes/premiere/cliente_mcp.py index cd25718..dac4b86 100644 --- a/code/engine/integracoes/premiere/cliente_mcp.py +++ b/code/engine/integracoes/premiere/cliente_mcp.py @@ -1,23 +1,43 @@ +"""Cliente MCP (Model Context Protocol) para o servidor local do Premiere. + +Fala o protocolo JSON-RPC do MCP por stdio com o processo Node do bridge +(``code/dist/index.js``). É a única camada do sistema que conhece o +transporte (subprocesso, linhas JSON, ids de requisição); o resto do código +usa só o método :meth:`ClienteMCP.chamar` com nome de ferramenta e +argumentos. +""" + from abc import ABC, abstractmethod import json import subprocess from typing import Any +from .erros_mcp import ErroDeConexaoMCP, ErroDeFerramentaMCP + class ClienteMCP(ABC): """Define a comunicação técnica com o servidor MCP.""" @abstractmethod - def conectar(self) -> None: ... + def conectar(self) -> None: + """Inicia a conexão com o servidor MCP. Não faz nada se já estiver conectado.""" @abstractmethod - def desconectar(self) -> None: ... + def desconectar(self) -> None: + """Encerra a conexão com o servidor MCP, se houver uma ativa.""" @abstractmethod - def esta_conectado(self) -> bool: ... + def esta_conectado(self) -> bool: + """Indica se há uma conexão ativa com o servidor MCP.""" @abstractmethod - def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]: ... + def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]: + """Executa uma ferramenta do servidor MCP e devolve o resultado. + + Levanta :class:`~.erros_mcp.ErroDeConexaoMCP` se não houver conexão + ativa e :class:`~.erros_mcp.ErroDeFerramentaMCP` se o servidor + responder com erro. + """ class ClienteMCPPorStdio(ClienteMCP): @@ -30,6 +50,11 @@ class ClienteMCPPorStdio(ClienteMCP): self._proximo_id = 1 def conectar(self) -> None: + """Sobe o processo do servidor MCP e envia o handshake ``initialize``. + + Não faz nada se um processo já estiver em execução — não é possível + reconectar sem antes chamar :meth:`desconectar`. + """ if self._processo is not None: return self._processo = subprocess.Popen( @@ -43,29 +68,42 @@ class ClienteMCPPorStdio(ClienteMCP): self._enviar("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "engine", "version": "0.1.0"}}) def desconectar(self) -> None: + """Encerra o processo do servidor MCP, se houver um em execução.""" if self._processo is not None: self._processo.terminate() self._processo = None def esta_conectado(self) -> bool: + """``True`` enquanto o processo do servidor MCP estiver vivo.""" return self._processo is not None and self._processo.poll() is None def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]: + """Executa ``nome_da_ferramenta`` no servidor MCP com ``argumentos``. + + Levanta :class:`~.erros_mcp.ErroDeConexaoMCP` se não houver conexão + ativa e :class:`~.erros_mcp.ErroDeFerramentaMCP` se o servidor + responder com um erro para essa ferramenta. + """ if not self.esta_conectado(): - raise RuntimeError("Cliente MCP não está conectado.") + raise ErroDeConexaoMCP("Cliente MCP não está conectado.") resposta = self._enviar("tools/call", {"name": nome_da_ferramenta, "arguments": argumentos or {}}) if resposta.get("error"): - raise RuntimeError(f"Erro MCP: {resposta['error']}") + raise ErroDeFerramentaMCP(f"Erro ao executar {nome_da_ferramenta!r} no MCP: {resposta['error']}") return resposta.get("result", {}) def _enviar(self, metodo: str, parametros: dict[str, Any]) -> dict[str, Any]: + """Envia uma requisição JSON-RPC pela stdin do processo e lê a resposta da stdout. + + Levanta :class:`~.erros_mcp.ErroDeConexaoMCP` se o processo não + estiver disponível ou encerrar sem responder. + """ if self._processo is None or self._processo.stdin is None or self._processo.stdout is None: - raise RuntimeError("Processo MCP indisponível.") + raise ErroDeConexaoMCP("Processo MCP indisponível.") identificador = self._proximo_id self._proximo_id += 1 self._processo.stdin.write(json.dumps({"jsonrpc": "2.0", "id": identificador, "method": metodo, "params": parametros}) + "\n") self._processo.stdin.flush() linha = self._processo.stdout.readline() if not linha: - raise RuntimeError("O MCP encerrou sem retornar resposta.") + raise ErroDeConexaoMCP("O MCP encerrou sem retornar resposta.") return json.loads(linha) diff --git a/code/engine/testes/duplos_de_premiere.py b/code/engine/testes/duplos_de_premiere.py new file mode 100644 index 0000000..62a9a1a --- /dev/null +++ b/code/engine/testes/duplos_de_premiere.py @@ -0,0 +1,182 @@ +"""Dublê de teste do cliente MCP do Premiere, para testar o editor sem o app aberto. + +Simula, em memória, uma sequência com faixas de vídeo e áudio cujos clipes +têm posição na timeline diferente da posição no arquivo de origem — o mesmo +descompasso encontrado no projeto real entre ``inicio_na_timeline`` e +``inicio_na_origem`` do banco de análises. Implementa só as ferramentas MCP +que o módulo ``engine.editor`` usa: ``get_active_sequence``, ``split_clip``, +``remove_from_timeline``, ``set_clip_properties`` e ``add_marker``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from engine.integracoes.premiere.cliente_mcp import ClienteMCP +from engine.integracoes.premiere.erros_mcp import ErroDeFerramentaMCP + + +@dataclass +class _ClipeFalso: + """Representação mínima de um clipe dentro do dublê de sequência.""" + + identificador: str + nome: str + inicio_na_timeline: float + fim_na_timeline: float + inicio_na_origem: float + fim_na_origem: float + + +class ClienteMCPFalso(ClienteMCP): + """Simula o servidor MCP do Premiere para testes do módulo ``engine.editor``. + + Vem pré-carregado com duas faixas (``video_0`` e ``audio_0``), cada uma + com dois clipes do mesmo arquivo fictício, reproduzindo o descompasso + real entre tempo de origem e tempo de timeline. Registra toda chamada em + :attr:`chamadas` para os testes inspecionarem ordem e argumentos. + """ + + _ARQUIVO = "0E6A8290.mp4" + + def __init__(self) -> None: + self.chamadas: list[tuple[str, dict[str, Any]]] = [] + self._proximo_id = 0 + self._faixas: dict[str, list[_ClipeFalso]] = { + "video_0": [ + self._novo_clipe(self._ARQUIVO, 0.0, 38.3, 2.3, 40.6, "clipe_a_video"), + self._novo_clipe(self._ARQUIVO, 58.1, 66.7, 86.2, 94.8, "clipe_b_video"), + ], + "audio_0": [ + self._novo_clipe(self._ARQUIVO, 0.0, 38.3, 2.3, 40.6, "clipe_a_audio"), + self._novo_clipe(self._ARQUIVO, 58.1, 66.7, 86.2, 94.8, "clipe_b_audio"), + ], + } + + @property + def chamadas_de_remocao(self) -> list[dict[str, Any]]: + """Argumentos de cada chamada a ``remove_from_timeline``, na ordem em que ocorreram.""" + return [argumentos for nome, argumentos in self.chamadas if nome == "remove_from_timeline"] + + def conectar(self) -> None: + """Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`.""" + + def desconectar(self) -> None: + """Não há conexão real; existe só para satisfazer a interface de :class:`ClienteMCP`.""" + + def esta_conectado(self) -> bool: + """Sempre conectado: este dublê não simula falha de conexão.""" + return True + + def chamar(self, nome_da_ferramenta: str, argumentos: dict[str, Any] | None = None) -> dict[str, Any]: + """Despacha para o simulador da ferramenta pedida e registra a chamada.""" + argumentos = argumentos or {} + self.chamadas.append((nome_da_ferramenta, argumentos)) + despachantes = { + "get_active_sequence": self._simular_get_active_sequence, + "split_clip": self._simular_split_clip, + "remove_from_timeline": self._simular_remove_from_timeline, + "set_clip_properties": self._simular_set_clip_properties, + "add_marker": self._simular_add_marker, + } + simulador = despachantes.get(nome_da_ferramenta) + if simulador is None: + raise ErroDeFerramentaMCP(f"Ferramenta MCP não simulada neste dublê de teste: {nome_da_ferramenta!r}.") + return simulador(argumentos) + + def _novo_clipe(self, nome, tl_inicio, tl_fim, origem_inicio, origem_fim, identificador=None) -> _ClipeFalso: + """Cria um clipe falso com um identificador único (ou o informado, para os fixtures).""" + if identificador is None: + self._proximo_id += 1 + identificador = f"clipe_falso_{self._proximo_id}" + return _ClipeFalso(identificador, nome, tl_inicio, tl_fim, origem_inicio, origem_fim) + + def _simular_get_active_sequence(self, argumentos: dict[str, Any]) -> dict[str, Any]: + """Devolve a sequência atual no formato que :class:`ConversorDeTimeline` espera.""" + del argumentos + + def _faixa_como_dict(identificador: str, tipo: str, indice: int) -> dict[str, Any]: + return { + "id": identificador, + "name": identificador, + "index": indice, + "clips": [ + { + "nodeId": clipe.identificador, + "name": clipe.nome, + "start": clipe.inicio_na_timeline, + "end": clipe.fim_na_timeline, + "inPoint": clipe.inicio_na_origem, + "outPoint": clipe.fim_na_origem, + } + for clipe in self._faixas[identificador] + ], + } + + dados = { + "id": "sequencia_falsa", + "name": "Sequência de teste", + "duration": 200.0, + "frameRate": 29.97, + "frameSizeHorizontal": 3840, + "frameSizeVertical": 2160, + "videoTracks": [_faixa_como_dict("video_0", "video", 0)], + "audioTracks": [_faixa_como_dict("audio_0", "audio", 0)], + } + return {"structuredContent": {"data": dados}} + + def _simular_split_clip(self, argumentos: dict[str, Any]) -> dict[str, Any]: + """Divide, na faixa pedida, o clipe que cobrir estritamente o instante informado.""" + identificador_da_faixa = self._identificador_da_faixa(argumentos["track_index"], argumentos["track_type"]) + instante = float(argumentos["time_seconds"]) + faixa = self._faixas[identificador_da_faixa] + for posicao, clipe in enumerate(faixa): + if clipe.inicio_na_timeline < instante < clipe.fim_na_timeline: + deslocamento = instante - clipe.inicio_na_timeline + self._proximo_id += 1 + parte_esquerda = _ClipeFalso( + f"{clipe.identificador}_esq{self._proximo_id}", clipe.nome, + clipe.inicio_na_timeline, instante, + clipe.inicio_na_origem, clipe.inicio_na_origem + deslocamento, + ) + self._proximo_id += 1 + parte_direita = _ClipeFalso( + f"{clipe.identificador}_dir{self._proximo_id}", clipe.nome, + instante, clipe.fim_na_timeline, + clipe.inicio_na_origem + deslocamento, clipe.fim_na_origem, + ) + faixa[posicao:posicao + 1] = [parte_esquerda, parte_direita] + return {"split": True} + raise ErroDeFerramentaMCP(f"Nenhum clipe da faixa {identificador_da_faixa!r} cobre estritamente {instante}s.") + + def _simular_remove_from_timeline(self, argumentos: dict[str, Any]) -> dict[str, Any]: + """Remove o clipe pedido e, com ``ripple``, fecha o espaço deixado na mesma faixa.""" + identificador_do_clipe = argumentos["node_id"] + for identificador_da_faixa, clipes in self._faixas.items(): + for posicao, clipe in enumerate(clipes): + if clipe.identificador == identificador_do_clipe: + duracao = clipe.fim_na_timeline - clipe.inicio_na_timeline + del clipes[posicao] + if argumentos.get("ripple"): + for outro in clipes: + if outro.inicio_na_timeline >= clipe.fim_na_timeline: + outro.inicio_na_timeline -= duracao + outro.fim_na_timeline -= duracao + return {"removed": True} + raise ErroDeFerramentaMCP(f"Clipe não encontrado para remoção: {identificador_do_clipe!r}.") + + def _simular_set_clip_properties(self, argumentos: dict[str, Any]) -> dict[str, Any]: + """Aceita a alteração de propriedades sem simular geometria (não é usada pelos testes).""" + del argumentos + return {"updated": True} + + def _simular_add_marker(self, argumentos: dict[str, Any]) -> dict[str, Any]: + """Aceita a criação de marcador sem manter estado (a chamada já fica em :attr:`chamadas`).""" + del argumentos + return {"added": True} + + @staticmethod + def _identificador_da_faixa(indice: int, tipo: str) -> str: + """Reconstrói o identificador de faixa (``video_0``/``audio_0``) a partir de índice e tipo.""" + return f"{tipo}_{indice}" diff --git a/code/engine/testes/test_aplicador_de_plano_de_edicao.py b/code/engine/testes/test_aplicador_de_plano_de_edicao.py new file mode 100644 index 0000000..a6e99b8 --- /dev/null +++ b/code/engine/testes/test_aplicador_de_plano_de_edicao.py @@ -0,0 +1,97 @@ +"""Testes do orquestrador de aplicação de plano de edição (sem Premiere real). + +Usa um ClienteMCP falso, em memória, que simula uma sequência de vídeo com +os mesmos tempos "torcidos" do projeto real: a timeline lida pelo scanner +não bate com o tempo do arquivo de origem (ver relatório da análise), então +os testes aqui garantem que o aplicador sempre recalcula a posição a partir +do clipe lido ao vivo, nunca de um tempo fixo. +""" + +import unittest +from typing import Any + +from engine.editor.aplicador_de_plano_de_edicao import AplicadorDePlanoDeEdicao +from engine.editor.escrita import EscritaNoEditor +from engine.editor.mapeamento import MapeadorDeTempoDeOrigemParaTimeline +from engine.editor.modelos import AcaoDeEdicao, PlanoDeEdicao, TipoDeAcao +from engine.integracoes.premiere.conversores import ConversorDeTimeline +from engine.integracoes.premiere.leitura import AcessoAoEditor, AcessoATimeline +from engine.testes.duplos_de_premiere import ClienteMCPFalso + + +class TesteAplicadorDePlanoDeEdicao(unittest.TestCase): + """Cobre a ordem de aplicação e o recálculo de posições a cada ação.""" + + def setUp(self): + self.cliente = ClienteMCPFalso() + self.aplicador = AplicadorDePlanoDeEdicao( + acesso_ao_editor=AcessoAoEditor(AcessoATimeline(self.cliente)), + conversor_de_timeline=ConversorDeTimeline(), + escrita=EscritaNoEditor(self.cliente), + mapeador=MapeadorDeTempoDeOrigemParaTimeline(), + ) + + def test_corte_remove_os_clipes_do_intervalo_e_fecha_o_espaco(self): + plano = PlanoDeEdicao("0E6A8290.mp4", ( + AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"), + )) + resultado = self.aplicador.aplicar(plano) + self.assertTrue(resultado.todas_bem_sucedidas) + self.assertNotIn("000f4763_video", [c["node_id"] for c in self.cliente.chamadas_de_remocao]) + # o clipe único que cobre 2.3-40.6s de origem foi removido em ambas as faixas + self.assertEqual( + {c["node_id"] for c in self.cliente.chamadas_de_remocao}, + {"clipe_a_video", "clipe_a_audio"}, + ) + + def test_marcador_e_zoom_sao_aplicados_antes_dos_cortes(self): + plano = PlanoDeEdicao("0E6A8290.mp4", ( + AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="bastidor"), + AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="conferir emenda", parametros={"content": "EMENDA"}), + AcaoDeEdicao(TipoDeAcao.ZOOM, inicio=86.42, fim=91.5, motivo="ênfase", parametros={"scale": 1.15}), + )) + resultado = self.aplicador.aplicar(plano) + self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas]) + # marcador e zoom aconteceram antes do corte na sequência de chamadas + indice_do_marcador = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "add_marker") + indice_do_zoom = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "set_clip_properties") + indice_da_remocao = next(i for i, c in enumerate(self.cliente.chamadas) if c[0] == "remove_from_timeline") + self.assertLess(indice_do_marcador, indice_da_remocao) + self.assertLess(indice_do_zoom, indice_da_remocao) + + def test_dois_cortes_sao_aplicados_do_fim_para_o_comeco(self): + plano = PlanoDeEdicao("0E6A8290.mp4", ( + AcaoDeEdicao(TipoDeAcao.CORTE, inicio=0.0, fim=40.6, motivo="primeiro trecho"), + AcaoDeEdicao(TipoDeAcao.CORTE, inicio=86.2, fim=94.8, motivo="segundo trecho"), + )) + resultado = self.aplicador.aplicar(plano) + self.assertTrue(resultado.todas_bem_sucedidas, msg=[r.detalhe for r in resultado.falhas]) + remocoes = [c["node_id"] for c in self.cliente.chamadas_de_remocao] + # o corte com início maior (86.2s) precisa ser removido antes do de início 0.0s + self.assertLess(remocoes.index("clipe_b_video"), remocoes.index("clipe_a_video")) + + def test_corte_sem_clipe_sobreposto_falha_sem_interromper_o_resto(self): + plano = PlanoDeEdicao("0E6A8290.mp4", ( + AcaoDeEdicao(TipoDeAcao.CORTE, inicio=1000.0, fim=1010.0, motivo="fora do vídeo"), + AcaoDeEdicao(TipoDeAcao.MARCADOR, inicio=90.0, fim=90.5, motivo="ok", parametros={"content": "X"}), + )) + resultado = self.aplicador.aplicar(plano) + self.assertFalse(resultado.todas_bem_sucedidas) + self.assertEqual(len(resultado.falhas), 1) + self.assertIn("Nenhum clipe", resultado.falhas[0].detalhe) + # a ação válida (marcador) ainda foi aplicada + self.assertTrue(any(c[0] == "add_marker" for c in self.cliente.chamadas)) + + def test_texto_e_convertido_em_marcador_por_falta_de_suporte_do_premiere(self): + plano = PlanoDeEdicao("0E6A8290.mp4", ( + AcaoDeEdicao(TipoDeAcao.TEXTO, inicio=90.0, fim=91.0, motivo="reforçar termo", parametros={"content": "MASTOPEXIA"}), + )) + resultado = self.aplicador.aplicar(plano) + self.assertTrue(resultado.todas_bem_sucedidas) + chamadas_de_marcador = [c for c in self.cliente.chamadas if c[0] == "add_marker"] + self.assertEqual(len(chamadas_de_marcador), 1) + self.assertIn("MASTOPEXIA", chamadas_de_marcador[0][1]["comments"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/engine/testes/test_gerar_relatorio_de_analises.py b/code/engine/testes/test_gerar_relatorio_de_analises.py new file mode 100644 index 0000000..c83ea0a --- /dev/null +++ b/code/engine/testes/test_gerar_relatorio_de_analises.py @@ -0,0 +1,90 @@ +"""Testes do relatório dos J-SOM de análise lido do banco de análises.""" + +import json +import tempfile +import unittest +from pathlib import Path + +from engine.dominio import Clipe, Faixa, IntervaloDeTempo, Timeline +from engine.gerar_relatorio_de_analises import ( + ErroDeBancoSemVideos, + MontadorDeRelatorioDeAnalises, + executar, +) +from engine.persistencia import ( + RepositorioDeAnalisesSQLite, + RepositorioDeTimelineSQLite, +) +from engine.scanner.modelos import Cena, EvidenciaVisual, SegmentoDeTranscricao +from engine.scanner.transcricao_da_timeline import TranscricaoDoClipe + + +class TesteMontadorDeRelatorioDeAnalises(unittest.TestCase): + """Cenário comum: um vídeo, duas cenas (uma duplicada), falas e evidências.""" + + def _banco_de_exemplo(self, pasta: Path) -> Path: + banco = pasta / "analises.db" + timeline = Timeline( + identificador="v1", nome="Entrevista", duracao=30.0, largura=1920, altura=1080, + faixas=[Faixa("f1", "V1", "video", 0, [ + Clipe("c1", "A.mp4", IntervaloDeTempo(0.0, 10.0)), + Clipe("c2", "A.mp4", IntervaloDeTempo(10.0, 30.0)), + ])], + ) + RepositorioDeTimelineSQLite(banco).registrar_timeline(timeline) + + repositorio = RepositorioDeAnalisesSQLite(banco) + repositorio.registrar_transcricoes("v1", [ + TranscricaoDoClipe( + identificador_do_clipe="c1", arquivo="A.mp4", + inicio_na_timeline=0.0, fim_na_timeline=10.0, + segmentos=[SegmentoDeTranscricao(0.0, 5.0, "Olá, tudo bem?", emocao="hap")], + ), + ]) + repositorio.registrar_evidencias_visuais("v1", "c1", [ + EvidenciaVisual(tipo="rosto", inicio=0.0, fim=5.0, valor={"qualidade": 0.9}, + confianca=0.9, provider="apple_vision"), + EvidenciaVisual(tipo="rosto", inicio=5.0, fim=10.0, valor={"qualidade": 0.8}, + confianca=0.8, provider="apple_vision"), + ]) + # Cena c1 gravada duas vezes: o relatório não deve duplicar a entrada. + # c2 fica sem cena registrada: deve aparecer com cena_registrada=False. + repositorio.registrar_cenas("v1", [Cena(0.0, 10.0), Cena(0.0, 10.0)], clipe_id="c1") + return banco + + def test_relatorio_unindo_som_e_imagem_por_cena(self): + with tempfile.TemporaryDirectory() as pasta: + banco = self._banco_de_exemplo(Path(pasta)) + relatorio = MontadorDeRelatorioDeAnalises(banco).montar() + + self.assertEqual(relatorio["video"]["nome"], "Entrevista") + self.assertEqual(len(relatorio["cenas"]), 2) # um item por clipe + cena_um = relatorio["cenas"][0] + self.assertTrue(cena_um["cena_registrada"]) + self.assertEqual(cena_um["falas"][0]["texto"], "Olá, tudo bem?") + self.assertEqual(cena_um["falas"][0]["emocao"], "hap") + self.assertEqual(cena_um["resumo_de_evidencias"], {"rosto": 2}) + self.assertEqual(cena_um["evidencias_visuais"][0]["provider"], "apple_vision") + cena_dois = relatorio["cenas"][1] + self.assertFalse(cena_dois["cena_registrada"]) + self.assertEqual(cena_dois["falas"], []) + + def test_executar_grava_json_e_devolve_relatorio(self): + with tempfile.TemporaryDirectory() as pasta: + banco = self._banco_de_exemplo(Path(pasta)) + saida = Path(pasta) / "relatorio.json" + relatorio = executar(banco, saida, "v1") + gravado = json.loads(saida.read_text(encoding="utf-8")) + self.assertEqual(gravado, relatorio) + self.assertEqual(gravado["video"]["video_id"], "v1") + + def test_banco_sem_videos_gera_erro_especifico(self): + with tempfile.TemporaryDirectory() as pasta: + banco = Path(pasta) / "vazio.db" + RepositorioDeTimelineSQLite(banco) # cria o banco com o esquema + with self.assertRaises(ErroDeBancoSemVideos): + MontadorDeRelatorioDeAnalises(banco).montar() + + +if __name__ == "__main__": + unittest.main() diff --git a/code/engine/testes/test_leitor_de_plano_de_edicao.py b/code/engine/testes/test_leitor_de_plano_de_edicao.py new file mode 100644 index 0000000..ec3e00b --- /dev/null +++ b/code/engine/testes/test_leitor_de_plano_de_edicao.py @@ -0,0 +1,92 @@ +"""Testes da leitura e validação do plano de edição (engine.editor.leitura).""" + +import tempfile +import unittest +from pathlib import Path + +from engine.editor.erros import ErroDePlanoInvalido +from engine.editor.leitura import LeitorDePlanoDeEdicao +from engine.editor.modelos import TipoDeAcao + +PLANO_VALIDO = { + "source": "0E6A8290.mp4", + "actions": [ + {"kind": "cut", "start": 0.0, "end": 10.0, "reason": "bastidor"}, + {"kind": "zoom", "start": 12.0, "end": 15.0, "params": {"scale": 1.2}, "reason": "ênfase"}, + ], +} + + +class TesteLeitorDePlanoDeEdicao(unittest.TestCase): + """Cobre a validação do formato JSON do plano de edição.""" + + def setUp(self): + self.leitor = LeitorDePlanoDeEdicao() + + def test_le_plano_valido_de_dicionario(self): + plano = self.leitor.ler_de_dicionario(PLANO_VALIDO) + self.assertEqual(plano.arquivo_de_origem, "0E6A8290.mp4") + self.assertEqual(len(plano.acoes), 2) + self.assertEqual(plano.acoes[0].tipo, TipoDeAcao.CORTE) + self.assertEqual(plano.acoes[1].parametros, {"scale": 1.2}) + + def test_le_plano_valido_de_arquivo(self): + with tempfile.TemporaryDirectory() as pasta: + caminho = Path(pasta) / "plano.json" + caminho.write_text( + '{"source": "a.mp4", "actions": [{"kind": "marker", "start": 1, "end": 2, "reason": "ok"}]}', + encoding="utf-8", + ) + plano = self.leitor.ler(caminho) + self.assertEqual(plano.arquivo_de_origem, "a.mp4") + + def test_arquivo_inexistente_levanta_erro_especifico(self): + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler(Path("/caminho/que/nao/existe.json")) + + def test_json_malformado_levanta_erro_especifico(self): + with tempfile.TemporaryDirectory() as pasta: + caminho = Path(pasta) / "plano.json" + caminho.write_text("{isso nao é json", encoding="utf-8") + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler(caminho) + + def test_sem_source_levanta_erro(self): + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler_de_dicionario({"actions": PLANO_VALIDO["actions"]}) + + def test_actions_vazia_levanta_erro(self): + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler_de_dicionario({"source": "a.mp4", "actions": []}) + + def test_kind_desconhecido_levanta_erro(self): + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler_de_dicionario({ + "source": "a.mp4", + "actions": [{"kind": "voar", "start": 0, "end": 1, "reason": "x"}], + }) + + def test_fim_menor_ou_igual_ao_inicio_levanta_erro(self): + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler_de_dicionario({ + "source": "a.mp4", + "actions": [{"kind": "cut", "start": 5, "end": 5, "reason": "x"}], + }) + + def test_reason_vazio_levanta_erro(self): + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler_de_dicionario({ + "source": "a.mp4", + "actions": [{"kind": "cut", "start": 0, "end": 1, "reason": " "}], + }) + + def test_campo_obrigatorio_faltando_levanta_erro(self): + with self.assertRaises(ErroDePlanoInvalido): + self.leitor.ler_de_dicionario({ + "source": "a.mp4", + "actions": [{"kind": "cut", "start": 0, "reason": "x"}], + }) + + +if __name__ == "__main__": + unittest.main() diff --git a/code/engine/testes/test_mapeador_de_tempo.py b/code/engine/testes/test_mapeador_de_tempo.py new file mode 100644 index 0000000..aadb533 --- /dev/null +++ b/code/engine/testes/test_mapeador_de_tempo.py @@ -0,0 +1,69 @@ +"""Testes do mapeamento de tempos de origem para a timeline (engine.editor.mapeamento).""" + +import unittest + +from engine.dominio import Clipe, Faixa, IntervaloDeTempo +from engine.editor.erros import ErroDeMapeamentoDeTempo +from engine.editor.mapeamento import MapeadorDeTempoDeOrigemParaTimeline + + +def _clipe(identificador, tl_inicio, tl_fim, origem_inicio, origem_fim, nome="0E6A8290.MP4"): + """Monta um Clipe de teste com intervalo na timeline e na origem.""" + return Clipe( + identificador=identificador, + nome=nome, + intervalo_na_timeline=IntervaloDeTempo(tl_inicio, tl_fim), + intervalo_na_origem=IntervaloDeTempo(origem_inicio, origem_fim), + ) + + +class TesteMapeadorDeTempoDeOrigemParaTimeline(unittest.TestCase): + """Cobre a tradução de tempos de origem, reproduzindo o caso real do projeto: + a timeline reindexada pelo detector de cena não bate com o tempo do arquivo.""" + + def setUp(self): + self.mapeador = MapeadorDeTempoDeOrigemParaTimeline() + # Reproduz o caso real: clip 000f4775 está em 86.2-114.0s de origem, + # mas em 58.1-86.0s na timeline reindexada pelo scanner. + self.faixa = Faixa("video_0", "Vídeo 1", "video", 0, clipes=[ + _clipe("000f4763", 0.0, 38.3, 2.3, 40.6), + _clipe("000f4775", 58.1, 86.0, 86.2, 114.0), + _clipe("outro_arquivo", 86.0, 90.0, 0.0, 4.0, nome="outro.mp4"), + ]) + + def test_clipes_do_arquivo_filtra_por_nome_ignorando_caixa(self): + clipes = self.mapeador.clipes_do_arquivo(self.faixa, "0e6a8290.mp4") + self.assertEqual([c.identificador for c in clipes], ["000f4763", "000f4775"]) + + def test_localizar_clipe_no_instante_encontra_o_clipe_certo(self): + clipes = self.mapeador.clipes_do_arquivo(self.faixa, "0E6A8290.MP4") + clipe = self.mapeador.localizar_clipe_no_instante(clipes, 90.0) + self.assertEqual(clipe.identificador, "000f4775") + + def test_localizar_clipe_no_instante_fora_de_qualquer_clipe_devolve_none(self): + clipes = self.mapeador.clipes_do_arquivo(self.faixa, "0E6A8290.MP4") + self.assertIsNone(self.mapeador.localizar_clipe_no_instante(clipes, 500.0)) + + def test_instante_na_timeline_traduz_usando_o_deslocamento_do_clipe(self): + clipe = _clipe("000f4775", 58.1, 86.0, 86.2, 114.0) + # 86.42s de origem (início real da fala) cai 0.22s depois do início + # do clipe (86.2s) e deve mapear para 58.1 + 0.22 = 58.32s na timeline. + self.assertAlmostEqual(self.mapeador.instante_na_timeline(clipe, 86.42), 58.32, places=6) + + def test_instante_na_timeline_sem_intervalo_de_origem_levanta_erro(self): + clipe = Clipe("x", "n", IntervaloDeTempo(0, 1), intervalo_na_origem=None) + with self.assertRaises(ErroDeMapeamentoDeTempo): + self.mapeador.instante_na_timeline(clipe, 0.5) + + def test_instante_na_timeline_por_arquivo_encontra_e_traduz(self): + timeline = None # não é usado pelo atalho; mantido para deixar a chamada explícita + instante = self.mapeador.instante_na_timeline_por_arquivo(timeline, self.faixa, "0E6A8290.MP4", 90.0) + self.assertAlmostEqual(instante, 58.1 + (90.0 - 86.2), places=6) + + def test_instante_na_timeline_por_arquivo_sem_clipe_levanta_erro(self): + with self.assertRaises(ErroDeMapeamentoDeTempo): + self.mapeador.instante_na_timeline_por_arquivo(None, self.faixa, "0E6A8290.MP4", 500.0) + + +if __name__ == "__main__": + unittest.main()