Commit c1a1af60 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Pre-reset backup: promo videos, test scripts, screenshots

parent 1a19a25c
import puppeteer from 'puppeteer';
import { mkdir } from 'fs/promises';
const BASE_URL = 'https://el3ab-player.caprover.al-arcade.com';
const AUTH_URL = 'https://safe-supabase-kong.caprover.al-arcade.com/auth/v1';
const API_URL = `${BASE_URL}/api`;
const ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84';
const PLAYER1 = { email: 'testplayer1@el3ab.com', password: 'ChessTest1!', id: 'd14a27c6-2448-4319-a775-a9f94dd6de87', name: 'testplayer1', displayName: 'لاعب اختبار' };
const PLAYER2 = { email: 'test@el3ab.com', password: 'ChessTest2!', id: '37f947c0-e10b-4bef-88ec-c0aa0e8b8034', name: 'TestPlayer', displayName: 'TestPlayer' };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SHOT_DIR = '/Users/mahmoudaglan/el3ab-player/screenshots/chess-test';
async function getToken(email, password) {
const res = await fetch(`${AUTH_URL}/token?grant_type=password`, {
method: 'POST',
headers: { 'apikey': ANON_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!data.access_token) throw new Error(`Login failed: ${JSON.stringify(data)}`);
return { token: data.access_token, refreshToken: data.refresh_token };
}
async function setupPage(browser, player, auth) {
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
page.on('console', msg => {
if (msg.type() === 'error' && !msg.text().includes('favicon'))
console.log(` [${player.name} ERR] ${msg.text().slice(0, 120)}`);
});
await page.goto(BASE_URL, { waitUntil: 'networkidle2', timeout: 30000 });
await page.evaluate((token, refreshToken, userId, displayName, username) => {
localStorage.setItem('el3ab_state', JSON.stringify({
auth: { token, refreshToken, userId },
player: { id: userId, display_name: displayName, username, level: 5, coins: 1000, xp: 500 },
activeWorld: 'play', audioEnabled: false, language: 'ar'
}));
}, auth.token, auth.refreshToken, player.id, player.displayName, player.name);
await page.reload({ waitUntil: 'networkidle2', timeout: 30000 });
await wait(2000);
return page;
}
async function navigateToQueue(page, playerName) {
// 1. Click chess tile
console.log(` [${playerName}] Clicking chess tile...`);
await page.evaluate(() => document.querySelector('[data-game="chess"]').click());
await wait(1000);
// 2. Click "أونلاين" (#btn-multi) in the popup menu
console.log(` [${playerName}] Clicking أونلاين...`);
await page.evaluate(() => document.querySelector('#btn-multi').click());
await wait(1000);
// 3. Now on time-select — click a time control to start search
console.log(` [${playerName}] Clicking time control...`);
const clicked = await page.evaluate(() => {
// Look for time control buttons in the scene
const allEls = document.querySelectorAll('button, [class*="btn"], [class*="time"], [role="button"]');
for (const el of allEls) {
const t = el.textContent.trim();
if (t.includes('10+0') || t.includes('10 + 0') || t.includes('سريعة') || t.includes('Rapid') || t.includes('10')) {
el.click(); return t;
}
}
// Fallback: look for any button in the current scene
const scene = document.querySelector('.scene');
if (scene) {
const btns = scene.querySelectorAll('button, [style*="cursor: pointer"]');
for (const btn of btns) {
if (btn.id !== 'menu-close') { btn.click(); return btn.textContent.slice(0,30); }
}
}
return null;
});
console.log(` [${playerName}] Clicked: "${clicked}"`);
await wait(500);
}
async function run() {
await mkdir(SHOT_DIR, { recursive: true });
console.log('=== Chess Multiplayer UI Test ===\n');
console.log('1. Authenticating...');
const p1Auth = await getToken(PLAYER1.email, PLAYER1.password);
const p2Auth = await getToken(PLAYER2.email, PLAYER2.password);
console.log(' ✓ Both authenticated');
console.log('\n2. Launching browser...');
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors']
});
const page1 = await setupPage(browser, PLAYER1, p1Auth);
const page2 = await setupPage(browser, PLAYER2, p2Auth);
console.log(' ✓ Both pages loaded');
// Navigate Player 1 to queue
console.log('\n3. Player 1 navigates to matchmaking...');
await navigateToQueue(page1, PLAYER1.name);
await page1.screenshot({ path: `${SHOT_DIR}/01-p1-queue.png` });
// Navigate Player 2 to queue
console.log('\n4. Player 2 navigates to matchmaking...');
await navigateToQueue(page2, PLAYER2.name);
await page2.screenshot({ path: `${SHOT_DIR}/02-p2-queue.png` });
// Wait for match — polling happens every 3s
console.log('\n5. Waiting for match (8s)...');
await wait(8000);
await page1.screenshot({ path: `${SHOT_DIR}/03-p1-matched.png` });
await page2.screenshot({ path: `${SHOT_DIR}/03-p2-matched.png` });
// Check if chess board appeared
const p1Board = await page1.$('canvas');
const p2Board = await page2.$('canvas');
console.log(` P1 has chess board: ${!!p1Board}`);
console.log(` P2 has chess board: ${!!p2Board}`);
if (p1Board && p2Board) {
console.log(' ✓ BOTH PLAYERS IN GAME!');
await wait(2000);
await page1.screenshot({ path: `${SHOT_DIR}/04-p1-board.png` });
await page2.screenshot({ path: `${SHOT_DIR}/04-p2-board.png` });
// Verify they're not playing against themselves — check opponent name
const p1OpponentName = await page1.evaluate(() => {
const el = document.querySelector('#opponent-name');
return el ? el.textContent : 'not found';
});
const p2OpponentName = await page2.evaluate(() => {
const el = document.querySelector('#opponent-name');
return el ? el.textContent : 'not found';
});
console.log(` P1 opponent: "${p1OpponentName}"`);
console.log(` P2 opponent: "${p2OpponentName}"`);
// Wait for syncing to happen (poll cycle)
await wait(5000);
await page1.screenshot({ path: `${SHOT_DIR}/05-p1-playing.png` });
await page2.screenshot({ path: `${SHOT_DIR}/05-p2-playing.png` });
} else {
// Debug: what page are they on?
const p1Text = await page1.evaluate(() => document.body.innerText.slice(0, 200));
const p2Text = await page2.evaluate(() => document.body.innerText.slice(0, 200));
console.log(` P1 page: ${p1Text.slice(0, 100)}`);
console.log(` P2 page: ${p2Text.slice(0, 100)}`);
}
await browser.close();
console.log(`\n Screenshots saved to: ${SHOT_DIR}/`);
console.log('\n=== Done ===');
}
run().catch(err => {
console.error('\n✗ FATAL ERROR:', err.message);
process.exit(1);
});
import puppeteer from 'puppeteer';
const BASE_URL = 'https://el3ab-player.caprover.al-arcade.com';
const API_URL = `${BASE_URL}/api`;
const AUTH_URL = 'https://safe-supabase-kong.caprover.al-arcade.com/auth/v1';
const ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84';
const PLAYER1 = { email: 'testplayer1@el3ab.com', password: 'ChessTest1!', id: 'd14a27c6-2448-4319-a775-a9f94dd6de87', name: 'Player1' };
const PLAYER2 = { email: 'test@el3ab.com', password: 'ChessTest2!', id: '37f947c0-e10b-4bef-88ec-c0aa0e8b8034', name: 'Player2' };
const wait = ms => new Promise(r => setTimeout(r, ms));
async function getToken(email, password) {
const res = await fetch(`${AUTH_URL}/token?grant_type=password`, {
method: 'POST',
headers: { 'apikey': ANON_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await res.json();
if (!data.access_token) throw new Error(`Login failed for ${email}: ${JSON.stringify(data)}`);
return { token: data.access_token, refreshToken: data.refresh_token };
}
async function apiCall(token, endpoint, body) {
const res = await fetch(`${API_URL}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify(body)
});
return res.json();
}
async function run() {
console.log('=== Chess Multiplayer E2E Test ===\n');
// Step 1: Get fresh tokens
console.log('1. Authenticating players...');
const p1Auth = await getToken(PLAYER1.email, PLAYER1.password);
const p2Auth = await getToken(PLAYER2.email, PLAYER2.password);
console.log(` ✓ ${PLAYER1.name} authenticated (${PLAYER1.id.slice(0,8)})`);
console.log(` ✓ ${PLAYER2.name} authenticated (${PLAYER2.id.slice(0,8)})`);
// Step 2: Clean any existing queue entries
console.log('\n2. Cleaning queue...');
await apiCall(p1Auth.token, 'matchmaking.php', { action: 'dequeue' });
await apiCall(p2Auth.token, 'matchmaking.php', { action: 'dequeue' });
console.log(' ✓ Queue cleaned');
// Step 3: Player 1 queues
console.log('\n3. Player 1 queues for chess (rapid_10_0)...');
const q1 = await apiCall(p1Auth.token, 'matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' });
console.log(` Response: ${JSON.stringify(q1)}`);
if (q1.match_id) {
console.log(' ✗ ERROR: Player 1 got instant match (should be queued)');
return;
}
if (!q1.queued) {
console.log(` ✗ ERROR: Expected queued=true, got: ${JSON.stringify(q1)}`);
return;
}
console.log(' ✓ Player 1 is waiting in queue');
// Step 4: Player 2 queues — should match with Player 1
console.log('\n4. Player 2 queues for chess (rapid_10_0)...');
const q2 = await apiCall(p2Auth.token, 'matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' });
console.log(` Response: ${JSON.stringify(q2)}`);
if (!q2.match_id) {
console.log(' ✗ ERROR: Player 2 should have matched with Player 1');
return;
}
console.log(` ✓ Match created: ${q2.match_id}`);
console.log(` ✓ Player 2 color: ${q2.color}, opponent: ${q2.opponent_id?.slice(0,8)}`);
const matchId = q2.match_id;
// Step 5: Player 1 polls for match
console.log('\n5. Player 1 checks status...');
const s1 = await apiCall(p1Auth.token, 'matchmaking.php', { action: 'status' });
console.log(` Response: ${JSON.stringify(s1)}`);
if (!s1.match_id) {
console.log(' ✗ ERROR: Player 1 should see the match');
return;
}
console.log(` ✓ Player 1 found match: ${s1.match_id}, color: ${s1.color}`);
// Step 6: Both players GET the match
console.log('\n6. Both players fetch match state...');
const g1 = await apiCall(p1Auth.token, 'game.php', { action: 'get', match_id: matchId });
const g2 = await apiCall(p2Auth.token, 'game.php', { action: 'get', match_id: matchId });
console.log(` P1 sees: status=${g1.status}, move_count=${g1.move_count}, fen=${g1.current_fen?.slice(0,20)}...`);
console.log(` P2 sees: status=${g2.status}, move_count=${g2.move_count}, fen=${g2.current_fen?.slice(0,20)}...`);
if (g1.status !== 'in_progress' || g2.status !== 'in_progress') {
console.log(' ✗ ERROR: Match should be in_progress');
return;
}
console.log(' ✓ Both players see the match in_progress');
// Determine who is white
const whitePlayer = g1.white_player_id === PLAYER1.id ? { auth: p1Auth, name: PLAYER1.name } : { auth: p2Auth, name: PLAYER2.name };
const blackPlayer = g1.white_player_id === PLAYER1.id ? { auth: p2Auth, name: PLAYER2.name } : { auth: p1Auth, name: PLAYER1.name };
console.log(` White: ${whitePlayer.name}, Black: ${blackPlayer.name}`);
// Step 7: White makes move e2-e4
console.log('\n7. White plays e2-e4...');
const m1 = await apiCall(whitePlayer.auth.token, 'game.php', {
action: 'move',
match_id: matchId,
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
move: JSON.stringify([{ from: 'e2', to: 'e4', san: 'e4', color: 'w' }]),
move_count: 1,
white_time_remaining_ms: 598000,
black_time_remaining_ms: 600000,
game_state: JSON.stringify({ last_move: { from: 'e2', to: 'e4', san: 'e4' } })
});
console.log(` Move response: ${JSON.stringify(m1)}`);
if (m1.error) {
console.log(` ✗ ERROR: Move failed: ${m1.error}`);
return;
}
console.log(' ✓ Move sent successfully');
// Step 8: Black polls and sees the move
console.log('\n8. Black polls for opponent move...');
await wait(500);
const poll1 = await apiCall(blackPlayer.auth.token, 'game.php', { action: 'get', match_id: matchId });
console.log(` Black sees: move_count=${poll1.move_count}, fen=${poll1.current_fen?.slice(0,40)}`);
if (poll1.move_count !== 1) {
console.log(` ERROR: Expected move_count=1, got ${poll1.move_count}`);
console.log(` Full response: ${JSON.stringify(poll1).slice(0,300)}`);
return;
}
const gs1 = typeof poll1.game_state === 'string' ? JSON.parse(poll1.game_state) : poll1.game_state;
console.log(` game_state.last_move: ${JSON.stringify(gs1?.last_move)}`);
console.log(' ✓ Black sees White\'s move synced!');
// Step 9: Black responds with e7-e5
console.log('\n9. Black plays e7-e5...');
const m2 = await apiCall(blackPlayer.auth.token, 'game.php', {
action: 'move',
match_id: matchId,
fen: 'rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2',
move: JSON.stringify([
{ from: 'e2', to: 'e4', san: 'e4', color: 'w' },
{ from: 'e7', to: 'e5', san: 'e5', color: 'b' }
]),
move_count: 2,
white_time_remaining_ms: 598000,
black_time_remaining_ms: 597000,
game_state: JSON.stringify({ last_move: { from: 'e7', to: 'e5', san: 'e5' } })
});
console.log(` Move response: ${JSON.stringify(m2)}`);
if (m2.error) {
console.log(` ✗ ERROR: Move failed: ${m2.error}`);
return;
}
console.log(' ✓ Black move sent successfully');
// Step 10: White polls and sees Black's move
console.log('\n10. White polls for opponent move...');
await wait(500);
const poll2 = await apiCall(whitePlayer.auth.token, 'game.php', { action: 'get', match_id: matchId });
console.log(` White sees: move_count=${poll2.move_count}, fen=${poll2.current_fen?.slice(0,50)}`);
if (poll2.move_count !== 2) {
console.log(` ERROR: Expected move_count=2, got ${poll2.move_count}`);
return;
}
console.log(' ✓ White sees Black\'s move synced!');
// Step 11: Black resigns
console.log('\n11. Black resigns...');
const resign = await apiCall(blackPlayer.auth.token, 'game.php', { action: 'resign', match_id: matchId });
console.log(` Resign response: ${JSON.stringify(resign)}`);
if (resign.error) {
console.log(` ✗ ERROR: Resign failed: ${resign.error}`);
return;
}
console.log(` ✓ Resignation accepted: ${resign.result}`);
// Step 12: White polls and sees resign
console.log('\n12. White polls and sees game ended...');
await wait(500);
const poll3 = await apiCall(whitePlayer.auth.token, 'game.php', { action: 'get', match_id: matchId });
console.log(` White sees: status=${poll3.status}, result=${poll3.result}`);
if (poll3.status !== 'completed') {
console.log(` ✗ ERROR: Expected status=completed, got ${poll3.status}`);
return;
}
if (poll3.result !== 'white_wins') {
console.log(` ✗ ERROR: Expected result=white_wins, got ${poll3.result}`);
return;
}
console.log(' ✓ White sees victory from resignation!');
// Step 13: Test self-match prevention
console.log('\n13. Testing self-match prevention...');
// Player 1 queues, dequeues, queues again quickly
await apiCall(p1Auth.token, 'matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' });
await apiCall(p1Auth.token, 'matchmaking.php', { action: 'dequeue' });
const requeueResult = await apiCall(p1Auth.token, 'matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' });
console.log(` Re-queue result: ${JSON.stringify(requeueResult)}`);
if (requeueResult.match_id && requeueResult.opponent_id === PLAYER1.id) {
console.log(' ✗ ERROR: Self-matched!');
return;
}
console.log(' ✓ No self-match');
await apiCall(p1Auth.token, 'matchmaking.php', { action: 'dequeue' });
// Step 14: Test ghost entry prevention
console.log('\n14. Testing ghost entry prevention...');
// Player 1 queues (simulating a session that crashes without dequeue)
await apiCall(p1Auth.token, 'matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' });
// Player 1 queues again (new session) — old entry should be cleaned
const ghostTest = await apiCall(p1Auth.token, 'matchmaking.php', { action: 'queue', game_key: 'chess', time_control: 'rapid_10_0' });
console.log(` Result after double-queue: ${JSON.stringify(ghostTest)}`);
if (ghostTest.queued) {
console.log(' ✓ No ghost duplicate — clean re-queue');
} else if (ghostTest.match_id && ghostTest.opponent_id === PLAYER1.id) {
console.log(' ✗ ERROR: Matched with self from ghost entry');
return;
}
await apiCall(p1Auth.token, 'matchmaking.php', { action: 'dequeue' });
console.log('\n========================================');
console.log(' ✓ ALL TESTS PASSED — Chess multiplayer works!');
console.log('========================================\n');
}
run().catch(err => {
console.error('\n✗ FATAL ERROR:', err.message);
process.exit(1);
});
import puppeteer from 'puppeteer';
const BASE_URL = 'https://el3ab-player.caprover.al-arcade.com';
const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkMTRhMjdjNi0yNDQ4LTQzMTktYTc3NS1hOWY5NGRkNmRlODciLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzgwNzMzOTIxLCJpYXQiOjE3ODA3MzAzMjEsImVtYWlsIjoidGVzdHBsYXllcjFAZWwzYWIuY29tIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6eyJkaXNwbGF5X25hbWUiOiLZhNin2LnYqCDYp9iu2KrYqNin2LEiLCJlbWFpbCI6InRlc3RwbGF5ZXIxQGVsM2FiLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInN1YiI6ImQxNGEyN2M2LTI0NDgtNDMxOS1hNzc1LWE5Zjk0ZGQ2ZGU4NyIsInVzZXJuYW1lIjoidGVzdHBsYXllcjEifSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc4MDczMDMyMX1dLCJzZXNzaW9uX2lkIjoiZjcxZDRmZDktOWU3YS00ZTk1LWIwNDItNzM2YmQ5MjIxODYyIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.xJmblxW4Kgf0vBZlDV6K8AOyolAOuijgMmz9z8De-58';
const USER_ID = 'd14a27c6-2448-4319-a775-a9f94dd6de87';
const wait = ms => new Promise(r => setTimeout(r, ms));
async function run() {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--ignore-certificate-errors']
});
const page = await browser.newPage();
await page.setViewport({ width: 390, height: 844, deviceScaleFactor: 2 });
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', err => errors.push(`PAGE_ERROR: ${err.message}`));
console.log('1. Loading app...');
await page.goto(BASE_URL, { waitUntil: 'networkidle2', timeout: 30000 });
console.log('2. Injecting auth...');
await page.evaluate((token, userId) => {
localStorage.setItem('el3ab_state', JSON.stringify({
auth: { token, refreshToken: '7emeg6xkuddg', userId },
player: { id: userId, display_name: 'لاعب اختبار', username: 'testplayer1', level: 5, coins: 1000, xp: 500 },
activeWorld: 'play', audioEnabled: false, language: 'ar'
}));
}, TOKEN, USER_ID);
await page.reload({ waitUntil: 'networkidle2', timeout: 30000 });
await wait(2000);
console.log('3. Logged in');
// Click domino game card
console.log('4. Clicking domino card...');
await page.click('[data-game="domino"]');
await wait(1000);
// Click "لاعب واحد" (single player button in popup)
console.log('5. Clicking #btn-single...');
const singleClicked = await page.evaluate(() => {
const btn = document.querySelector('#btn-single');
if (btn) { btn.click(); return 'clicked'; }
return 'not found';
});
console.log(' Result:', singleClicked);
await wait(1500);
// Check for bot picker or game
const state1 = await page.evaluate(() => ({
text: document.body.innerText.slice(0, 300),
hasBotPicker: document.body.innerText.includes('خبير'),
hasGame: !!document.querySelector('#domino-wrap')
}));
console.log('6. After single:', state1.hasBotPicker ? 'BOT PICKER' : state1.hasGame ? 'GAME' : 'OTHER');
console.log(' Text:', state1.text.slice(0, 150));
await page.screenshot({ path: '/tmp/domino-step6.png' });
// Click intermediate
if (state1.hasBotPicker) {
console.log('7. Clicking intermediate...');
await page.evaluate(() => {
const btns = [...document.querySelectorAll('[data-level], button')];
const mid = btns.find(b => b.dataset?.level === 'intermediate' || b.textContent?.includes('متوسط'));
if (mid) mid.click();
});
await wait(3000);
}
await page.screenshot({ path: '/tmp/domino-step7.png' });
// ===== MAIN GAME STATE CHECK =====
const gs = await page.evaluate(() => {
const wrap = document.querySelector('#domino-wrap');
const canvas = document.querySelector('canvas');
const hand = document.querySelector('#domino-hand-area');
const board = document.querySelector('#domino-board');
const drawBtn = document.querySelector('#btn-draw');
const turnEl = document.querySelector('#turn-status');
const boneEl = document.querySelector('#boneyard-count');
const oppEl = document.querySelector('#opp-count');
const scoreEl = document.querySelector('#my-score');
let handChildren = [];
if (hand) {
handChildren = [...hand.children].map(c => ({
tag: c.tagName,
class: c.className,
style: c.getAttribute('style')?.slice(0, 80),
dataId: c.dataset?.tileId || c.getAttribute('data-tile-id') || '',
childCount: c.children.length
}));
}
return {
hasWrap: !!wrap,
wrapChildren: wrap ? wrap.children.length : 0,
hasCanvas: !!canvas,
canvasW: canvas?.width, canvasH: canvas?.height,
hasBoard: !!board,
boardChildren: board ? board.children.length : 0,
hasHand: !!hand,
handChildCount: hand ? hand.children.length : 0,
handChildren: handChildren.slice(0, 10),
hasDrawBtn: !!drawBtn,
turnText: turnEl?.textContent,
boneText: boneEl?.textContent,
oppText: oppEl?.textContent,
scoreText: scoreEl?.textContent,
fullHTML: document.querySelector('#domino-wrap')?.innerHTML?.slice(0, 800) || document.body.innerHTML.slice(0, 800)
};
});
console.log('\n======= GAME STATE =======');
console.log('Has #domino-wrap:', gs.hasWrap, '| children:', gs.wrapChildren);
console.log('Has canvas:', gs.hasCanvas, gs.canvasW + 'x' + gs.canvasH);
console.log('Has #domino-board:', gs.hasBoard, '| children:', gs.boardChildren);
console.log('Has #domino-hand-area:', gs.hasHand, '| children:', gs.handChildCount);
console.log('Hand children:', JSON.stringify(gs.handChildren, null, 2));
console.log('Has #btn-draw:', gs.hasDrawBtn);
console.log('Turn:', gs.turnText);
console.log('Boneyard:', gs.boneText);
console.log('Opponent:', gs.oppText);
console.log('Score:', gs.scoreText);
if (!gs.hasWrap) {
console.log('\nFull HTML preview:', gs.fullHTML.slice(0, 500));
}
// ===== TILE INTERACTION TEST =====
if (gs.hasHand && gs.handChildCount > 0) {
console.log('\n======= INTERACTION TEST =======');
// Get first tile position
const tileInfo = await page.evaluate(() => {
const hand = document.querySelector('#domino-hand-area');
if (!hand || !hand.children.length) return null;
const firstTile = hand.children[0];
const rect = firstTile.getBoundingClientRect();
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
w: rect.width, h: rect.height,
text: firstTile.textContent?.slice(0, 20),
style: firstTile.getAttribute('style')?.slice(0, 80)
};
});
console.log('First tile:', JSON.stringify(tileInfo));
if (tileInfo && tileInfo.w > 0) {
// TAP test
console.log('\nA) TAP on tile...');
await page.mouse.click(tileInfo.x, tileInfo.y);
await wait(500);
const afterTap = await page.evaluate(() => ({
turnText: document.querySelector('#turn-status')?.textContent,
handHTML: document.querySelector('#domino-hand-area')?.innerHTML?.slice(0, 200),
canvasChanged: document.querySelector('canvas')?.toDataURL()?.length
}));
console.log(' After tap - turn:', afterTap.turnText);
await page.screenshot({ path: '/tmp/domino-tap.png' });
// DRAG test
console.log('\nB) DRAG tile to board...');
const boardPos = await page.evaluate(() => {
const b = document.querySelector('#domino-board');
if (!b) return null;
const r = b.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
if (boardPos) {
await page.mouse.move(tileInfo.x, tileInfo.y);
await page.mouse.down();
// Move in steps
const steps = 15;
for (let i = 1; i <= steps; i++) {
const p = i / steps;
await page.mouse.move(
tileInfo.x + (boardPos.x - tileInfo.x) * p,
tileInfo.y + (boardPos.y - tileInfo.y) * p
);
await wait(20);
}
await wait(200);
// Check for proxy/ghost
const duringDrag = await page.evaluate(() => {
const proxy = document.querySelector('.drag-proxy, .domino-drag-proxy, [style*="position: fixed"]');
const ghost = document.querySelector('[style*="opacity: 0.5"], .ghost');
return { hasProxy: !!proxy, hasGhost: !!ghost };
});
console.log(' During drag:', duringDrag);
await page.screenshot({ path: '/tmp/domino-drag.png' });
await page.mouse.up();
await wait(1000);
await page.screenshot({ path: '/tmp/domino-afterdrag.png' });
}
}
}
// Errors
if (errors.length > 0) {
console.log('\n======= JS ERRORS =======');
errors.slice(0, 20).forEach(e => console.log(' ✗', e.slice(0, 300)));
} else {
console.log('\n✓ No JS errors');
}
await browser.close();
}
run().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
......@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EL3AB — 1m×3m Display</title>
<title>EL3AB — 1m×2.5m Display</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;600;700;800;900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
......@@ -16,10 +16,10 @@ html, body {
display: flex; align-items: center; justify-content: center;
}
/* 1:3 portrait — 1m wide × 3m tall */
/* 1:2.5 portrait — 1m wide × 2.5m tall */
.stage {
width: min(100vw, calc(100vh / 3));
height: min(100vh, calc(100vw * 3));
width: min(100vw, calc(100vh / 2.5));
height: min(100vh, calc(100vw * 2.5));
position: relative; overflow: hidden;
background: #020406;
container-type: size;
......@@ -29,19 +29,19 @@ html, body {
.bg {
position: absolute; inset: 0;
background:
radial-gradient(ellipse 100% 15% at 50% 5%, rgba(228,172,56,0.05) 0%, transparent 60%),
radial-gradient(ellipse 80% 10% at 50% 63%, rgba(32,130,240,0.03) 0%, transparent 40%),
radial-gradient(ellipse 90% 12% at 50% 95%, rgba(228,172,56,0.03) 0%, transparent 50%);
radial-gradient(ellipse 100% 18% at 50% 4%, rgba(228,172,56,0.05) 0%, transparent 60%),
radial-gradient(ellipse 80% 12% at 50% 56%, rgba(32,130,240,0.03) 0%, transparent 40%),
radial-gradient(ellipse 90% 14% at 50% 96%, rgba(228,172,56,0.03) 0%, transparent 50%);
}
.bg-grid {
position: absolute; inset: 0; opacity: 0.01;
background-image:
linear-gradient(rgba(255,255,255,0.3) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.3) 1px, transparent 1px);
background-size: 12.5% 4.16%;
background-size: 12.5% 5%;
animation: drift 40s linear infinite;
}
@keyframes drift { to { transform: translateY(4.16%); } }
@keyframes drift { to { transform: translateY(5%); } }
.vein {
position: absolute; width: 1px; top: 0; bottom: 0;
......@@ -65,16 +65,18 @@ html, body {
}
/* ═══ LAYOUT ═══
Safe areas: 4% top, 4% bottom, 8% sides
Zones (of 100% height):
4-18% : Logo + brand (14%)
20-52% : Phone screenshot (32%)
54-74% : QR section (20%) — center at 64% = 1.08m from ground ✓
76-96% : Games + features (20%)
1m × 2.5m display (1:2.5 ratio)
Safe areas: 3.5% top, 3.5% bottom, 8% sides
QR at phone height: 1.1m from ground = 56% from top
Zones:
0-16% : Logo + brand
17-50% : Phone screenshots
52-72% : QR section (center ~56% ✓)
74-97% : Games + features + badges
*/
.layout {
position: absolute;
top: 4cqh; bottom: 4cqh;
top: 3.5cqh; bottom: 3.5cqh;
left: 8cqw; right: 8cqw;
z-index: 10;
}
......@@ -86,11 +88,11 @@ html, body {
height: 14cqh;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 0.6cqh;
gap: 0.5cqh;
}
.logo-wrap {
position: relative;
width: 8cqh; height: 8cqh;
width: 7.5cqh; height: 7.5cqh;
}
.logo-wrap img {
width: 100%; height: 100%; object-fit: contain;
......@@ -112,22 +114,22 @@ html, body {
}
@keyframes spin { to { transform: rotate(360deg); } }
.brand { font-size: 4cqh; font-weight: 900; line-height: 1; background: linear-gradient(180deg, #FFF 20%, #E4AC38 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.brand-sub { font-size: 1cqh; font-weight: 300; color: #64748B; letter-spacing: 0.4em; }
.brand { font-size: 4.5cqh; font-weight: 900; line-height: 1; background: linear-gradient(180deg, #FFF 20%, #E4AC38 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.brand-sub { font-size: 1.1cqh; font-weight: 300; color: #64748B; letter-spacing: 0.4em; }
/* ═══ PHONE CAROUSEL ═══ */
.phone-area {
position: absolute;
top: 16cqh;
left: 50%; transform: translateX(-50%);
width: 38cqw;
height: 30cqh;
width: 40cqw;
height: 32cqh;
}
.phone-frame {
position: relative;
width: 100%; height: 100%;
border-radius: 2cqh;
border-radius: 2.2cqh;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.07);
box-shadow:
......@@ -139,7 +141,7 @@ html, body {
.phone-glow {
position: absolute; inset: -2px;
border-radius: 2.2cqh;
border-radius: 2.4cqh;
z-index: -1;
background: conic-gradient(from 0deg, rgba(228,172,56,0.2), rgba(32,130,240,0.12), rgba(0,255,255,0.1), rgba(228,172,56,0.2));
filter: blur(5px);
......@@ -181,20 +183,20 @@ html, body {
z-index: 5;
}
/* ═══ QR SECTION — at ~64% from top ═══ */
/* ═══ QR SECTION — 56% from top = 1.1m from ground on 2.5m ═══ */
.qr-section {
position: absolute;
top: 50cqh;
top: 51cqh;
left: 0; right: 0;
height: 16cqh;
border-radius: 2cqh;
height: 18cqh;
border-radius: 2.2cqh;
background: linear-gradient(155deg, rgba(228,172,56,0.04) 0%, rgba(8,12,24,0.85) 100%);
border: 1px solid rgba(228,172,56,0.1);
display: flex;
flex-direction: row;
align-items: center;
padding: 2cqh 3cqw;
gap: 3cqw;
padding: 2.5cqh 3.5cqw;
gap: 3.5cqw;
overflow: hidden;
}
......@@ -209,10 +211,10 @@ html, body {
.qr-box {
position: relative; z-index: 2;
flex-shrink: 0;
width: 11cqh; height: 11cqh;
width: 13cqh; height: 13cqh;
background: #fff;
border-radius: 1.2cqh;
padding: 0.6cqh;
border-radius: 1.4cqh;
padding: 0.7cqh;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 6px 25px rgba(0,0,0,0.4);
}
......@@ -220,16 +222,16 @@ html, body {
.qr-scanline {
position: absolute;
left: 0.6cqh; right: 0.6cqh; height: 2px;
left: 0.7cqh; right: 0.7cqh; height: 2px;
background: linear-gradient(90deg, transparent, #E4AC38, transparent);
border-radius: 1px;
animation: scan 3s ease-in-out infinite;
}
@keyframes scan { 0%,100%{top:0.6cqh;opacity:0;} 10%{opacity:1;} 90%{opacity:1;} 50%{top:calc(100% - 0.6cqh);} }
@keyframes scan { 0%,100%{top:0.7cqh;opacity:0;} 10%{opacity:1;} 90%{opacity:1;} 50%{top:calc(100% - 0.7cqh);} }
.qr-corner {
position: absolute; width: 15%; height: 15%;
border: 2px solid #E4AC38; z-index: 3;
border: 2.5px solid #E4AC38; z-index: 3;
}
.qr-corner.tl { top: -1px; left: -1px; border-right: none; border-bottom: none; border-radius: 5px 0 0 0; }
.qr-corner.tr { top: -1px; right: -1px; border-left: none; border-bottom: none; border-radius: 0 5px 0 0; }
......@@ -238,7 +240,7 @@ html, body {
.qr-pulse {
position: absolute; inset: -4px;
border-radius: 1.5cqh;
border-radius: 1.7cqh;
border: 1.5px solid rgba(228,172,56,0.2);
animation: qpulse 2.5s ease-out infinite;
z-index: 1;
......@@ -248,20 +250,20 @@ html, body {
.qr-info {
position: relative; z-index: 2;
display: flex; flex-direction: column;
gap: 0.8cqh;
gap: 0.9cqh;
flex: 1;
min-width: 0;
}
.qr-headline {
font-size: 2.2cqh; font-weight: 800; line-height: 1.4;
font-size: 2.6cqh; font-weight: 800; line-height: 1.4;
}
.qr-headline .gold { color: #E4AC38; }
.qr-desc {
font-size: 1.2cqh; color: #94A3B8; line-height: 1.6;
font-size: 1.4cqh; color: #94A3B8; line-height: 1.6;
}
.qr-url {
font-size: 0.9cqh; color: #2082F0; direction: ltr;
padding: 0.3cqh 1cqw;
font-size: 1cqh; color: #2082F0; direction: ltr;
padding: 0.35cqh 1.2cqw;
background: rgba(32,130,240,0.05);
border: 1px solid rgba(32,130,240,0.08);
border-radius: 999px; width: fit-content;
......@@ -271,7 +273,7 @@ html, body {
/* ═══ BOTTOM ZONE ═══ */
.bottom {
position: absolute;
top: 69cqh;
top: 72cqh;
left: 0; right: 0;
bottom: 0;
display: flex; flex-direction: column;
......@@ -284,26 +286,26 @@ html, body {
}
.game-item {
display: flex; flex-direction: column;
align-items: center; gap: 0.3cqh;
align-items: center; gap: 0.4cqh;
animation: bob 4s ease-in-out infinite;
}
.game-item:nth-child(2) { animation-delay: -1.3s; }
.game-item:nth-child(3) { animation-delay: -2.6s; }
@keyframes bob { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-3px);} }
.game-item img { width: 3.5cqh; height: 3.5cqh; object-fit: contain; }
.game-item .name { font-size: 1cqh; font-weight: 700; color: #CBD5E1; }
.game-item .live { font-size: 0.8cqh; color: #34D399; display: flex; align-items: center; gap: 0.2cqh; }
.game-item img { width: 4cqh; height: 4cqh; object-fit: contain; }
.game-item .name { font-size: 1.1cqh; font-weight: 700; color: #CBD5E1; }
.game-item .live { font-size: 0.85cqh; color: #34D399; display: flex; align-items: center; gap: 0.2cqh; }
.game-item .live::before { content:''; width:3px; height:3px; border-radius:50%; background:#34D399; animation:blink 1.5s ease-in-out infinite; }
@keyframes blink { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
.feat-area {
position: relative; width: 100%; height: 2.5cqh;
position: relative; width: 100%; height: 3cqh;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
.feat-line {
position: absolute;
font-size: 1.2cqh; font-weight: 600; color: #94A3B8;
font-size: 1.3cqh; font-weight: 600; color: #94A3B8;
white-space: nowrap; opacity: 0;
animation: feat-rot 20s ease-in-out infinite;
text-align: center;
......@@ -327,15 +329,15 @@ html, body {
}
.badge {
display: flex; align-items: center; gap: 0.5cqw;
font-size: 0.85cqh; color: #64748B;
padding: 0.25cqh 1cqw;
font-size: 0.95cqh; color: #64748B;
padding: 0.3cqh 1.2cqw;
border-radius: 999px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.04);
}
.b-dot { width:3px; height:3px; border-radius:50%; background:#34D399; animation:blink 1.5s ease-in-out infinite; }
.b-flag {
width: 1cqh; height: 0.7cqh; border-radius: 1px;
width: 1.1cqh; height: 0.75cqh; border-radius: 1px;
display: flex; flex-direction: column; overflow: hidden;
}
.b-flag span { flex: 1; }
......@@ -343,16 +345,16 @@ html, body {
.b-flag span:nth-child(2) { background: #fff; }
.b-flag span:nth-child(3) { background: #111; }
/* Separator line between zones */
/* Separator lines */
.sep {
position: absolute;
left: 15%; right: 15%;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(228,172,56,0.08), transparent);
}
.sep-1 { top: 15.5cqh; }
.sep-2 { top: 48.5cqh; }
.sep-3 { top: 67.5cqh; }
.sep-1 { top: 15cqh; }
.sep-2 { top: 49.5cqh; }
.sep-3 { top: 70.5cqh; }
</style>
</head>
<body>
......@@ -364,7 +366,6 @@ html, body {
<div class="particles" id="ptcls"></div>
<div class="layout">
<!-- Subtle separators -->
<div class="sep sep-1"></div>
<div class="sep sep-2"></div>
<div class="sep sep-3"></div>
......@@ -393,7 +394,7 @@ html, body {
</div>
</div>
<!-- QR: Center at ~58% from top = 1.26m from ground on 3m -->
<!-- QR: Center at ~56% from top = 1.1m from ground on 2.5m display -->
<div class="qr-section">
<div class="qr-box">
<div class="qr-pulse"></div>
......
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import { mkdirSync, rmSync, existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRAMES_DIR = path.join(__dirname, 'frames-16x9');
const OUTPUT = path.join(__dirname, 'promo-16x9.mp4');
const HTML_PATH = path.join(__dirname, 'video-16x9.html');
const FPS = 30;
const DURATION = 120; // 2 minutes
const TOTAL_FRAMES = FPS * DURATION;
// 1920x1080 Full HD 16:9
const WIDTH = 1920;
const HEIGHT = 1080;
async function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function main() {
if (existsSync(FRAMES_DIR)) rmSync(FRAMES_DIR, { recursive: true });
mkdirSync(FRAMES_DIR, { recursive: true });
console.log(`Recording ${DURATION}s at ${FPS}fps → ${TOTAL_FRAMES} frames`);
console.log(`Resolution: ${WIDTH}×${HEIGHT}`);
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: WIDTH, height: HEIGHT, deviceScaleFactor: 1 });
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'networkidle0' });
await wait(500);
// Pause all animations and the rAF timeline
await page.evaluate(() => {
// Stop the rAF loop
window.__PAUSED = true;
document.getAnimations().forEach(a => a.pause());
});
// Inject a function to manually advance the timeline
await page.evaluate(() => {
window.advanceTo = function(ms) {
// Run timeline logic
const TIMELINE = [
[0, 's0', null],
[2000, 's1', 'white'],
[7000, 's2', 'white'],
[10000, 's3', 'gold'],
[14000, 's4', 'white'],
[20000, 's5', 'white'],
[25000, 's6', 'white'],
[33000, 's7', 'gold'],
[37000, 's8', 'white'],
[45000, 's9', 'gold'],
[49000, 's10', 'gold'],
[54000, 's11', 'white'],
[65000, 's12', 'white'],
[73000, 's13', 'white'],
[82000, 's14', 'white'],
[90000, 's15', 'gold'],
[97000, 's16', 'white'],
[108000, 's17', 'gold'],
];
let active = TIMELINE[0];
for (const entry of TIMELINE) {
if (ms >= entry[0]) active = entry;
else break;
}
// Show scene
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
const el = document.getElementById(active[1]);
if (el) el.classList.add('active');
// Progress bar
const prog = document.getElementById('prog');
if (prog) prog.style.width = ((ms / 120000) * 100) + '%';
// Advance CSS animations
document.getAnimations().forEach(a => {
a.currentTime = ms;
});
};
});
const msPerFrame = (DURATION * 1000) / TOTAL_FRAMES;
for (let i = 0; i < TOTAL_FRAMES; i++) {
const t = i * msPerFrame;
await page.evaluate((time) => {
window.advanceTo(time);
}, t);
await wait(10);
const frameNum = String(i).padStart(5, '0');
await page.screenshot({
path: path.join(FRAMES_DIR, `frame_${frameNum}.png`),
type: 'png'
});
if (i % 150 === 0) {
console.log(` Frame ${i}/${TOTAL_FRAMES} (${Math.round(t/1000)}s)`);
}
}
await browser.close();
console.log('Screenshots done. Encoding video...');
const ffmpegCmd = [
'ffmpeg', '-y',
'-framerate', String(FPS),
'-i', path.join(FRAMES_DIR, 'frame_%05d.png'),
'-c:v', 'libx264',
'-preset', 'medium',
'-crf', '18',
'-pix_fmt', 'yuv420p',
OUTPUT
].join(' ');
console.log(`Running ffmpeg...`);
execSync(ffmpegCmd, { stdio: 'inherit' });
rmSync(FRAMES_DIR, { recursive: true });
console.log(`\n✅ Done! Video saved to: ${OUTPUT}`);
console.log(` Duration: ${DURATION}s, ${WIDTH}×${HEIGHT}, ${FPS}fps`);
}
main().catch(e => { console.error(e); process.exit(1); });
import puppeteer from 'puppeteer';
import { execSync } from 'child_process';
import { mkdirSync, rmSync, existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FRAMES_DIR = path.join(__dirname, 'frames');
const OUTPUT = path.join(__dirname, 'banner-loop.mp4');
const HTML_PATH = path.join(__dirname, 'banner-mega.html');
const FPS = 30;
const DURATION = 20; // 20s = one full loop (covers 16s crossfade + 20s feature rotation)
const TOTAL_FRAMES = FPS * DURATION;
// Display: 1m x 2.5m → render at 1080 x 2700px (1:2.5)
const WIDTH = 1080;
const HEIGHT = 2700;
async function wait(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function main() {
// Clean up old frames
if (existsSync(FRAMES_DIR)) rmSync(FRAMES_DIR, { recursive: true });
mkdirSync(FRAMES_DIR, { recursive: true });
console.log(`Recording ${DURATION}s at ${FPS}fps → ${TOTAL_FRAMES} frames`);
console.log(`Resolution: ${WIDTH}×${HEIGHT}`);
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
await page.setViewport({ width: WIDTH, height: HEIGHT, deviceScaleFactor: 1 });
// Load the banner
await page.goto(`file://${HTML_PATH}`, { waitUntil: 'networkidle0' });
await wait(1000); // let animations initialize
// Capture frames by advancing CSS animations
// We override animation play state and manually seek time
await page.evaluate(() => {
// Pause all animations initially
document.getAnimations().forEach(a => a.pause());
});
const msPerFrame = (DURATION * 1000) / TOTAL_FRAMES;
for (let i = 0; i < TOTAL_FRAMES; i++) {
const t = i * msPerFrame;
// Set all animations to the correct time
await page.evaluate((time) => {
document.getAnimations().forEach(a => {
a.currentTime = time;
});
}, t);
// Small delay to let browser paint
await wait(20);
const frameNum = String(i).padStart(5, '0');
await page.screenshot({
path: path.join(FRAMES_DIR, `frame_${frameNum}.png`),
type: 'png'
});
if (i % 60 === 0) {
console.log(` Frame ${i}/${TOTAL_FRAMES} (${Math.round(t/1000)}s)`);
}
}
await browser.close();
console.log('Screenshots done. Encoding video...');
// Stitch frames into video with ffmpeg
const ffmpegCmd = [
'ffmpeg', '-y',
'-framerate', String(FPS),
'-i', path.join(FRAMES_DIR, 'frame_%05d.png'),
'-c:v', 'libx264',
'-preset', 'slow',
'-crf', '18',
'-pix_fmt', 'yuv420p',
'-vf', 'scale=1080:2700',
OUTPUT
].join(' ');
console.log(`Running: ${ffmpegCmd}`);
execSync(ffmpegCmd, { stdio: 'inherit' });
// Clean up frames
rmSync(FRAMES_DIR, { recursive: true });
console.log(`\n✅ Done! Video saved to: ${OUTPUT}`);
console.log(` Duration: ${DURATION}s, ${WIDTH}×${HEIGHT}, ${FPS}fps`);
}
main().catch(e => { console.error(e); process.exit(1); });
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EL3AB — 16:9 Promo</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;600;700;800;900&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100vw; height: 100vh; overflow: hidden;
font-family: 'IBM Plex Sans Arabic', sans-serif;
background: #000; color: #F8FAFC;
display: flex; align-items: center; justify-content: center;
}
.stage {
width: min(100vw, calc(100vh * 16 / 9));
height: min(100vh, calc(100vw * 9 / 16));
position: relative; overflow: hidden;
background: #020406;
}
/* ═══ GLOBAL ═══ */
.scene {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none;
transition: none;
}
.scene.active { opacity: 1; }
/* Flash overlay */
.flash {
position: absolute; inset: 0;
background: #fff; opacity: 0; z-index: 999;
pointer-events: none;
}
.flash.fire { animation: flash-pop 0.12s ease-out; }
.flash.gold { background: #E4AC38; }
@keyframes flash-pop { 0%{opacity:0.9;} 100%{opacity:0;} }
/* Glitch effect */
.glitch-overlay {
position: absolute; inset: 0; z-index: 998;
pointer-events: none; opacity: 0;
background: repeating-linear-gradient(
0deg,
transparent, transparent 2px,
rgba(0,255,255,0.03) 2px, rgba(0,255,255,0.03) 4px
);
}
.glitch-overlay.fire { animation: glitch-on 0.2s steps(4); }
@keyframes glitch-on { 0%{opacity:1;transform:translateX(-2px);} 25%{transform:translateX(3px);} 50%{transform:translateX(-1px);} 75%{transform:translateX(2px);} 100%{opacity:0;transform:translateX(0);} }
/* Particles BG */
.particles {
position: absolute; inset: 0; pointer-events: none; z-index: 1;
}
.pt {
position: absolute; border-radius: 50%;
animation: float-up 12s linear infinite;
}
@keyframes float-up {
0% { transform: translateY(0) scale(1); opacity: 0; }
5% { opacity: 0.5; }
90% { opacity: 0.1; }
100% { transform: translateY(-110vh) scale(0.3); opacity: 0; }
}
/* ═══ TEXT STYLES ═══ */
.slam {
font-weight: 900; text-align: center; line-height: 1.3;
text-shadow: 0 0 40px rgba(228,172,56,0.3);
}
.slam-huge { font-size: 8vh; }
.slam-big { font-size: 6vh; }
.slam-med { font-size: 4.5vh; }
.slam-sm { font-size: 3.5vh; }
.gold { color: #E4AC38; }
.cyan { color: #00FFFF; }
.green { color: #34D399; }
.dim { color: #64748B; }
.strike { text-decoration: line-through; color: #64748B; }
.glow { text-shadow: 0 0 20px currentColor, 0 0 40px currentColor; }
/* Neon border accent */
.neon-box {
border: 2px solid rgba(0,255,255,0.3);
border-radius: 16px;
padding: 3vh 5vw;
box-shadow: 0 0 30px rgba(0,255,255,0.1), inset 0 0 30px rgba(0,255,255,0.02);
}
/* Phone frame */
.phone {
width: 18vh; height: 38vh;
border-radius: 2vh;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.1);
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 30px rgba(32,130,240,0.1);
}
.phone img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
/* Stats counter */
.stat-block { text-align: center; }
.stat-num { font-size: 10vh; font-weight: 900; color: #E4AC38; line-height: 1; }
.stat-label { font-size: 2.5vh; color: #94A3B8; margin-top: 0.5vh; }
/* Logo */
.logo-big { width: 20vh; height: 20vh; object-fit: contain; filter: drop-shadow(0 0 30px rgba(228,172,56,0.5)); }
.logo-sm { width: 8vh; height: 8vh; object-fit: contain; filter: drop-shadow(0 0 15px rgba(228,172,56,0.4)); }
/* Shield */
.shield { font-size: 14vh; filter: drop-shadow(0 0 20px rgba(52,211,153,0.4)); }
/* Feature list */
.feat-list { display: flex; flex-direction: column; gap: 2vh; }
.feat-item { display: flex; align-items: center; gap: 2vw; font-size: 3.5vh; font-weight: 700; }
.feat-dot { width: 12px; height: 12px; border-radius: 50%; background: #E4AC38; flex-shrink: 0; }
/* QR */
.qr-wrap { background: #fff; padding: 1.5vh; border-radius: 1.5vh; box-shadow: 0 0 40px rgba(228,172,56,0.3); }
.qr-wrap img { width: 16vh; height: 16vh; display: block; }
/* Bracket visual */
.bracket { display: flex; align-items: center; gap: 3vw; }
.bracket-col { display: flex; flex-direction: column; gap: 1.5vh; }
.bracket-item { background: rgba(228,172,56,0.08); border: 1px solid rgba(228,172,56,0.2); border-radius: 8px; padding: 1vh 2vw; font-size: 2vh; font-weight: 600; min-width: 10vw; text-align: center; }
.bracket-line { width: 3vw; height: 2px; background: rgba(228,172,56,0.3); }
/* Entrance animations */
.enter-up { animation: enter-up 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
@keyframes enter-up { from { opacity: 0; transform: translateY(30%); } to { opacity: 1; transform: translateY(0); } }
.enter-scale { animation: enter-scale 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
@keyframes enter-scale { from { opacity: 0; transform: scale(0.7); } to { opacity: 1; transform: scale(1); } }
.enter-left { animation: enter-left 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
@keyframes enter-left { from { opacity: 0; transform: translateX(-20%); } to { opacity: 1; transform: translateX(0); } }
.enter-right { animation: enter-right 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
@keyframes enter-right { from { opacity: 0; transform: translateX(20%); } to { opacity: 1; transform: translateX(0); } }
/* Progress bar at bottom */
.progress {
position: absolute; bottom: 0; left: 0; right: 0; height: 3px;
background: rgba(255,255,255,0.05); z-index: 1000;
}
.progress-bar {
height: 100%; background: linear-gradient(90deg, #E4AC38, #00FFFF);
width: 0%; transition: width 0.1s linear;
}
</style>
</head>
<body>
<div class="stage" id="stage">
<div class="particles" id="ptcls"></div>
<div class="flash" id="flash"></div>
<div class="glitch-overlay" id="glitch"></div>
<!-- SCENE 0: Black + lines (0-2s) -->
<div class="scene" id="s0">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="slam slam-sm dim" style="letter-spacing:0.5em;">♟ ━━━━━━━━━━━━ ♟</div>
<div class="slam slam-sm dim" style="letter-spacing:0.5em;">🎲 ━━━━━━━━━━━━ 🎲</div>
<div class="slam slam-sm dim" style="letter-spacing:0.5em;">🁣 ━━━━━━━━━━━━ 🁣</div>
</div>
</div>
<!-- SCENE 1: Hook (2-7s) -->
<div class="scene" id="s1">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="slam slam-big">ولادك بيلعبوا ألعاب</div>
<div class="slam slam-big"><span class="strike">ملهاش لازمة</span></div>
</div>
</div>
<!-- SCENE 2: Hook pt2 (7-10s) -->
<div class="scene" id="s2">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="slam slam-big">ما لو اللعبة</div>
<div class="slam slam-huge"><span class="gold glow">بتذكّيهم؟</span></div>
</div>
</div>
<!-- SCENE 3: Brand reveal (10-14s) -->
<div class="scene" id="s3">
<div style="display:flex;flex-direction:column;align-items:center;gap:3vh;">
<img class="logo-big enter-scale" src="video/assets/logof.png" alt="">
<div class="slam slam-huge"><span class="gold">العب</span></div>
<div class="slam slam-sm dim">منصة الألعاب الذهنية المصرية</div>
</div>
</div>
<!-- SCENE 4: The games (14-20s) -->
<div class="scene" id="s4">
<div style="display:flex;align-items:center;gap:4vw;">
<div class="phone"><img src="screenshots/06-chess-midgame.png" alt=""></div>
<div class="phone"><img src="screenshots/08-ludo-game.png" alt=""></div>
<div class="phone"><img src="screenshots/09-domino-menu.png" alt=""></div>
</div>
</div>
<!-- SCENE 5: Games text (20-25s) -->
<div class="scene" id="s5">
<div style="display:flex;flex-direction:column;align-items:center;gap:3vh;">
<div class="slam slam-big">شطرنج • لودو • دومينو</div>
<div class="slam slam-med dim">من أول ما يفتح — لحد ما يلعب</div>
<div class="slam slam-med"><span class="cyan glow">ثواني.</span></div>
</div>
</div>
<!-- SCENE 6: Education (25-33s) -->
<div class="scene" id="s6">
<div style="display:flex;align-items:center;gap:6vw;">
<div class="phone"><img src="screenshots/06-chess-midgame.png" alt=""></div>
<div class="feat-list">
<div class="feat-item"><span class="feat-dot"></span>تعليم شطرنج مدمج</div>
<div class="feat-item"><span class="feat-dot"></span>ألغاز يومية</div>
<div class="feat-item"><span class="feat-dot"></span>تصنيف وتقييم مستمر</div>
<div class="feat-item"><span class="feat-dot"></span>تطوير التفكير النقدي</div>
</div>
</div>
</div>
<!-- SCENE 7: Education slam (33-37s) -->
<div class="scene" id="s7">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="slam slam-big">الطفل بيلعب</div>
<div class="slam slam-huge"><span class="gold glow">وهو بيتعلم.</span></div>
</div>
</div>
<!-- SCENE 8: Safety (37-45s) -->
<div class="scene" id="s8">
<div style="display:flex;align-items:center;gap:6vw;">
<div class="shield">🛡️</div>
<div class="neon-box" style="border-color:rgba(52,211,153,0.3);box-shadow:0 0 30px rgba(52,211,153,0.1);">
<div style="display:flex;flex-direction:column;gap:2vh;">
<div class="slam slam-med"><span class="green">مفيش chat مفتوح.</span></div>
<div class="slam slam-sm">عبارات جاهزة فقط.</div>
<div class="slam slam-sm">نظام إبلاغ فوري.</div>
<div class="slam slam-med" style="margin-top:1vh;"><span class="green">الأهل يطمنوا.</span></div>
</div>
</div>
</div>
</div>
<!-- SCENE 9: Safety slam (45-49s) -->
<div class="scene" id="s9">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="slam slam-big">بيئة نضيفة</div>
<div class="slam slam-huge"><span class="green glow">100%</span></div>
</div>
</div>
<!-- SCENE 10: Tournaments intro (49-54s) -->
<div class="scene" id="s10">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="slam slam-huge"><span class="gold glow">بطولات.</span></div>
</div>
</div>
<!-- SCENE 11: Tournament details (54-65s) -->
<div class="scene" id="s11">
<div style="display:flex;flex-direction:column;align-items:center;gap:3vh;">
<div class="slam slam-big">مدارس • جامعات • أندية</div>
<div class="slam slam-big">شركات • مراكز شباب • أحياء</div>
<div style="margin-top:2vh;display:flex;gap:4vw;">
<div class="neon-box" style="text-align:center;">
<div class="slam slam-sm">Swiss</div>
</div>
<div class="neon-box" style="text-align:center;">
<div class="slam slam-sm">Knockout</div>
</div>
<div class="neon-box" style="text-align:center;">
<div class="slam slam-sm">Arena</div>
</div>
</div>
</div>
</div>
<!-- SCENE 12: Tournament automation (65-73s) -->
<div class="scene" id="s12">
<div style="display:flex;flex-direction:column;align-items:center;gap:2.5vh;">
<div class="slam slam-med">القرعة <span class="gold">أوتوماتيك.</span></div>
<div class="slam slam-med">الجدول <span class="gold">أوتوماتيك.</span></div>
<div class="slam slam-med">النتايج <span class="gold">أوتوماتيك.</span></div>
<div class="slam slam-big" style="margin-top:2vh;"><span class="cyan glow">البنية التحتية شغالة.</span></div>
</div>
</div>
<!-- SCENE 13: Stats (73-82s) -->
<div class="scene" id="s13">
<div style="display:flex;gap:8vw;align-items:center;">
<div class="stat-block">
<div class="stat-num">4</div>
<div class="stat-label">ألعاب ذهنية</div>
</div>
<div class="stat-block">
<div class="stat-num"></div>
<div class="stat-label">بطولات</div>
</div>
<div class="stat-block">
<div class="stat-num">0</div>
<div class="stat-label">إعلانات</div>
</div>
</div>
</div>
<!-- SCENE 14: Free + Egyptian (82-90s) -->
<div class="scene" id="s14">
<div style="display:flex;flex-direction:column;align-items:center;gap:3vh;">
<div class="slam slam-big">مجاني بالكامل.</div>
<div class="slam slam-big">بدون إعلانات.</div>
<div class="slam slam-huge" style="margin-top:1vh;"><span class="gold">تطوير مصري 100%</span></div>
</div>
</div>
<!-- SCENE 15: The punch (90-97s) -->
<div class="scene" id="s15">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="slam slam-big">ولادنا يستاهلوا</div>
<div class="slam slam-huge"><span class="gold glow">أحسن من كده.</span></div>
</div>
</div>
<!-- SCENE 16: CTA (97-108s) -->
<div class="scene" id="s16">
<div style="display:flex;align-items:center;gap:6vw;">
<div style="display:flex;flex-direction:column;align-items:center;gap:2vh;">
<div class="qr-wrap"><img src="video/assets/qr-code.png" alt="QR"></div>
</div>
<div style="display:flex;flex-direction:column;gap:2vh;">
<div class="slam slam-big">جرّب دلوقتي.</div>
<div class="slam slam-med dim">امسح الكود وابدأ أول ماتش</div>
<div class="slam slam-sm cyan" style="direction:ltr;font-family:monospace;">el3ab-player.caprover.al-arcade.com</div>
</div>
</div>
</div>
<!-- SCENE 17: Logo end (108-120s) -->
<div class="scene" id="s17">
<div style="display:flex;flex-direction:column;align-items:center;gap:3vh;">
<img class="logo-big" src="video/assets/logof.png" alt="">
<div class="slam slam-huge"><span class="gold">العب</span></div>
<div class="slam slam-sm dim">منصة الألعاب الذهنية — مجانية — آمنة — مصرية</div>
</div>
</div>
<div class="progress"><div class="progress-bar" id="prog"></div></div>
</div>
<script>
// Timeline: [startMs, sceneId, flashType]
const TIMELINE = [
[0, 's0', null],
[2000, 's1', 'white'],
[7000, 's2', 'white'],
[10000, 's3', 'gold'],
[14000, 's4', 'white'],
[20000, 's5', 'white'],
[25000, 's6', 'white'],
[33000, 's7', 'gold'],
[37000, 's8', 'white'],
[45000, 's9', 'gold'],
[49000, 's10', 'gold'],
[54000, 's11', 'white'],
[65000, 's12', 'white'],
[73000, 's13', 'white'],
[82000, 's14', 'white'],
[90000, 's15', 'gold'],
[97000, 's16', 'white'],
[108000, 's17', 'gold'],
];
const TOTAL_DURATION = 120000;
let currentScene = null;
const flash = document.getElementById('flash');
const glitch = document.getElementById('glitch');
const prog = document.getElementById('prog');
function fireFlash(type) {
flash.className = 'flash';
if (type === 'gold') flash.classList.add('gold');
void flash.offsetWidth;
flash.classList.add('fire');
// Glitch on every transition
glitch.className = 'glitch-overlay';
void glitch.offsetWidth;
glitch.classList.add('fire');
}
function showScene(id) {
if (currentScene === id) return;
document.querySelectorAll('.scene').forEach(s => s.classList.remove('active'));
const el = document.getElementById(id);
if (el) el.classList.add('active');
currentScene = id;
}
function runTimeline(elapsed) {
// Find current scene
let active = TIMELINE[0];
for (const entry of TIMELINE) {
if (elapsed >= entry[0]) active = entry;
else break;
}
if (active[1] !== currentScene) {
if (active[2]) fireFlash(active[2]);
showScene(active[1]);
}
prog.style.width = ((elapsed / TOTAL_DURATION) * 100) + '%';
}
// Auto-play timeline
let startTime = null;
function tick(ts) {
if (!startTime) startTime = ts;
const elapsed = ts - startTime;
if (elapsed >= TOTAL_DURATION) {
startTime = ts;
runTimeline(0);
} else {
runTimeline(elapsed);
}
requestAnimationFrame(tick);
}
// Particles
const pc = document.getElementById('ptcls');
const cols = ['rgba(228,172,56,0.2)','rgba(32,130,240,0.15)','rgba(0,255,255,0.12)'];
for (let i = 0; i < 20; i++) {
const p = document.createElement('div');
p.className = 'pt';
const s = 2 + Math.random() * 3;
Object.assign(p.style, {
width:s+'px', height:s+'px',
left:Math.random()*100+'%', bottom:'-5%',
background:cols[i%3],
animationDuration:(8+Math.random()*12)+'s',
animationDelay:(Math.random()*10)+'s'
});
pc.appendChild(p);
}
requestAnimationFrame(tick);
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment