From c343c15a3ea09fa2093efe4607cd86f733267cca Mon Sep 17 00:00:00 2001 From: badmark Date: Mon, 17 Aug 2026 20:05:53 -0600 Subject: [PATCH] more fixes + yt b-roll --- pipeline/__pycache__/research.cpython-314.pyc | Bin 0 -> 12351 bytes .../__pycache__/script_gen.cpython-314.pyc | Bin 27169 -> 29592 bytes pipeline/__pycache__/tts_gen.cpython-314.pyc | Bin 16231 -> 16232 bytes .../__pycache__/visual_gen.cpython-314.pyc | Bin 51611 -> 68882 bytes pipeline/research.py | 184 +++++++++ pipeline/script_gen.py | 40 +- pipeline/tts_gen.py | 3 +- pipeline/visual_gen.py | 366 +++++++++++++++--- youtube_factory.py | 13 + 9 files changed, 541 insertions(+), 65 deletions(-) create mode 100644 pipeline/__pycache__/research.cpython-314.pyc create mode 100644 pipeline/research.py diff --git a/pipeline/__pycache__/research.cpython-314.pyc b/pipeline/__pycache__/research.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8df31ceccc95eb15e2522e43939e78d98bd73581 GIT binary patch literal 12351 zcmcgyYit`=cAnu&Bt<=J%aUx#*63kLv?a=x?bwl>IFc+`ie*U|(X_LEa7W~b9E;>I zGegVdHm%VXSO-`%4tCMli!SV46kQudu}*<4@-x}pr0K7YSN2+%xJZB&E%HxU2XL_a zqy5euQlb=jlWl=g(4Bkl+_{f)&pnUt+&$G*eh$C+pZxiyw>voQ->Fdgxr&)5e~rvl zPUH;kA}8|Wyuq`l!*H;t({Qn;yZH1N9+Vy96=Pn*JLWTdV}8RwR%ukWa2I*eIUX3R zGOAj*3!drb(on0oZtfsTF zp%}_#SqQ6HB_@cnE=yW0DRc@EMUzs($dznLQZlk8jAslvl~NLNCMF9bnFK0B{!)8c z7mTDVB$Y%`){WlFio77lg;&*FI5#T`mzB7z>VlMsBTKzzBu$gpu)3k;Vn$Arb^n~I z2? zEZ1=*W?0U_Ox|)$D7s;Jr?Q5kW~5Y*x4fd9RtXjG>T+OAqh`;ux}?n_JBeBZRDUXo*m z-giPyD;Xt1d@!wK)-fdrI&v)AGvOU+8 z9m5A+{DUfiC)<(Yi0}dIW4|NoIFA?hZ@RpFsgAgR-7n%O$=R$^bVQs14*j^w`k}+J z?lrinvS$BxbdYZVe>Az}i4^)b{FM36Hsh=Z!l+QVG z&M0@F*fd)ashB2OultqWPZQbK{X~ygA$m)FX1(WWSJ-d5u9RDSLihM0?of$s?efXPQ`S-EXyh3wLso<9yssPOPH8(#S1b*wa<~Dh|3db7IYOsiLg6 zu%|R~J15qL!HNAi83MP5D>Y7F@0&Tr(ZcQETG-!2=!kO%r|<%IUMO|t;o|MoE*m)C zR;rucRm!bC#p-Z?#wd+Sy^2-Yz_Gx@tM*gj70&KIxT$b#R5oA%6b%7Aqhw(u*xI|K z(2MUNxN|AIBF#(h2pu`JpCus zP)tp;y1rwty-@IBWJ`&*+R!7AlU?n>{aD$Ty!8 zM`lI_#o@6F0%UcG08mEqfMD_dLf3TB%qU-;o|`2VCunj)(&9=cF(3r%EKg2@tu$-7 zvy!IEmix6Fda)`r`L!JEBdbERGa8)-s{)PHteml28WwAL;&M!l%a%jK7jsZ6QYvM6 zj!%ULCng{!bLbVaQpsA*6u$9D*q%(>@{L@H$;`lk5?2!mS+m@6c{Z1@T$fZO6Rgr2 zFqYO#17bR|mbWTmDOt)yU$dN;faS#GEN^MMX)b!!a?w^<70~uE74c&fFU}t6#k4d|X}kX6B8|qw0?P)g8BI z?j3!v?w?!UZ!!B{F{?WktHo>1Pq%En=3RF9eJ?!T)bRGsw{||-)OCMT*B$4*SIkXa zk2W28u<6jd2bLO|-p;?3|NeI#H3aWB1n+dc+if-k7aNAJyZ&Q)^R3-K+->ffHpR$; zv)_7jR=R&yTHHQ+y<%ybaC`HurW;My2Oqb#|Fr%m^>_UDhTi2DTL&JszI4C!rN!2v z>n}fU*nVT+`+s(4^KTmVK*ipwxmk0^|C`4BpI30(cRlrSn-4#&=eqi?8E@v_$lqvO ztZ6g7viaNv^J2`r5H}CVC_ZgKGfejPU(|B7+rHpJ7pzk#VxQUW8qtwc6%wV^?= zp)paKPpRMdf05E?f`ce}MW|e+KAnAz5D#TmE!K#&eXcAVn2qK(y^)!*;i%%Ki0k4} zaq}UUP0Fl0;$E4--!bL5CE})SjNhVC#4T7GDFw<)Sq~KU24;-7wX|{= zI-U)UnfjF$-bjTQJiLI}oTb^E`7W6Ragp+6=EsI6&Y48SdyyvxqV(grm41C?X4^)5 z)e!MblVD!=6C1^CC5K(yUcOrJPSYAL^udT|6n8RCYw352g4oLPZT(K{XuH_KO1oI8 z6R^PUDPHUnyZhW^a-3f=s8);#Z`q7DFr~!iu=gv*#Ez`@n0Mm1$ev}!#2#q?GJFLk z5YjLgl-@MFCOG220_24ijG$yDXeG1&4UD7$T!8@-h6+Q%(BP>L_OAm^SWZ(^ozQ`Vl*KnE<$M|LpveFa070l3+p>wNIp7;wFFf3w z4TzMq7CxOZCMU- zIpS2j%utn860V{uWSb2LT?JQHC=@EZ2Ln!!Fuo+DC5)l1kRN^+>@|C$fM(BNxxp*!%XL+JgyTD_6&lCE>)&Dc$yNS4q+%tkSBdj5<9qxZpkm z*|9;Nm(^q@lNZJS6DfKy(dshphEz&p$oR&$!5Y6=iCZ1JJqefboR5|rHT1Z@_W+S2-aDI87l?mw z#(}=kw|#m{Ats!|DS(WCp{!;AEW){f6)nZpD}*wtI7vmwkgbPpB6u3-Hy-jIz?duG zViZUwMm*RHsZmDztfnSFOFESUei*L3!XV!76S_&NjM>8DRGv;pKDhe6-+n)h)kq5P ztAub0bSP`E-)=Aj8-CsZkdsR!h2urMvb>`McDqwD9TG(+F)>q=FZ2WiE2SW{%6dLn z9GZxrB+|l6QkgR#;ZDIz&@#dZfHZW4FAg&HA;}VD;sy)^xu}5#gWz8n@Wi_N_V(^O zC@iQNfw4>qLG%Dw)!@Yt>%?U}2JeF9N$O^FptAye4?zc;f%j5M76+T?WPgt>&tuV zZ@Zoo&b6_f9uU~T0xR{eDeYfVIW8>jYFAA3*=IP)ICBrsp3Qs8!&}%c)rd5y5&w|w^NoIfMZ&+ylF5n zz&{;+nwpX=7omZ0=u{}NjA2z8GLU`9arBp%W554AX8F_7m8hZ4V@p3HJnVOMeV6z3 zfenp5l5Q!40-$@eR@frwDyb?l^j}dN$>P6b@vj%!$`sOwt&kxSZ2dzr3%bUtT`$74 zPIL*Mo1BHof(z8|Ds+@}G%<0isOBY-)(~@o?R8HUc8@?Q;GofggS0V{bgr1-!Fh*R zGgK9_dKR#NQ0N4Obph*1!7)x?TC;fwZXKUOWR^k)`H;1BLv}0R7((d7i7yjLf)rzx z0i@8JP-qfJMKRVVQ3_TRWRgFSm?5iX9cLCgNuI!}fDKSo2TZBNB!kvyK%in(aUcsJ zt7;9WT3a^YuEP00g12HYh7e4FyZ}3W6At_AzwryY3tMQ;Qveif0mp=3p^*v36)_FH zozY1w=!GVFBg&W6RE~Iu;GPwcSE#c;i-SJGinJ?iqS~V58c)N7GJ@6GVPZL#l7Ye< zMV0~CmK%^mY~Dt@eOXw0(M!4tkc1!+%dN48i&eQ(sub5PXF@i#L)29Tjv6f?=r2P( zDhZ2VCj`)i2U;MV1rn2$^7OUEYuXfj%In$)>msf~MS#P|Ph3SfY+&Xge$fKs7`%OJ}!auuk_rjy5BfoAsvh3Jgb&~(IuIa{^rMjKBJMVPf3%(mM zTaTN<$WmSF?fpx2!tIV_pSx-E^@`;Hw{_dwC*L~xs4jTFE_kQoXT5iO&F81gSI(NV zakDPCSSMfSKiN`uW7}=%Z+0&2Y`N|GQR>dVAE|eghdU3yyY1o5;p-QqthRqFlX|nP*@7Io84m{ZT+aioVsLo&YQbZOMx9`8>{;0HB)%SY!R0NO~2*1 zofjNSf!1Y?>lkt@S9)sw%bdsO|MCkTSGNP;3JCYOw*Kwvx2nz7k@uwcX3UoHN3}2C zuYH-&6}_MQaRe~{Ls@l+V1?Y-HrSj0ENN<I7lmv@H%mSJ70<#tN<9OI z0XhYQVq-*PqrlFFh)4+&DD@jhIPxnvjB6bZgRt@c8HaJN!C`=wtjypaX$In!h=<`Y zf6rFw`CsEO&j=@#;V?j1z6OUuxJc|~I82;obLIxaVJ=esYjKz>5#KX$7=OgSAMx#@DQE!K*S=eLy_tOkd`S=-j&GnK$+n#CRBPO+uWft-L` zE6cSZ*UoYs$n9dePUO141MX}f5)hjr4jwU2;4~{y`xTs~99dckYL(`LAocdJ?<+V> zQ`UFPH!&u%XBkctEQI}osS@e3pa_OQJ6k7FenNoyjiIB?R>F`wV zYFwpc9gulZ*uCx+$1^(ua`6`>%5qmQleRMeeAqoCU>`9l(?uaV0#L|-jm=}Ot6w8diN#0R zR?x78sx~&2*O6ZEzplNEW2K#>1o3E2J4Fd1z#Ov}wQ1xMKfFo5&yH!YP(3MiZH5wZ zrnE36rzwe0at4Xzu@_%xde#wv|59i#dR9fAq3~dyaSkYq*eUH)2%glsLq_h@in~+t zq*&xH_Y#I?2DpuH&>=eU%YT}c=TnLhma_o5;E~V~8&)m@q!!Q63^6;qTSS~GP1W}5~g_B{FlL~V= z&>x&}ps_d&z>Q@B!1g_VXdk`-ay6@H_!eN6jiJC5=n4~Fl3@^p7ASMIL zO1M^WO48h13R5B!2-E=jxj<~n_8TE#_;PxvFh#wEwVaG*xU)tZhz^B8z-IGslOd;9 zeJvX}>?eZvz6yf(PQeo;JXB4hlv~c z3br59BS9;{J;zvt(qTg~0J}ql7l;%?AhGnfF_hd2PCN@ID*Ov0t~ z!W5m?ByA-@_R=gE5nK@m`=)_<#egKw(FFrxRs+&ZO9q0Kig!UoGDew`HIkV402%?% zeMpOf)q^$6wbZ_iywx~8HGO(wP>hb8ot_vRpBxdRljFlZnFB`HeC#njT2FelR$2N}P!q+_dRc7U_B~gQcC>iwF=}W4&%r6SGpbt$H}pnJ65&~&UBxW)YJ^~~ za$k5?{L}t>2k&*6&&SNV_ye!}iLmRf+=D&C?+rfOGkPts?BeVFOa7W`4QB1UnYv=m z=a;#v0^hj2o!hqS`k&(VU8i$-$0p{Yc76jFb<6*^i%L%EH*ry0+HVzpSh&-5Z}6V< z^Wi(8#g^x9xRzRX{j~ANjd!~54K21Fy5aexsr7Ml+pUu~Pnx?&eii$u^Oy7QT`E&61DUEpRgWYCug?VZ+aV-tGQa?w&(U%v!=`R zc76Fp!yo6_9z8Z%#r@*w&|W+~tg0LxaDLd>IC{|e;Xya@+E(yR&@rhD;ocNt_XPKS zMiz6>PnQofQkpJv`lC@u+gytBxaRp9VCv$VnrM`rV!9TZQKM0fqLMZeq*CrXNUW{6 z%!6y8N-QcFFgIo)y>#@nsq7MJ3>TE6(I9VMAcQ@|y=31XBtKK5gbZb!oSiSf3qF79 zpbMkFWp_lM`sp5_eT(pE4_zp*FA_YRbilWLN)9e_ZX z4g%_xWQt4X3p)o%Eo74bH)GUh2FRCOQX{fn=$T>xBZf16RdTXUDX5UOd+RHzViNzh zBqv4Dxekg8Bg6o;OT+IvPPX4v4EKEyw;)(gfhE@|i~##j3NxeupkfQ%r!%^mq9Bhw z0E?%NWDs-!u-eH2cEYfvqvfs;?}CR?@s|jV<9cAn<@jvN<@Qjr>X)AgN9GQ zlFDcCT*WbLSi?=9;C?CH^R}-q`(Wfvu&YtFR#>3nh%4@L_O8~_C*Gqrm%gZd4@F`k zeH;mZ9-jZ);o@CS0vzxEn5+Dl+xjuL-jgX`BPu*Bj5I4`?i}+eT%-m ZS3SS=_?jIi*Z8#loP+Ov!coeG`X5Hb<}LsL literal 0 HcmV?d00001 diff --git a/pipeline/__pycache__/script_gen.cpython-314.pyc b/pipeline/__pycache__/script_gen.cpython-314.pyc index f1961ba179933f1e03ae3bdc588c1c601141c6e2..403216e7508f5ed6d11e1632b6b548efb6087468 100644 GIT binary patch delta 6473 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 S-S&wkWD}>whXQ|bF8>3t`3MmJ delta 4166 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 delta 689 zcmaD+_q>izn~#@^0SF8}HD*23+{kC8noz{Z%Jq=}L@sn$DZDayg~U?74`BYzUcae=#xTLQKll@KlJVA_1Ur$t)m4p+FEwGcz#od_Kg@z|g=j*-uS;@_y9@ z#<0x}YWo=(!zXDdbFXG#C}(0|D0&~pKRG~6WV4e-3X4FD1K$jXQlCMwV1%H+L{fq|1jZgQ@=!sOQ)Vv5#b%s|C9 zM$9Ha>p-ADI>r#fVqgf94ziuRPg{2KdtC)~yD%A`O8d!TdXe%DU|r%!x@2RlV7dZq zCQlR8RQk@9pn?_8z!sA9|VGa=D?5ufdaWOxd6M# z*Yq?Q9VcHD6rRkltHkOL6myzvrLWBz0A`&QQJCDNFVE;Y`J%A!kF<85vb37nrVR5eGVKvz%EmBV*F$N#-WZ!gE+|2uRKF_`uJh!uN%N zgO~q<(BykoA?&4$4B}c}sy16%7cnw=PF`cr+l4P=>o6PuVMqBImcOeLrs{@ zND75nvR_b@59ejQYQi06#(LFWC(MxbnjtHYea(_PoQwN9FL$`yWCPnnyr4w)9h}sF X32gI!TSG?n3Pwh*&kPll&Fu{V+d8KH diff --git a/pipeline/__pycache__/visual_gen.cpython-314.pyc b/pipeline/__pycache__/visual_gen.cpython-314.pyc index 89905d152e676418314e4b3cb2318a42907317d9..6335e55bf70d2055690d8553c8dfe121e4455cef 100644 GIT binary patch delta 28496 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 delta 13843 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 str: + """Fetch search snippets and YouTube search results for the topic.""" + query = f"{topic} documentary explained" + clean_q = urllib.parse.quote(query) + url = f"https://html.duckduckgo.com/html/?q={clean_q}" + req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) + + snippets = [] + try: + with urllib.request.urlopen(req, timeout=8) as resp: + html = resp.read().decode("utf-8", errors="ignore") + # Extract search result snippets + matches = re.findall(r' Dict[str, Any]: + """ + Research multi-million view YouTube explainer videos on this topic to extract: + - The most viral psychological hook & curiosity gap + - Key narrative revelations and counter-intuitive insights + - Step-by-step explainer pacing (Kurzgesagt / Veritasium / ColdFusion style) + - Core visual motifs for artistic consistency + """ + logger.info(f"[RESEARCH] Researching top-performing viral explainer structures for: '{topic}'...") + web_context = self._search_web_insights(topic) + + prompt = f"""You are a master YouTube retention strategist and documentary showrunner (studying 10M+ view explainer channels like Veritasium, Kurzgesagt, ColdFusion, Wendover, and Johnny Harris). + +TOPIC: "{topic}" +WEB SEARCH CONTEXT: +{web_context[:1500]} + +OBJECTIVE: +Analyze why the most viral videos on this topic succeed and formulate a comprehensive high-retention blueprint for our original 7-12 minute explainer documentary. + +Provide: +1. "viral_hook_angle": The irresistible curiosity gap / central question that hooks viewers in the first 10 seconds. +2. "common_misconceptions": 2-3 widely believed myths that we will dramatically debunk. +3. "key_revelations": 3-4 mind-blowing historical/scientific milestones or paradoxes to structure the video around. +4. "narrative_arc": 5-act explainer progression: + - Act 1 (The Hook & Mystery) + - Act 2 (The Prevailing Myth) + - Act 3 (The Breakthrough Discovery) + - Act 4 (The Mechanism / Step-by-Step Reality) + - Act 5 (The Paradigm Shift & Modern Epiphany) +5. "visual_style_anchor": A cohesive 10-15 word visual art direction description to ensure ALL scene clips look visually unified. + +STRICT JSON OUTPUT: +{{ + "viral_hook_angle": "...", + "common_misconceptions": ["...", "..."], + "key_revelations": ["...", "...", "..."], + "narrative_arc": ["Act 1: ...", "Act 2: ...", "Act 3: ...", "Act 4: ...", "Act 5: ..."], + "visual_style_anchor": "cinematic documentary style, warm archival tones, 35mm film texture, photorealistic 8k" +}}""" + + if self.llm_pc and self.llm_pc.check(self.llm_port): + try: + payload = { + "model": "llama", + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.7, + "max_tokens": 2048 + } + r = self.llm_pc.post_json(self.llm_port, "/v1/chat/completions", payload, timeout=60) + if r and "choices" in r: + content = r["choices"][0]["message"]["content"] + # Extract JSON + s = content.find('{') + e = content.rfind('}') + if s != -1 and e > s: + dossier = json.loads(content[s:e+1]) + logger.info(f"[RESEARCH] Extracted viral hook: '{dossier.get('viral_hook_angle', '')[:60]}...'") + return dossier + except Exception as e: + logger.warning(f"[RESEARCH] LLM topic analysis note: {e}") + + # Fallback structured research dossier + return { + "viral_hook_angle": f"Everything we thought we understood about {topic} was challenged by a single discovery.", + "common_misconceptions": [f"Standard historical accounts of {topic} overlook the key evidence."], + "key_revelations": [f"The untold origin and catalyst behind {topic}."], + "narrative_arc": ["The Opening Mystery", "The Historical Consensus", "The Revolutionary Discovery", "The Hidden Mechanism", "The Modern Impact"], + "visual_style_anchor": "cinematic documentary style, warm archival color grade, 35mm film texture, photorealistic 8k" + } + + def discover_popular_trending_topics(self, niche: str = "", count: int = 3) -> List[Dict[str, Any]]: + """ + Autonomously research and generate high-velocity popular YouTube explainer topics. + """ + logger.info(f"[AUTO-RESEARCH] Discovering {count} high-velocity YouTube explainer topics...") + target_niche = niche if niche else POPULAR_EXPLAINER_NICHES[0] + + prompt = f"""You are the Head of Programming for top-tier YouTube explainer channels (like Kurzgesagt, ColdFusion, Lemmino, Wendover). +Generate {count} distinct, multi-million-view potential documentary explainer video topics for the niche: "{target_niche}". + +Requirements for EACH topic: +- Must have massive viral appeal, strong curiosity gaps, and deep explainer substance. +- Target duration: 7 to 12 minutes. +- Titles must be high CTR, intriguing, and professional. + +STRICT JSON OUTPUT: +{{ + "topics": [ + {{ + "topic": "The Full Intriguing Video Title", + "curiosity_angle": "Why viewers can't resist clicking", + "target_niche": "{target_niche}" + }} + ] +}}""" + + if self.llm_pc and self.llm_pc.check(self.llm_port): + try: + payload = { + "model": "llama", + "messages": [{"role": "user", "content": prompt}], + "temperature": 0.8, + "max_tokens": 2048 + } + r = self.llm_pc.post_json(self.llm_port, "/v1/chat/completions", payload, timeout=60) + if r and "choices" in r: + content = r["choices"][0]["message"]["content"] + s = content.find('{') + e = content.rfind('}') + if s != -1 and e > s: + data = json.loads(content[s:e+1]) + topics = data.get("topics", []) + if topics: + logger.info(f"[AUTO-RESEARCH] Discovered {len(topics)} viral topics ready for production.") + return topics + except Exception as e: + logger.warning(f"[AUTO-RESEARCH] LLM discovery note: {e}") + + # Fallback curated high-velocity explainer topics + fallback_catalog = [ + {"topic": "The Mystery of Göbekli Tepe and the Civilization Before History", "curiosity_angle": "How hunter gatherers built stone monoliths 7,000 years before Stonehenge."}, + {"topic": "How the First Microprocessor Silently Changed the Human Race", "curiosity_angle": "The hidden engineering race in 1971 that birthed modern personal computing."}, + {"topic": "The Black Hole Information Paradox: How the Universe Destroys Reality", "curiosity_angle": "The terrifying theoretical paradox that baffled Stephen Hawking for decades."}, + {"topic": "The True Story of the Antikythera Mechanism: The 2,000-Year-Old Analog Computer", "curiosity_angle": "Ancient Greek gears that shouldn't have existed for another millennium."}, + {"topic": "How Beer Built Human Civilization: The Agriculture Hypothesis", "curiosity_angle": "Why humanity abandoned nomadic life to brew the world's first fermented grains."} + ] + return fallback_catalog[:count] diff --git a/pipeline/script_gen.py b/pipeline/script_gen.py index 8aacbe1..2b0b1a7 100644 --- a/pipeline/script_gen.py +++ b/pipeline/script_gen.py @@ -9,6 +9,7 @@ import logging import re from typing import Dict, Any, List, Optional from .remote import RemotePC +from .research import ViralTopicResearcher logger = logging.getLogger("YTFactory.Script") @@ -16,31 +17,51 @@ class ScriptGen: def __init__(self, pcs: Dict[str, RemotePC], llm_port: int = 8002): self.pcs = pcs self.llm_port = llm_port + self.researcher = ViralTopicResearcher(pcs.get("amdllm"), llm_port) - def _build_master_prompt(self, topic: str, duration: int) -> str: + def _build_master_prompt(self, topic: str, duration: int, research_dossier: Optional[Dict[str, Any]] = None) -> str: # Pacing: ~140 words per minute for professional documentary delivery. num_segments = max(4, duration // 20) target_words = max(70, int(duration * 140 / 60)) words_per_seg = max(25, target_words // num_segments) - return f"""You are an award-winning documentary writer and YouTube strategist. -Generate a complete, high-retention faceless documentary script for the topic: "{topic}". + dossier = research_dossier or {} + viral_hook = dossier.get("viral_hook_angle", "The unexpected mystery behind this subject.") + misconceptions = ", ".join(dossier.get("common_misconceptions", [])) or "Standard historical misconceptions" + revelations = ", ".join(dossier.get("key_revelations", [])) or "Groundbreaking discoveries" + visual_style = dossier.get("visual_style_anchor", "cinematic documentary style, warm archival tones, 35mm film texture, photorealistic 8k") + + return f"""You are a master YouTube retention strategist and award-winning documentary showrunner (modeling 10M+ view explainer channels like Veritasium, Kurzgesagt, ColdFusion, and Wendover). +Generate an authoritative, high-retention faceless explainer documentary script for the topic: "{topic}". + +COMPETITIVE VIRAL INTELLIGENCE & RESEARCH: +- Primary Curiosity Gap Hook: "{viral_hook}" +- Critical Myths to Debunk: {misconceptions} +- Breakthrough Revelations & Step-by-Step Mechanisms: {revelations} +- Unified Visual Art Direction: "{visual_style}" SPECIFICATIONS: - Target Duration: Exactly ~{duration} seconds of continuous narration - Total Word Count: ~{target_words} spoken words across all segments (~{words_per_seg} words per segment) - Number of Segments: Exactly {num_segments} chronological segments -- Tone: Mysterious, engaging, cinematic, informative, highly gripping +- Tone: Mysterious, authoritative, engaging, cinematic, insightful +- Narrative Flow (5-Act Explainer Blueprint): + * Act 1 (Opening): Irresistible hook & psychological curiosity gap + * Act 2 (The Illusion): The common myth or historical puzzle + * Act 3 (The Turning Point): The catalyst or critical breakthrough + * Act 4 (The Mechanism): Step-by-step breakdown of how it actually happened + * Act 5 (The Paradigm Shift): Profound modern conclusion and lasting impact - Spoken Depth: Each segment MUST contain a full, rich spoken paragraph ({words_per_seg - 5} to {words_per_seg + 10} words) so the narration fills the entire video duration without silence or rushing. For EACH segment provide: 1. "chapter_title": Short 2-4 word chapter title (e.g., "The Ancient Mystery", "The Critical Discovery") 2. "text": English spoken narration (rich 2-4 sentence narrative paragraph, natural pacing, compelling storytelling) -3. "visual_prompt": Highly detailed visual scene description for an AI video/image generator (cinematic lighting, camera angle, 8k documentary style) +3. "visual_prompt": Highly detailed visual scene description for an AI video/image generator (cinematic lighting, camera angle, {visual_style}) 4. "stock_query": 2-4 concrete, high-precision visual search keywords describing physical objects/scenery that exist in stock video libraries (e.g., "ancient temple monoliths", "medieval cathedral altar", "archaeological excavation desert", "prehistoric stone tools", "ancient scrolls manuscript"). Never use abstract concepts, full sentences, or obscure person names. 5. "on_screen_text": Optional 2-5 word lower-third label, key statistic, date, or scene title to display on screen (e.g., "Depth: 11,034m", "Year: 1960", "Pacific Ocean Abyss", or "" if none) 6. "is_historical": boolean (true if this segment depicts past events, ancient civilizations, historical figures, archaeological discoveries, or vintage eras; false if modern/contemporary) -7. "duration_estimate": Estimated duration in seconds ({max(10, duration // num_segments)}s) +7. "person_name": Name of any specific historical person, scientist, inventor, philosopher, leader, or notable figure discussed in this scene (e.g., "Alan Turing", "Steve Wozniak", "Charles Babbage", "Jack the Ripper", or "" if none) +8. "duration_estimate": Estimated duration in seconds ({max(10, duration // num_segments)}s) METADATA REQUIREMENTS: - "titles": Exactly 3 distinct viral, high-CTR YouTube titles for A/B testing (max 85 chars each) @@ -352,7 +373,9 @@ STRICT JSON OUTPUT: if not pc or not pc.check(self.llm_port): raise RuntimeError(f"AMDLLM llama.cpp not reachable on port {self.llm_port}") - prompt = self._build_master_prompt(topic, duration) + # ── Step 1.0: Viral Topic Research & High-Retention Explainer Blueprint ── + research_dossier = self.researcher.research_topic(topic) + prompt = self._build_master_prompt(topic, duration, research_dossier=research_dossier) for attempt in range(1, 4): logger.info(f"[SCRIPT] Requesting script generation from AMDLLM (attempt {attempt}/3)...") @@ -372,6 +395,8 @@ STRICT JSON OUTPUT: titles.append(f"{topic} - Part {len(titles)+1}") data["titles"] = titles[:3] data["title"] = titles[0] + data["research_dossier"] = research_dossier + data["visual_style_anchor"] = research_dossier.get("visual_style_anchor", "cinematic documentary style, warm archival tones, 35mm film texture, photorealistic 8k") segments = data.get("script_segments", []) if not segments: @@ -381,6 +406,7 @@ STRICT JSON OUTPUT: s.setdefault("chapter_title", f"Part {i+1}") s.setdefault("duration_estimate", max(15, duration // len(segments))) s.setdefault("stock_query", topic) + s.setdefault("visual_style_anchor", data["visual_style_anchor"]) if not s.get("visual_prompt"): s["visual_prompt"] = f"Cinematic documentary footage illustrating: {s.get('text', topic)}" diff --git a/pipeline/tts_gen.py b/pipeline/tts_gen.py index f14d43a..e9e39c8 100644 --- a/pipeline/tts_gen.py +++ b/pipeline/tts_gen.py @@ -221,7 +221,8 @@ class TTSGen: "end": current_time + dur, "duration": dur, "chapter_title": chapter_title, - "text": text, + "text": raw_text, + "spoken_text": spoken_text, "wav_file": seg_path }) current_time += dur diff --git a/pipeline/visual_gen.py b/pipeline/visual_gen.py index 7c6d795..818194f 100644 --- a/pipeline/visual_gen.py +++ b/pipeline/visual_gen.py @@ -66,11 +66,149 @@ def clean_search_keywords(text: str) -> str: # Keep up to 3 most specific visual keywords return " ".join(filtered[:3]) if filtered else text.strip() +import hashlib + +def get_file_hash(path: str) -> str: + """Compute SHA-256 hash of a file to detect exact duplicate clips or images.""" + if not path or not os.path.exists(path) or os.path.getsize(path) < 100: + return "" + h = hashlib.sha256() + try: + with open(path, "rb") as f: + while chunk := f.read(65536): + h.update(chunk) + return h.hexdigest() + except Exception: + return "" + class StockMediaFetcher: def __init__(self, pexels_key: Optional[str] = None, pixabay_key: Optional[str] = None): self.pexels_key = pexels_key self.pixabay_key = pixabay_key self.user_agent = "YouTubeFactory/2.0 (faceless-video-pipeline)" + self.used_media_urls = set() + self.used_file_hashes = set() + self.used_filenames = set() + self.used_youtube_ids = set() + + def reset_session(self): + """Reset used media set at the start of a new video job.""" + self.used_media_urls.clear() + self.used_file_hashes.clear() + self.used_filenames.clear() + self.used_youtube_ids.clear() + + def _query_youtube_broll(self, query: str, duration_sec: float, out_path: str, offset_sec: int = 15) -> Optional[str]: + """Search YouTube for relevant topic documentary/b-roll footage and download muted HD clip.""" + try: + clean_q = re.sub(r'[^A-Za-z0-9 ]', '', query).strip() + if not clean_q: + return None + search_target = f"ytsearch8:{clean_q} documentary footage" + logger.info(f"[STOCK:YOUTUBE] Searching YouTube for b-roll footage: '{clean_q}'...") + cmd_search = [ + "yt-dlp", search_target, + "--dump-json", "--flat-playlist", "--no-warnings" + ] + r = subprocess.run(cmd_search, capture_output=True, text=True, timeout=20) + lines = [l for l in r.stdout.strip().split("\n") if l] + for line in lines: + try: + d = json.loads(line) + vid_id = d.get("id") + dur = d.get("duration") or 60 + if not vid_id or vid_id in self.used_youtube_ids or (dur and dur < 12): + continue + + # Determine clean mid-video section avoiding intros/outros + start_t = min(max(15, offset_sec), max(5, int(dur) - int(duration_sec) - 5)) if dur else 15 + end_t = start_t + int(duration_sec) + 3 + + raw_tmp = out_path + f".raw_{vid_id}.mp4" + cmd_dl = [ + "yt-dlp", f"https://www.youtube.com/watch?v={vid_id}", + "--extractor-args", "youtube:player_client=android,web", + "-f", "22/18/best[height<=720]/best", + "--download-sections", f"*{start_t}-{end_t}", + "--force-keyframes-at-cuts", + "-o", raw_tmp, + "--no-playlist", + "--socket-timeout", "15" + ] + r_dl = subprocess.run(cmd_dl, capture_output=True, text=True, timeout=35) + if r_dl.returncode == 0 and os.path.exists(raw_tmp) and os.path.getsize(raw_tmp) > 5000: + # Normalize to 1280x720 25fps muted video (stripping any YouTube audio) + cmd_norm = [ + "ffmpeg", "-y", "-i", raw_tmp, + "-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:black,fps=25,format=yuv420p", + "-t", f"{duration_sec:.2f}", "-r", "25", + "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-pix_fmt", "yuv420p", + "-an", out_path + ] + r_norm = subprocess.run(cmd_norm, capture_output=True, text=True) + if os.path.exists(raw_tmp): + try: + os.remove(raw_tmp) + except OSError: + pass + + if r_norm.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 5000: + h = get_file_hash(out_path) + if h and h in self.used_file_hashes: + logger.warning(f"[STOCK:YOUTUBE] Duplicate content detected (hash {h[:8]}). Continuing search...") + try: + os.remove(out_path) + except OSError: + pass + continue + self.used_youtube_ids.add(vid_id) + self.used_media_urls.add(f"https://www.youtube.com/watch?v={vid_id}") + if h: + self.used_file_hashes.add(h) + logger.info(f"[STOCK:YOUTUBE] Extracted high quality muted YouTube b-roll clip from '{d.get('title')}'") + return out_path + except Exception as e: + logger.debug(f"[STOCK:YOUTUBE] Candidate parse error: {e}") + continue + except Exception as e: + logger.debug(f"[STOCK:YOUTUBE] Search failed for '{query}': {e}") + return None + + def query_person_image(self, person_name: str) -> Optional[str]: + """Fetch authentic historical portrait/photograph of a person from Wikipedia / Wikimedia Commons.""" + if not person_name or len(person_name.strip()) < 3: + return None + clean_name = person_name.strip() + logger.info(f"[STOCK:PERSON] Searching authentic archival portrait/photo for person: '{clean_name}'...") + + # 1. Query Wikipedia PageImage API (1920px curated lead portrait) + try: + encoded = urllib.parse.quote(clean_name) + url = f"https://en.wikipedia.org/w/api.php?action=query&titles={encoded}&prop=pageimages&pithumbsize=1920&format=json" + req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) + with urllib.request.urlopen(req, timeout=8) as resp: + data = json.loads(resp.read().decode("utf-8")) + pages = data.get("query", {}).get("pages", {}) + for pid, page in pages.items(): + if pid != "-1" and "thumbnail" in page: + src = page["thumbnail"].get("source") + if src and src not in self.used_media_urls: + return src + except Exception as e: + logger.debug(f"[STOCK:PERSON] Wikipedia query note for '{clean_name}': {e}") + + # 2. Query Wikimedia Commons for high-res archival portrait + try: + wiki_img = self._query_wikimedia(f"portrait {clean_name}", is_video=False) + if wiki_img: + return wiki_img + wiki_img2 = self._query_wikimedia(clean_name, is_video=False) + if wiki_img2: + return wiki_img2 + except Exception as e: + logger.debug(f"[STOCK:PERSON] Wikimedia query note for '{clean_name}': {e}") + + return None def _query_wikimedia(self, query: str, is_video: bool = False) -> Optional[str]: """Search Wikimedia Commons for high-resolution images or videos without API keys.""" @@ -79,7 +217,7 @@ class StockMediaFetcher: encoded_q = urllib.parse.quote(clean_q) api_url = ( f"https://commons.wikimedia.org/w/api.php?action=query&generator=search" - f"&gsrsearch={encoded_q}&gsrnamespace=6&gsrlimit=5&prop=imageinfo" + f"&gsrsearch={encoded_q}&gsrnamespace=6&gsrlimit=10&prop=imageinfo" f"&iiprop=url|mime|size&format=json" ) req = urllib.request.Request(api_url, headers={"User-Agent": self.user_agent}) @@ -91,6 +229,11 @@ class StockMediaFetcher: if not infos: continue url = infos[0].get("url", "") + if not url or url in self.used_media_urls: + continue + clean_name = os.path.basename(urllib.parse.urlparse(url).path) + if clean_name and clean_name in self.used_filenames: + continue mime = infos[0].get("mime", "").lower() if is_video and ("video" in mime or url.endswith((".webm", ".mp4", ".ogv"))): return url @@ -106,7 +249,7 @@ class StockMediaFetcher: return None try: clean_q = urllib.parse.quote(query) - url = f"https://api.pexels.com/videos/search?query={clean_q}&per_page=5&orientation=landscape" + url = f"https://api.pexels.com/videos/search?query={clean_q}&per_page=10&orientation=landscape" headers = {"Authorization": self.pexels_key, "User-Agent": self.user_agent} req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: @@ -114,12 +257,10 @@ class StockMediaFetcher: videos = data.get("videos", []) for v in videos: files = v.get("video_files", []) - # Prefer the smallest HD file (>=1280 wide) to keep downloads fast hd = [vf for vf in files if vf.get("width", 0) >= 1280 and vf.get("link")] - if hd: - return sorted(hd, key=lambda x: x["width"])[0]["link"] - if files and files[0].get("link"): - return files[0]["link"] + target = sorted(hd, key=lambda x: x["width"])[0]["link"] if hd else (files[0].get("link") if files else None) + if target and target not in self.used_media_urls: + return target except Exception as e: logger.debug(f"Pexels video query failed: {e}") return None @@ -130,14 +271,16 @@ class StockMediaFetcher: return None try: clean_q = urllib.parse.quote(query) - url = f"https://api.pexels.com/v1/search?query={clean_q}&per_page=5&orientation=landscape" + url = f"https://api.pexels.com/v1/search?query={clean_q}&per_page=10&orientation=landscape" headers = {"Authorization": self.pexels_key, "User-Agent": self.user_agent} req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) photos = data.get("photos", []) - if photos: - return photos[0].get("src", {}).get("large2x") or photos[0].get("src", {}).get("large") + for p in photos: + src = p.get("src", {}).get("large2x") or p.get("src", {}).get("large") + if src and src not in self.used_media_urls: + return src except Exception as e: logger.debug(f"Pexels photo query failed: {e}") return None @@ -148,7 +291,7 @@ class StockMediaFetcher: return None try: clean_q = urllib.parse.quote(query) - url = f"https://pixabay.com/api/videos/?key={self.pixabay_key}&q={clean_q}&per_page=5&safesearch=true" + url = f"https://pixabay.com/api/videos/?key={self.pixabay_key}&q={clean_q}&per_page=10&safesearch=true" req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) @@ -157,7 +300,9 @@ class StockMediaFetcher: for quality in ("large", "medium", "small"): v = vids.get(quality) if v and v.get("url") and v.get("width", 0) >= 640: - return v["url"] + target_url = v["url"] + if target_url not in self.used_media_urls: + return target_url except Exception as e: logger.debug(f"Pixabay video query failed: {e}") return None @@ -169,24 +314,40 @@ class StockMediaFetcher: try: clean_q = urllib.parse.quote(query) url = (f"https://pixabay.com/api/?key={self.pixabay_key}&q={clean_q}" - f"&image_type=photo&orientation=horizontal&per_page=5&safesearch=true") + f"&image_type=photo&orientation=horizontal&per_page=10&safesearch=true") req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) with urllib.request.urlopen(req, timeout=10) as resp: data = json.loads(resp.read().decode("utf-8")) hits = data.get("hits", []) - if hits: - return hits[0].get("largeImageURL") or hits[0].get("webformatURL") + for h in hits: + img_url = h.get("largeImageURL") or h.get("webformatURL") + if img_url and img_url not in self.used_media_urls: + return img_url except Exception as e: logger.debug(f"Pixabay photo query failed: {e}") return None def download_media(self, url: str, out_path: str) -> bool: - """Download remote media with retry and validation.""" + """Download remote media with retry, validation, and content hash duplicate prevention.""" try: req = urllib.request.Request(url, headers={"User-Agent": self.user_agent}) with urllib.request.urlopen(req, timeout=30) as resp, open(out_path, "wb") as f: f.write(resp.read()) if os.path.exists(out_path) and os.path.getsize(out_path) > 5000: + h = get_file_hash(out_path) + if h and h in self.used_file_hashes: + logger.warning(f"[STOCK] Duplicate asset content detected (hash {h[:8]}). Discarding duplicate...") + try: + os.remove(out_path) + except OSError: + pass + return False + self.used_media_urls.add(url) + if h: + self.used_file_hashes.add(h) + clean_name = os.path.basename(urllib.parse.urlparse(url).path) + if clean_name: + self.used_filenames.add(clean_name) return True except Exception as e: logger.debug(f"Download failed from {url}: {e}") @@ -197,22 +358,38 @@ class StockMediaFetcher: pass return False - def search_and_download(self, queries: Any, out_path: str, prefer_video: bool = False) -> Optional[str]: + def search_and_download(self, queries: Any, out_path: str, prefer_video: bool = False, duration_sec: float = 6.0) -> Optional[str]: """Search across stock engines with candidate query cascade and download asset.""" if isinstance(queries, str): candidate_list = [queries] else: candidate_list = list(queries) + expanded_queries = [] for raw_q in candidate_list: if not raw_q: continue - cleaned_q = clean_search_keywords(raw_q) - if not cleaned_q: - continue - + cleaned = clean_search_keywords(raw_q) + if cleaned and cleaned not in expanded_queries: + expanded_queries.append(cleaned) + words = cleaned.split() + if len(words) >= 2: + two_word = " ".join(words[:2]) + if two_word not in expanded_queries: + expanded_queries.append(two_word) + for w in words: + if len(w) > 3 and w not in expanded_queries: + expanded_queries.append(w) + + for cleaned_q in expanded_queries: logger.info(f"[STOCK] Searching stock media for: '{cleaned_q}' (video={prefer_video})...") if prefer_video: + # 1. First priority: Real YouTube documentary B-roll footage without audio + yt_clip = self._query_youtube_broll(cleaned_q, duration_sec, out_path) + if yt_clip and os.path.exists(yt_clip) and os.path.getsize(yt_clip) > 5000: + return yt_clip + + # 2. Stock APIs (Pexels, Pixabay, Wikimedia) for label, fn in (("Pexels", self._query_pexels_video), ("Pixabay", self._query_pixabay_video), ("Wikimedia", lambda q: self._query_wikimedia(q, is_video=True))): @@ -229,7 +406,7 @@ class StockMediaFetcher: logger.info(f"[STOCK] Downloaded {label} photo for '{cleaned_q}'") return out_path - logger.debug(f"[STOCK] No matching stock media found for queries: {candidate_list}") + logger.debug(f"[STOCK] No matching stock media found for queries: {expanded_queries}") return None # ─── Ken Burns Motion Engine ──────────────────────────────────────────────── @@ -326,10 +503,10 @@ def normalize_clip_to_duration(src_path: str, out_path: str, duration_sec: float def apply_text_overlay(video_path: str, out_mp4: str, text: str, duration_sec: float) -> str: """ - Render a broadcast-style lower-third text banner onto the video clip. - Only active if text is non-empty. + Render a broadcast-style lower-third text banner onto the video clip using UTF-8 textfile. + Supports full international characters (accents, umlauts, Spanish punctuation) without corruption. """ - clean_text = re.sub(r"[^A-Za-z0-9 \-\?\!\,\:\'\.\/]", "", text).strip() + clean_text = text.strip() if not clean_text: if video_path != out_mp4: import shutil @@ -348,14 +525,17 @@ def apply_text_overlay(video_path: str, out_mp4: str, text: str, duration_sec: f font_file = fp break + # Write clean UTF-8 text to a temporary textfile for FFmpeg drawtext to avoid character stripping + import tempfile + txt_fd, txt_path = tempfile.mkstemp(suffix=".txt", prefix="banner_text_") + with open(txt_fd, "w", encoding="utf-8") as f: + f.write(clean_text) + font_param = f":fontfile='{font_file}'" if font_file else "" - escaped_text = clean_text.replace("'", "'\\''").replace(":", "\\:") - - # Lower-third banner active between 0.8s and (duration - 0.8s) end_t = max(1.0, duration_sec - 0.8) enable_expr = f"between(t,0.8,{end_t:.2f})" vf = ( - f"drawtext=text='{escaped_text}'{font_param}:fontcolor=white:fontsize=36:" + f"drawtext=textfile='{txt_path}'{font_param}:fontcolor=white:fontsize=36:" f"box=1:boxcolor=black@0.65:boxborderw=14:x=60:y=h-th-60:enable='{enable_expr}'" ) @@ -366,6 +546,12 @@ def apply_text_overlay(video_path: str, out_mp4: str, text: str, duration_sec: f "-an", out_mp4 ] r = subprocess.run(cmd, capture_output=True, text=True) + if os.path.exists(txt_path): + try: + os.remove(txt_path) + except OSError: + pass + if r.returncode != 0: logger.warning(f"Text overlay rendering fallback: {r.stderr[-200:]}") if video_path != out_mp4: @@ -427,6 +613,7 @@ class VisualGen: def __init__(self, pcs: Dict[str, RemotePC], cfg: Any): self.pcs = pcs self.cfg = cfg + self.seen_clip_hashes = set() self.stock_fetcher = StockMediaFetcher( pexels_key=getattr(cfg, "pexels_api_key", None), pixabay_key=getattr(cfg, "pixabay_api_key", None) @@ -525,6 +712,8 @@ class VisualGen: def _try_ai_video(self, pc, prompt, seed, duration_sec, raw_path, final_clip_path, idx): """MiniMax H3 text-to-video clip, normalized to the segment duration.""" + if not pc or not pc.check(8188): + raise RuntimeError("NvidiaLLM ComfyUI is not reachable on port 8188") h3_length = min(h3_snap_length(duration_sec), self.H3_CLIP_CAP) logger.info(f"[VISUAL] Segment {idx+1}: MiniMax H3 AI video ({h3_length} frames)...") wf = self._h3_workflow(prompt, seed, self.cfg.clip_width, self.cfg.clip_height, h3_length) @@ -535,6 +724,8 @@ class VisualGen: def _try_ai_image(self, pc, prompt, seed, duration_sec, raw_path, final_clip_path, idx): """SDXL AI image animated with Ken Burns motion.""" + if not pc or not pc.check(8188): + raise RuntimeError("NvidiaLLM ComfyUI is not reachable on port 8188") logger.info(f"[VISUAL] Segment {idx+1}: SDXL AI image + Ken Burns...") pid = self._submit(pc.host, self._sdxl_workflow(prompt, seed)) outputs = self._wait_result(pc.host, pid, timeout_s=300) @@ -545,25 +736,56 @@ class VisualGen: """Stock video (looped/trimmed) or stock image (Ken Burns).""" kind = "video" if prefer_video else "image" logger.info(f"[VISUAL] Segment {idx+1}: stock {kind} for '{query}'...") - got = self.stock_fetcher.search_and_download(query, raw_path, prefer_video=prefer_video) + got = self.stock_fetcher.search_and_download(query, raw_path, prefer_video=prefer_video, duration_sec=duration_sec) if not got or not os.path.exists(got): raise RuntimeError(f"no stock {kind} found for '{query}'") if got.lower().endswith((".mp4", ".webm", ".mov")): return normalize_clip_to_duration(got, final_clip_path, duration_sec) return apply_ken_burns_effect(got, final_clip_path, duration_sec, motion_type=idx) + def _try_person(self, person_name: str, duration_sec: float, raw_path: str, final_clip_path: str, idx: int): + """Fetch authentic photo/portrait of historical person and animate with Ken Burns motion.""" + if not person_name: + raise RuntimeError("no person name provided") + img_url = self.stock_fetcher.query_person_image(person_name) + if not img_url: + raise RuntimeError(f"no authentic archival photo found for person '{person_name}'") + if not self.stock_fetcher.download_media(img_url, raw_path): + raise RuntimeError(f"failed to download person photo from {img_url}") + logger.info(f"[VISUAL] Segment {idx+1}: Using authentic historical photo/portrait for '{person_name}'") + return apply_ken_burns_effect(raw_path, final_clip_path, duration_sec, motion_type=idx) + def _generate_segment_visual(self, pc: RemotePC, seg: Dict[str, Any], idx: int, out_dir: str, duration_sec: float) -> str: """ Produce one visual for a segment as an MP4 of exactly duration_sec. "hybrid" rotates across 4 styles for maximum variety: - AI video (MiniMax H3) -> stock video -> AI image (Ken Burns) -> stock image (Ken Burns) - Each source falls back to the next one in the rotation on failure. + YouTube b-roll / stock video -> AI video -> stock image (Ken Burns) -> AI image (Ken Burns) + Each source falls back to the next one in the rotation on failure or duplicate collision. """ - prompt = (seg.get("visual_prompt") or seg.get("text", "")) + ", cinematic documentary style, 8k, detailed" + os.makedirs(out_dir, exist_ok=True) + CAMERA_ANGLES = [ + "wide establishing cinematic drone shot", + "dramatic macro close-up detail shot", + "dynamic low-angle heroic perspective", + "atmospheric high-angle bird's eye view", + "side profile tracking cinematic perspective", + "epic panoramic sweeping shot", + "dramatic cinematic lighting with volumetric shadows", + "slow dolly-in center focus shot" + ] + angle = CAMERA_ANGLES[idx % len(CAMERA_ANGLES)] + visual_anchor = ( + seg.get("visual_style_anchor") or + getattr(self.cfg, "visual_style_anchor", "") or + "cinematic documentary style, consistent warm color grading, 35mm film texture, photorealistic 8k" + ) + raw_prompt = seg.get("visual_prompt") or seg.get("text", "") + prompt = f"{raw_prompt}, {angle}, {visual_anchor}" primary_stock = seg.get("stock_query") or "" chapter_stock = seg.get("chapter_title") or "" topic_anchor = clean_search_keywords(getattr(self.cfg, "topic", "") or "") + person_name = (seg.get("person_name") or "").strip() # Build prioritized topical query cascade query_candidates = [] @@ -576,45 +798,73 @@ class VisualGen: if topic_anchor: query_candidates.append(topic_anchor) - seed = zlib.crc32(prompt.encode()) % (2**31) + import time + seed = zlib.crc32(f"{prompt}_{idx}_{time.time()}_{os.urandom(4)}".encode()) % (2**31) final_clip_path = os.path.join(out_dir, f"clip_{idx:03d}.mp4") raw_video = os.path.join(out_dir, f"raw_vid_{idx:03d}.mp4") raw_img = os.path.join(out_dir, f"raw_img_{idx:03d}.jpg") - strategy = getattr(self.cfg, "visual_strategy", "hybrid") - attempts = { - "ai_video": lambda: self._try_ai_video(pc, prompt, seed, duration_sec, raw_video, final_clip_path, idx), - "stock_video": lambda: self._try_stock(query_candidates, duration_sec, raw_video, final_clip_path, idx, prefer_video=True), - "ai_image": lambda: self._try_ai_image(pc, prompt, seed, duration_sec, raw_img, final_clip_path, idx), - "stock_image": lambda: self._try_stock(query_candidates, duration_sec, raw_img, final_clip_path, idx, prefer_video=False), - } - if strategy == "all_ai": - order = ["ai_video", "ai_image", "stock_video", "stock_image"] - elif strategy == "stock_focused": - order = ["stock_video", "stock_image", "ai_video", "ai_image"] - else: # hybrid — rotate the starting source so all 4 styles appear - base = ["ai_video", "stock_video", "ai_image", "stock_image"] - order = base[idx % 4:] + base[:idx % 4] - chosen_clip = None - for source in order: + + # 1. If talking about a specific person, prioritize authentic archival portrait/photo + if person_name: try: - chosen_clip = attempts[source]() - break + candidate = self._try_person(person_name, duration_sec, raw_img, final_clip_path, idx) + cand_hash = get_file_hash(candidate) + if cand_hash and cand_hash not in self.seen_clip_hashes: + chosen_clip = candidate except Exception as e: - logger.warning(f"[VISUAL] Segment {idx+1} source '{source}' failed: {e}") - errors.append(f"{source}: {e}") + logger.debug(f"[VISUAL] Person portrait search note for '{person_name}': {e}") if not chosen_clip: - # Emergency color background — pipeline never dies on visuals - logger.warning(f"[VISUAL] Segment {idx+1}: all sources failed, using color background") + strategy = getattr(self.cfg, "visual_strategy", "hybrid") + attempts = { + "stock_video": lambda: self._try_stock(query_candidates, duration_sec, raw_video, final_clip_path, idx, prefer_video=True), + "ai_video": lambda: self._try_ai_video(pc, prompt, seed, duration_sec, raw_video, final_clip_path, idx), + "stock_image": lambda: self._try_stock(query_candidates, duration_sec, raw_img, final_clip_path, idx, prefer_video=False), + "ai_image": lambda: self._try_ai_image(pc, prompt, seed, duration_sec, raw_img, final_clip_path, idx), + } + if strategy == "all_ai": + order = ["ai_video", "ai_image", "stock_video", "stock_image"] + elif strategy == "stock_focused": + order = ["stock_video", "stock_image", "ai_video", "ai_image"] + else: # hybrid — rotate the starting source so all 4 styles appear + base = ["stock_video", "ai_video", "stock_image", "ai_image"] + order = base[idx % 4:] + base[:idx % 4] + + errors = [] + for source in order: + try: + candidate = attempts[source]() + cand_hash = get_file_hash(candidate) + if cand_hash and cand_hash in self.seen_clip_hashes: + logger.warning(f"[VISUAL] Segment {idx+1}: Collision on source '{source}' (hash {cand_hash[:8]}). Trying next source...") + continue + chosen_clip = candidate + break + except Exception as e: + logger.warning(f"[VISUAL] Segment {idx+1} source '{source}' failed: {e}") + errors.append(f"{source}: {e}") + + if not chosen_clip: + # Fallback to unique animated gradient motion card with Ken Burns motion + logger.warning(f"[VISUAL] Segment {idx+1}: Standard sources failed. Generating standalone gradient visual...") + c1 = ["0x0a1128", "0x1b1b2f", "0x162447", "0x1f4068", "0x111d5e", "0x0f3460", "0x1e152a", "0x0c2461"][idx % 8] + c2 = ["0x1a2a6c", "0x2c3e50", "0x20002c", "0x34495e", "0x1e3c72", "0x2a5298", "0x3c1053", "0x1b1464"][idx % 8] subprocess.run([ - "ffmpeg", "-y", "-f", "lavfi", "-i", f"color=c=0x0a1128:s=1280x720:d={duration_sec:.2f}", + "ffmpeg", "-y", "-f", "lavfi", + "-i", f"gradients=s=1280x720:c0={c1}:c1={c2}:d={duration_sec:.2f}:speed=0.005", + "-vf", "noise=alls=12:allf=t+u,vignette=PI/4,format=yuv420p", "-r", "25", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", "-an", final_clip_path ], check=True, capture_output=True) chosen_clip = final_clip_path + # Record fingerprint for zero duplicate guarantee + clip_hash = get_file_hash(chosen_clip) + if clip_hash: + self.seen_clip_hashes.add(clip_hash) + # Apply dynamic 35mm film grain filter overlay if scene depicts past/historical events if is_historical_scene(seg, topic=getattr(self.cfg, "topic", "")): aged_tmp = os.path.join(out_dir, f"aged_{idx:03d}.mp4") @@ -632,6 +882,8 @@ class VisualGen: Returns: {"en": List[str], "es": List[str]} If on-screen text is present in a segment, generates distinct English and Spanish clips. """ + self.stock_fetcher.reset_session() + self.seen_clip_hashes.clear() segs = script.get("script_segments", []) logger.info(f"[VISUAL] Producing {len(segs)} video clips (bilingual text-matching enabled)...") pc = self.pcs.get("nvidiam") diff --git a/youtube_factory.py b/youtube_factory.py index 2c1290a..d534e49 100755 --- a/youtube_factory.py +++ b/youtube_factory.py @@ -377,6 +377,9 @@ def main(): parser.add_argument("--topic", "-t", help="Single video topic to generate") parser.add_argument("--duration", "-d", type=int, help="Target duration in seconds") parser.add_argument("--batch", "-b", nargs="+", help="List of video topics to process sequentially") + parser.add_argument("--auto", action="store_true", help="Autonomous mode: research top-performing viral YouTube explainer topics and produce our own versions") + parser.add_argument("--auto-count", type=int, default=3, help="Number of viral topics to auto-research and produce (default 3)") + parser.add_argument("--auto-niche", default="", help="Specific niche for auto-research (e.g. ancient history, astrophysics, tech, mysteries)") parser.add_argument("--batch-file", "-f", help="Text file containing one topic per line") parser.add_argument("--trending", action="store_true", help="Fetch real-time trending topics and generate videos") parser.add_argument("--trending-niche", help="Trending niche (history, space, science, tech, mysteries, business)") @@ -427,6 +430,16 @@ def main(): logger.info(f"Retried {retried} failed jobs.") elif args.daemon: factory.run_daemon() + elif args.auto: + from pipeline.research import ViralTopicResearcher + researcher = ViralTopicResearcher(factory.pcs.get("amdllm")) + discovered = researcher.discover_popular_trending_topics(niche=args.auto_niche, count=args.auto_count) + logger.info(f"🚀 Auto-Research discovered {len(discovered)} high-velocity viral explainer topics!") + for item in discovered: + t = item.get("topic") if isinstance(item, dict) else str(item) + curiosity = item.get("curiosity_angle", "") if isinstance(item, dict) else "" + logger.info(f"🎬 Starting autonomous production for viral topic: '{t}' (Curiosity Angle: {curiosity})") + factory.run_video_pipeline(t, duration=args.duration, dry_run=args.dry_run) elif args.trending: fetcher = TrendingFetcher(factory.pcs.get("amdllm")) topics = fetcher.get_trending_topics(niche=args.trending_niche, count=args.trending_count)