Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
unit:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
# ffmpeg is needed for boundary-frame extraction and shader pre-render.
- run: sudo apt-get update && sudo apt-get install -y ffmpeg
- run: npm install
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm test

cross-browser:
name: ci-smoke (${{ matrix.browser }})
needs: unit
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
env:
ARGO_BROWSER: ${{ matrix.browser }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- run: sudo apt-get update && sudo apt-get install -y ffmpeg
- run: npm install
# chromium is also installed even when recording on webkit/firefox —
# shader-render uses headless chromium for WebGL regardless of the
# recording browser.
- run: npx playwright install --with-deps chromium "$ARGO_BROWSER"
- run: npm run build

- name: Run pipeline
run: npx tsx bin/argo.js pipeline ci-smoke --config demos/ci-smoke.config.mjs --browser "$ARGO_BROWSER"

- name: Verify output
run: |
pip install --quiet pillow
python3 scripts/verify-ci-smoke.py videos/ci-smoke.mp4

- name: Upload mp4 artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: ci-smoke-${{ matrix.browser }}
path: videos/ci-smoke.mp4
if-no-files-found: warn
2 changes: 2 additions & 0 deletions demos/blocks-showcase.demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ test('blocks-showcase', async ({ page, narration }) => {
`);
await page.waitForTimeout(500);

await narration.startRecording(page);

for (const scene of ['intro', 'x-post', 'macos', 'ytlt', 'chart', 'spotify', 'closing']) {
narration.mark(scene);
await showOverlay(page, scene, narration.durationFor(scene, { maxMs: 6000 }));
Expand Down
26 changes: 26 additions & 0 deletions demos/ci-smoke.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { defineConfig } from '@argo-video/cli';

// CI smoke config — exercises the cross-browser robustness fixes:
// * captureMode: 'jpeg-stitch' auto-downgrades to 'webm' on non-chromium
// * deviceScaleFactor: 2 auto-clamps to 1 on non-chromium
// * shader transition exercises setsar=1 normalization on webkit
// Silent demo (no text in scenes manifest) — TTS is skipped, video-only export.
export default defineConfig({
// Demo uses page.setContent — baseURL is unused but required by config schema.
baseURL: 'about:blank',
demosDir: 'demos',
outputDir: 'videos',
video: {
width: 1920,
height: 1080,
fps: 30,
deviceScaleFactor: 2,
captureMode: 'jpeg-stitch',
},
export: {
preset: 'ultrafast',
crf: 28,
encoder: 'cpu',
transition: { type: 'shader', shader: 'crosswarp', durationMs: 600 },
},
});
34 changes: 34 additions & 0 deletions demos/ci-smoke.demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { test } from '@argo-video/cli';

test('ci-smoke', async ({ page, narration }) => {
test.setTimeout(60_000);

await page.setContent(`
<!doctype html>
<html><body style="margin:0;font-family:system-ui;color:#fff;overflow:hidden">
<div id="s1" data-scene style="position:absolute;inset:0;background:#1e3a8a;display:grid;place-items:center;font-size:96px;font-weight:800">Scene 1</div>
<div id="s2" data-scene style="position:absolute;inset:0;background:#7c2d12;display:none;place-items:center;font-size:96px;font-weight:800">Scene 2</div>
<div id="s3" data-scene style="position:absolute;inset:0;background:#14532d;display:none;place-items:center;font-size:96px;font-weight:800">Scene 3</div>
</body></html>
`);
await page.waitForTimeout(300);

await narration.startRecording(page);

narration.mark('one');
await page.waitForTimeout(2500);

await page.evaluate(() => {
(document.getElementById('s1') as HTMLElement).style.display = 'none';
(document.getElementById('s2') as HTMLElement).style.display = 'grid';
});
narration.mark('two');
await page.waitForTimeout(2500);

await page.evaluate(() => {
(document.getElementById('s2') as HTMLElement).style.display = 'none';
(document.getElementById('s3') as HTMLElement).style.display = 'grid';
});
narration.mark('three');
await page.waitForTimeout(2500);
});
5 changes: 5 additions & 0 deletions demos/ci-smoke.scenes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{ "scene": "one" },
{ "scene": "two" },
{ "scene": "three" }
]
74 changes: 74 additions & 0 deletions scripts/verify-ci-smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""
Verify ci-smoke output mp4. Asserts:
- output exists and is valid mp4
- dimensions are 1920x1080 (catches dsf-clamp regressions)
- duration is in expected window
- midpoint frame's bottom-right quadrant is not near-gray
(catches frame-in-frame regressions where a non-chromium browser
rendered the page at 1x into a 2x screencast canvas)

Usage: python3 scripts/verify-ci-smoke.py videos/ci-smoke.mp4
"""

import subprocess
import sys
from pathlib import Path

EXPECTED_W, EXPECTED_H = 1920, 1080
MIN_DURATION_S, MAX_DURATION_S = 5, 20


def ffprobe(*args: str) -> str:
return subprocess.check_output(['ffprobe', '-v', 'error', *args]).decode().strip()


def main() -> None:
if len(sys.argv) != 2:
sys.exit('Usage: verify-ci-smoke.py <mp4>')
mp4 = Path(sys.argv[1])
if not mp4.exists():
sys.exit(f'missing output: {mp4}')

dims_csv = ffprobe('-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', str(mp4))
w, h = (int(x) for x in dims_csv.split(','))
if (w, h) != (EXPECTED_W, EXPECTED_H):
sys.exit(f'dimensions {w}x{h} != {EXPECTED_W}x{EXPECTED_H}')

duration = float(ffprobe('-show_entries', 'format=duration', '-of', 'csv=p=0', str(mp4)))
if not MIN_DURATION_S <= duration <= MAX_DURATION_S:
sys.exit(f'duration {duration:.1f}s outside [{MIN_DURATION_S}, {MAX_DURATION_S}]')

sample_path = Path('/tmp/ci-smoke-sample.png')
subprocess.check_call(
['ffmpeg', '-y', '-ss', f'{duration / 2:.2f}', '-i', str(mp4), '-frames:v', '1', str(sample_path)],
stderr=subprocess.DEVNULL,
)

from PIL import Image
img = Image.open(sample_path).convert('RGB')
samples = {
'top-left': img.getpixel((w // 4, h // 4)),
'top-right': img.getpixel((3 * w // 4, h // 4)),
'bottom-left': img.getpixel((w // 4, 3 * h // 4)),
'bottom-right': img.getpixel((3 * w // 4, 3 * h // 4)),
'center': img.getpixel((w // 2, h // 2)),
}
print('pixel samples:')
for name, rgb in samples.items():
print(f' {name:13s}: {rgb}')

# Bottom-right should be inside the rendered scene background, not gray padding.
# The three scenes use saturated dark colors (#1e3a8a, #7c2d12, #14532d) — the
# max channel deviation from grey is at least ~70. Padding gray pixels stay
# within ~10 of (128,128,128).
br = samples['bottom-right']
gray_distance = sum(abs(c - 128) for c in br)
if gray_distance < 30:
sys.exit(f'bottom-right {br} too close to gray (distance={gray_distance}) — frame-in-frame regression?')

print(f'OK: {w}x{h}, {duration:.1f}s, br_gray_distance={gray_distance}')


if __name__ == '__main__':
main()
Loading
Loading