add promo-video generator script (Playwright record → ffmpeg mp4)
scripts/promo.js builds an animated portrait promo from the store screenshots (branded slides + Persian captions + Ken Burns), records it with Playwright, and is encoded to store-assets/promo.mp4 via the system ffmpeg. Output dir gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
// Build an animated portrait promo from the captured screenshots and record it
|
||||
// to webm with Playwright. (ffmpeg then encodes it to mp4 — see the run step.)
|
||||
const { chromium } = require("playwright");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ASSETS = path.join(__dirname, "..", "store-assets");
|
||||
const OUT = path.join(__dirname, "promo");
|
||||
fs.mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const b64 = (f) => "data:image/png;base64," + fs.readFileSync(path.join(ASSETS, f)).toString("base64");
|
||||
const icon = b64("icon-512.png");
|
||||
|
||||
// type: intro | slide | outro
|
||||
const SLIDES = [
|
||||
{ type: "intro", title: "برگ وسط", sub: "بازی حکم آنلاین ایرانی" },
|
||||
{ type: "slide", img: b64("01-home.png"), cap: "سه حالت بازی در یک اپ" },
|
||||
{ type: "slide", img: b64("06-game.png"), cap: "حکمِ واقعی، کارتهای زیبا" },
|
||||
{ type: "slide", img: b64("02-leaderboard.png"), cap: "در لیگها بالا برو و رکورد بزن" },
|
||||
{ type: "slide", img: b64("04-shop.png"), cap: "آواتار و آیتمهای ویژه" },
|
||||
{ type: "slide", img: b64("03-achievements.png"), cap: "دستاوردها و جایزهی روزانه" },
|
||||
{ type: "slide", img: b64("05-profile.png"), cap: "پروفایل کامل خودت را بساز" },
|
||||
{ type: "outro", title: "همین حالا رایگان نصب کن!", sub: "برگ وسط", icon: true },
|
||||
];
|
||||
const DUR = 2600; // ms per slide
|
||||
const FADE = 700;
|
||||
|
||||
const slideHtml = (s, i) => {
|
||||
if (s.type === "intro" || s.type === "outro")
|
||||
return `<div class="slide center" data-i="${i}">
|
||||
<img class="icon" src="${icon}"/>
|
||||
<div class="title">${s.title}</div>
|
||||
<div class="sub">${s.sub}</div>
|
||||
</div>`;
|
||||
return `<div class="slide" data-i="${i}">
|
||||
<div class="brand">برگ وسط</div>
|
||||
<div class="phonewrap"><img class="phone" src="${s.img}"/></div>
|
||||
<div class="cap">${s.cap}</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
const html = `<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">
|
||||
<style>
|
||||
* { margin:0; padding:0; box-sizing:border-box; font-family: Tahoma, "Segoe UI", sans-serif; }
|
||||
html,body { width:1080px; height:1920px; overflow:hidden; }
|
||||
.stage { position:relative; width:1080px; height:1920px;
|
||||
background: radial-gradient(120% 80% at 50% 0%, #123 0%, #0e1c3f 38%, #060c1f 100%); }
|
||||
.stage::after { content:""; position:absolute; inset:0;
|
||||
background: radial-gradient(60% 35% at 50% 42%, rgba(212,175,55,.16), transparent 70%); }
|
||||
.slide { position:absolute; inset:0; opacity:0; transition: opacity ${FADE}ms ease;
|
||||
display:flex; flex-direction:column; align-items:center; padding:70px 60px; }
|
||||
.slide.active { opacity:1; }
|
||||
.brand { color:#d4af37; font-size:46px; font-weight:800; letter-spacing:1px;
|
||||
opacity:.85; margin-bottom:18px; }
|
||||
.phonewrap { flex:1; display:flex; align-items:center; justify-content:center; min-height:0; }
|
||||
.phone { max-height:1300px; max-width:660px; border-radius:42px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,.55), 0 0 0 2px rgba(212,175,55,.25);
|
||||
transform: scale(1.0); transition: transform ${DUR + FADE}ms ease-out; }
|
||||
.slide.active .phone { transform: scale(1.07); }
|
||||
.cap { color:#fdf6e3; font-size:58px; font-weight:800; text-align:center; line-height:1.5;
|
||||
text-shadow:0 3px 18px rgba(0,0,0,.6); margin-top:34px;
|
||||
transform: translateY(24px); opacity:0; transition: all ${FADE}ms ease ${FADE / 2}ms; }
|
||||
.slide.active .cap { transform: translateY(0); opacity:1; }
|
||||
.center { justify-content:center; gap:40px; }
|
||||
.center .icon { width:360px; height:360px; border-radius:80px;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.5); transform:scale(.8); transition:transform 900ms ease; }
|
||||
.center.active .icon { transform:scale(1); }
|
||||
.center .title { color:#f1da8a; font-size:96px; font-weight:800; text-align:center;
|
||||
text-shadow:0 4px 24px rgba(0,0,0,.5); }
|
||||
.center .sub { color:#cbd5e1; font-size:50px; font-weight:600; text-align:center; }
|
||||
/* intro/outro icon hidden when not requested */
|
||||
</style></head><body>
|
||||
<div class="stage">${SLIDES.map(slideHtml).join("")}</div>
|
||||
<script>
|
||||
const slides=[...document.querySelectorAll('.slide')];
|
||||
const DUR=${DUR};
|
||||
let i=0;
|
||||
function show(n){ slides.forEach((s,k)=>s.classList.toggle('active',k===n)); }
|
||||
show(0);
|
||||
const timer=setInterval(()=>{ i++; if(i>=slides.length){ clearInterval(timer); window.__done=true; return;} show(i); }, DUR);
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
(async () => {
|
||||
const total = SLIDES.length * DUR + 1200;
|
||||
const browser = await chromium.launch({ channel: "chrome" });
|
||||
const ctx = await browser.newContext({
|
||||
viewport: { width: 1080, height: 1920 },
|
||||
deviceScaleFactor: 1,
|
||||
recordVideo: { dir: OUT, size: { width: 1080, height: 1920 } },
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
await page.setContent(html, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(total);
|
||||
const video = page.video();
|
||||
await ctx.close(); // finalizes the recording
|
||||
const p = await video.path();
|
||||
await browser.close();
|
||||
console.log("VIDEO:" + p);
|
||||
})().catch((e) => { console.error("FATAL", e); process.exit(1); });
|
||||
Reference in New Issue
Block a user