'
return html, css, 3.6, None
def sc_ranking(ins,p,i):
bars=(ins.get("bars") or [])[:4]
maxv=max([b[1] for b in bars] or [1])
rows=""
for k,b in enumerate(bars):
w=max(10,int(100*b[1]/maxv))
rows+=('
')
return html, css, 3.2, None
# ---------- plan des scènes ----------
def plan_scenes(ins,p):
photos=[it for it in (ins.get("list") or []) if it.get("image_data")]
if not photos and (ins.get("product") or {}).get("image_data"):
pr=ins["product"]
photos=[{"title":pr.get("title",""),"subtitle":pr.get("subtitle",""),
"price":pr.get("price",""),"image_data":pr["image_data"]}]
has_pct=parse_pct(ins.get("headline")) is not None
genre=ins.get("genre","hero")
photo_led=genre in ("spotlight","list","grid") and photos
def stat_block():
out=[]
if has_pct: out.append(("donut",sc_donut))
else: out.append(("stat",sc_stat))
if ins.get("tiles"): out.append(("tiles",sc_tiles))
if ins.get("bars") and len(ins["bars"])>=3: out.append(("ranking",sc_ranking))
return out
has_quote=len(str(ins.get("fact") or ""))>=40
# budget photos AVANT construction (les numéros « k / n » restent exacts) :
# base ≈ intro 2.8 + stat/donut 4.3 + tuiles 3.6 + palmarès 4.6 + quote 4.5 + outro 3.2
base_est=2.8+4.4+ (3.6 if ins.get("tiles") else 0) + (4.6 if (ins.get("bars") and len(ins["bars"])>=3) else 0) \
+ (4.5 if has_quote else 0) + 3.2
maxp=4 if photo_led else 3
nphotos=max(0,min(len(photos),maxp,int((34.0-base_est)//3.0)))
tot_p=nphotos
photo_scenes=[("photo%d"%k,(lambda it,k2: (lambda ins2,p2,i: sc_photo(it,p2,i,k2,tot_p)))(it,k))
for k,it in enumerate(photos[:nphotos])]
builders=[("intro",sc_intro)]
if photo_led:
builders+=photo_scenes+stat_block()
else:
builders+=stat_block()+photo_scenes
if has_quote: builders.append(("quote",sc_quote))
builders.append(("outro",sc_outro))
scenes=[]
for i,(name,fn) in enumerate(builders):
html,css,dur,hook=fn(ins,p,i)
scenes.append(dict(name=name,html=html,css=css,dur=dur,hook=hook,i=i))
return scenes
# ---------- assemblage HTML ----------
def build_html(ins):
p=pal(ins["site"])
scenes=plan_scenes(ins,p)
tot=round(sum(s["dur"] for s in scenes),2)
shell="""
*{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased}
html,body{width:1080px;height:__H__px;overflow:hidden;font-family:__FONT__;color:#fff;background:__G2__}
.stage{position:relative;width:1080px;height:__H__px;overflow:hidden;
background:radial-gradient(1300px 900px at 78% -5%, __A__50,transparent 55%),
radial-gradient(1100px 1000px at -12% 105%, __B__36,transparent 55%),
linear-gradient(160deg,__G1__,__G2__);background-size:150% 150%;animation:drift 18s ease-in-out infinite alternate}
@keyframes drift{0%{background-position:0% 0%}100%{background-position:100% 100%}}
.vign{position:absolute;inset:0;pointer-events:none;background:radial-gradient(120% 90% at 50% 45%, transparent 55%, rgba(0,0,0,.5) 100%);z-index:3}
.eyeb{position:absolute;top:104px;left:90px;right:250px;display:flex;align-items:center;gap:14px;font-weight:800;
letter-spacing:.16em;font-size:25px;text-transform:uppercase;color:__A__;z-index:8;opacity:0;animation:fu .7s ease .2s forwards}
.eyeb .dot{width:46px;height:6px;border-radius:3px;background:__A__;flex:0 0 46px}
.kabadge{position:absolute;top:92px;right:90px;background:__A__;color:#0d0d0d;font-weight:900;font-size:48px;
padding:10px 24px;border-radius:20px;transform:rotate(-4deg) scale(.4);opacity:0;z-index:8;
animation:pop .7s cubic-bezier(.2,1.4,.4,1) .35s forwards;box-shadow:0 16px 46px __A__66}
.wm{position:absolute;left:90px;bottom:96px;font-size:27px;font-weight:800;color:#ffffff9a;z-index:8}
.wm b{color:__A__}
.prog{position:absolute;left:0;bottom:0;height:9px;background:linear-gradient(90deg,__A__,__B__);width:0;z-index:9;
animation:prog __TOT__s linear .1s forwards;box-shadow:0 0 20px __A__}
@keyframes prog{to{width:100%}}
@keyframes fu{to{opacity:1;transform:translateY(0)}}
@keyframes pop{to{opacity:1;transform:rotate(-4deg) scale(1)}}
.scene{position:absolute;inset:0;padding:220px 90px 210px;display:flex;flex-direction:column;z-index:4;
opacity:0;pointer-events:none;transform:translateY(46px) scale(.985);
transition:opacity .5s ease,transform .62s cubic-bezier(.2,.8,.2,1)}
.scene.on{opacity:1;transform:none}
.scene.off{opacity:0;transform:translateY(-46px) scale(1.012)}
.fadeout{position:absolute;inset:0;background:#000;opacity:0;z-index:20;pointer-events:none;
animation:fo .7s ease __FOT__s forwards}
@keyframes fo{to{opacity:1}}
"""
shell=(shell.replace("__H__",str(H)).replace("__FONT__",FONT).replace("__A__",p["a"])
.replace("__B__",p["b"]).replace("__G1__",p["g1"]).replace("__G2__",p["g2"])
.replace("__TOT__",str(tot)).replace("__FOT__",str(round(tot-0.7,2))))
css_all=[shell]; body=[]; sc_list=[]; hooks={}
for s in scenes:
sid="sc"+str(s["i"])
css=(s["css"].replace(".scene.on #S_","#"+sid+".on").replace("#S_","#"+sid).replace("#S","#"+sid)
.replace("__A__",p["a"]).replace("__B__",p["b"]).replace("__G2__",p["g2"]))
# les hooks référencent des ids préfixés S_ -> remplacer par l'id de scène
html=s["html"].replace('id="S_','id="'+sid+'_')
hook=(s["hook"] or "").replace("'S_","'"+sid+"_")
css_all.append(css)
body.append('
'+html+'
')
sc_list.append([sid, s["dur"]])
if hook: hooks[sid]=hook
hooks_js="{"+",".join('"'+k+'":function(){'+v+'}' for k,v in hooks.items())+"}"
script="""""".replace("__SC__",json.dumps(sc_list)).replace("__HOOKS__",hooks_js)
eyeb='
'+script+"")
return html, tot
# ---------- enregistrement + musique + mux ----------
def record_webm(html_path, out_webm, tmpdir, dur):
from playwright.sync_api import sync_playwright
with sync_playwright() as pw:
b=pw.chromium.launch(args=["--no-sandbox"])
ctx=b.new_context(viewport={"width":1080,"height":H}, device_scale_factor=2,
record_video_dir=tmpdir, record_video_size={"width":1080,"height":H})
pg=ctx.new_page()
pg.goto("file://"+html_path)
pg.wait_for_timeout(int((dur+0.3)*1000))
vid=pg.video
ctx.close(); b.close()
path=vid.path()
os.replace(path, out_webm)
def render(ins, out_dir):
os.makedirs(out_dir,exist_ok=True)
prod=ins.get('product')
if prod and prod.get('image') and not prod.get('image_data'):
du=data_uri(prod['image'])
if du: prod['image_data']=du
for it in (ins.get('list') or []):
if it.get('image') and not it.get('image_data'):
du=data_uri(it['image'])
if du: it['image_data']=du
key=hashlib.md5((ins['site']+str(ins.get('insight_id',''))+str(ins.get('genre',''))+'reelv4').encode()).hexdigest()[:8]
tmp=os.path.join(out_dir,"_reeltmp_"+key); os.makedirs(tmp,exist_ok=True)
html,dur=build_html(ins)
html_path=os.path.join(tmp,"reel.html"); open(html_path,"w",encoding="utf-8").write(html)
webm=os.path.join(tmp,"reel.webm"); record_webm(html_path,webm,tmp,dur)
# musique : style en rotation via variant, contenu par graine (unique)
variant=ins.get('variant')
seed=ins['site']+"|"+str(ins.get('insight_id',''))+"|"+str(variant)+"|"+datetime.date.today().isoformat()
style=STYLES_ORDER[int(variant)%len(STYLES_ORDER)] if variant is not None else None
wav=os.path.join(tmp,"bed.wav")
used=music_engine.compose(dur+0.2, seed, wav, style)
sys.stderr.write("musique: style=%s seed=%s dur=%.1fs\n"%(used,seed,dur))
out=os.path.join(out_dir,f"reel-{ins['site']}-{ins.get('insight_id','x')}.mp4")
subprocess.run([FFMPEG,"-y","-i",webm,"-i",wav,
"-filter_complex","[0:v]scale=1080:"+str(H)+":force_original_aspect_ratio=increase,crop=1080:"+str(H)+",fps=30,format=yuv420p[v];"
"[1:a]acompressor=threshold=-16dB:ratio=3:attack=8:release=180,alimiter=limit=0.95[a]",
"-map","[v]","-map","[a]","-c:v","libx264","-preset","slow","-crf","18",
"-c:a","aac","-b:a","192k","-pix_fmt","yuv420p","-movflags","+faststart","-shortest",out],
capture_output=True)
for f in glob.glob(os.path.join(tmp,"*")):
try: os.remove(f)
except: pass
try: os.rmdir(tmp)
except: pass
if not os.path.exists(out): raise SystemExit("mp4 non produit")
return out
if __name__=="__main__":
ins=json.load(sys.stdin)
out_dir=sys.argv[1] if len(sys.argv)>1 else "/tmp/ka-reels"
print(render(ins,out_dir))