add promo-video generator script (Playwright record → ffmpeg mp4)
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 52s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m7s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 59s

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:
soroush.asadi
2026-06-12 14:08:33 +03:30
parent 66c83991d4
commit bf5b07962d
2 changed files with 101 additions and 0 deletions
+1
View File
@@ -64,3 +64,4 @@ next-env.d.ts
# store screenshot artifacts # store screenshot artifacts
/scripts/shots/ /scripts/shots/
/store-assets/ /store-assets/
/scripts/promo/
+100
View File
@@ -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); });