App Studio Demo Capture
What this is: A Playwright-based pipeline that opens a real Domo App Studio app in an authenticated headless browser, navigates through pages, scrolls, and records a polished demo video. Captures the full production experience — left-nav, native cards, pro-code iframes, applied theme, live data. What this is NOT: This is not Remotion. It does not render React components in Node.js or use sample data. If you need component-level rendering with synthetic data and animated cursors, use basic-custom-app-build-w-video instead.When to Use
- After deploying an App Studio app (via
app-studio+app-studio-pro-codepipeline) and you want a walkthrough video - When the user says "demo", "walkthrough", "animated preview", "record the app", or "show me what it looks like"
- When generating eval screenshots for comparison or documentation
- When producing content for pitch decks, stakeholder reviews, or marketing
Prerequisites
- App is deployed — the App Studio app exists on the Domo instance with all pages, cards, themes, and data
- Playwright installed —
npm install playwrightin the project directory - Domo authentication — a valid session ID obtainable via
domo loginor theupload_bridge.get_sid()helper - ffmpeg installed (for post-processing) —
brew install ffmpegon macOS
Pipeline Overview
┌─────────────┐ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ Authenticate │ ──▶ │ Capture │ ──▶ │ Post-process │ ──▶ │ Output │
│ (get SID) │ │ (Playwright) │ │ (ffmpeg) │ │ (.mp4/.gif) │
└─────────────┘ └──────────────┘ └───────────────┘ └──────────────┘Three capture modes, in order of complexity:
| Mode | Method | Output | Best for |
|---|---|---|---|
| Screenshots | page.screenshot() per page | PNGs | Static eval, deck slides, documentation |
| Scroll video | Playwright recordVideo + scripted scroll | Raw MP4 per page | Smooth page walkthroughs |
| Full walkthrough | Playwright recordVideo + nav + scroll across all pages | Single MP4 | Polished demo videos |
Mode 1: Multi-Page Screenshots (Proven Pattern)
This is the existing screenshot.js pattern — battle-tested across dozens of App Studio apps.
Usage
node screenshot.js app <appId> '<json-pageIds>' [outputDir]Example
node screenshot.js app 396231467 '{"Overview":"1942764543","Sales":"1942764544","Inventory":"1942764545","Performance":"1942764546"}' ./demo-output/screenshotsIntegration in Python build scripts
import subprocess, json
page_ids = {"Overview": str(overview_id), "Sales": str(sales_id), ...}
subprocess.run([
"node", "screenshot.js", "app", str(app_id),
json.dumps(page_ids), f"./{app_name}/screenshots"
], check=True)Key implementation details (from proven screenshot.js)
- Viewport: 1440x900 with
deviceScaleFactor: 2(produces 2880x1800 retina PNGs) - Auth: Inject
X-Domo-Authenticationheader viapage.route('**/*')interception - Wait strategy:
waitUntil: 'networkidle'+ additionalwaitForTimeout(8000)for pro-code iframes to render - Full page:
fullPage: truecaptures content below the fold - Error recovery: On navigation failure, capture an
_error.pngfor debugging
Also capture supporting assets
# Dataset details pages
node screenshot.js datasets '{"sales":"guid1","inventory":"guid2"}' ./demo-output/datasets
# Magic ETL dataflow graph
node screenshot.js etl <dataflowId> ./demo-output/etl
# Arbitrary Domo URLs
node screenshot.js urls '{"landing":"https://modocorp.domo.com/page/123"}' ./demo-output/urlsMode 2: Per-Page Scroll Video
Record a smooth scroll-through of each page individually. Produces one MP4 per page.
Script: demo-capture.js
const { chromium } = require('playwright');
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const INSTANCE = 'modocorp';
function getSid() {
const result = execSync(
`cd "${path.join(__dirname, 'domo_data_generator')}" && python3 -c "from upload_bridge import get_sid; print(get_sid('${INSTANCE}'));"`,
{ encoding: 'utf-8' }
).trim();
return result.split('\n').pop().trim();
}
async function capturePageVideo(browser, sid, appId, pageName, pageId, outputDir, options = {}) {
const {
scrollDistance = 600,
scrollStepPx = 2,
scrollIntervalMs = 16,
holdTopMs = 3000,
holdBottomMs = 2000,
loadWaitMs = 8000,
} = options;
const videoDir = path.join(outputDir, '_raw_video');
fs.mkdirSync(videoDir, { recursive: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: { dir: videoDir, size: { width: 1440, height: 900 } },
});
const page = await context.newPage();
await page.route('**/*', (route, request) => {
const url = request.url();
if (url.includes('.domo.com') || url.includes('domoapps.')) {
route.continue({ headers: { ...request.headers(), 'X-Domo-Authentication': sid } });
} else {
route.continue();
}
});
const url = `https://${INSTANCE}.domo.com/app-studio/${appId}/pages/${pageId}`;
console.log(` Recording ${pageName}: ${url}`);
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForTimeout(loadWaitMs);
// Hold at top
await page.waitForTimeout(holdTopMs);
// Smooth scroll down
const scrollSteps = Math.ceil(scrollDistance / scrollStepPx);
for (let i = 0; i < scrollSteps; i++) {
await page.evaluate((px) => window.scrollBy(0, px), scrollStepPx);
await page.waitForTimeout(scrollIntervalMs);
}
// Hold at bottom
await page.waitForTimeout(holdBottomMs);
// Close context to finalize video
const videoPath = await page.video().path();
await context.close();
// Move to final location
const finalPath = path.join(outputDir, `${pageName}.webm`);
fs.renameSync(videoPath, finalPath);
console.log(` Saved: ${finalPath}`);
return finalPath;
}
async function captureAllPages(appId, pageIds, outputDir, options = {}) {
fs.mkdirSync(outputDir, { recursive: true });
const sid = getSid();
console.log(`SID obtained (${sid.substring(0, 12)}...)`);
const browser = await chromium.launch({ headless: true });
const videoPaths = [];
for (const [pageName, pageId] of Object.entries(pageIds)) {
const vp = await capturePageVideo(browser, sid, appId, pageName, pageId, outputDir, options);
videoPaths.push(vp);
}
await browser.close();
console.log('All pages recorded.');
return videoPaths;
}
// CLI
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Usage: node demo-capture.js <appId> <json-pageIds> [outputDir] [scrollDistance]');
process.exit(1);
}
const appId = args[0];
const pageIds = JSON.parse(args[1]);
const outputDir = args[2] || path.join(__dirname, 'demo-output', `app-${appId}`);
const scrollDistance = args[3] ? parseInt(args[3]) : 600;
captureAllPages(appId, pageIds, outputDir, { scrollDistance })
.catch(err => { console.error('Fatal:', err); process.exit(1); });Usage
node demo-capture.js 396231467 '{"Overview":"1942764543","Sales":"1942764544"}' ./demo-output 800Mode 3: Full Walkthrough Video
Record a single continuous video that navigates through all pages — simulating a user clicking through the left-nav. This produces the most polished output.
Script: demo-walkthrough.js
const { chromium } = require('playwright');
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const INSTANCE = 'modocorp';
function getSid() {
const result = execSync(
`cd "${path.join(__dirname, 'domo_data_generator')}" && python3 -c "from upload_bridge import get_sid; print(get_sid('${INSTANCE}'));"`,
{ encoding: 'utf-8' }
).trim();
return result.split('\n').pop().trim();
}
async function recordWalkthrough(appId, pageIds, outputDir, options = {}) {
const {
scrollDistance = 600,
scrollStepPx = 2,
scrollIntervalMs = 16,
holdPageMs = 3000,
loadWaitMs = 8000,
transitionPauseMs = 2000,
} = options;
fs.mkdirSync(outputDir, { recursive: true });
const videoDir = path.join(outputDir, '_raw_video');
fs.mkdirSync(videoDir, { recursive: true });
const sid = getSid();
console.log(`SID obtained (${sid.substring(0, 12)}...)`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: { dir: videoDir, size: { width: 1440, height: 900 } },
});
const page = await context.newPage();
await page.route('**/*', (route, request) => {
const url = request.url();
if (url.includes('.domo.com') || url.includes('domoapps.')) {
route.continue({ headers: { ...request.headers(), 'X-Domo-Authentication': sid } });
} else {
route.continue();
}
});
const entries = Object.entries(pageIds);
console.log(`Recording walkthrough: ${entries.length} pages`);
for (let i = 0; i < entries.length; i++) {
const [pageName, pageId] = entries[i];
const url = `https://${INSTANCE}.domo.com/app-studio/${appId}/pages/${pageId}`;
console.log(` Page ${i + 1}/${entries.length}: ${pageName}`);
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForTimeout(i === 0 ? loadWaitMs : loadWaitMs / 2);
// Hold at top
await page.waitForTimeout(holdPageMs);
// Smooth scroll
const scrollSteps = Math.ceil(scrollDistance / scrollStepPx);
for (let s = 0; s < scrollSteps; s++) {
await page.evaluate((px) => window.scrollBy(0, px), scrollStepPx);
await page.waitForTimeout(scrollIntervalMs);
}
// Hold at scroll position
await page.waitForTimeout(holdPageMs / 2);
// Scroll back to top before navigating to next page
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }));
await page.waitForTimeout(transitionPauseMs);
}
const videoPath = await page.video().path();
await context.close();
await browser.close();
// Move raw video to output
const rawPath = path.join(outputDir, 'walkthrough_raw.webm');
fs.renameSync(videoPath, rawPath);
console.log(`Raw walkthrough: ${rawPath}`);
return rawPath;
}
// CLI
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Usage: node demo-walkthrough.js <appId> <json-pageIds> [outputDir]');
process.exit(1);
}
recordWalkthrough(args[0], JSON.parse(args[1]), args[2] || `./demo-output/app-${args[0]}`)
.catch(err => { console.error('Fatal:', err); process.exit(1); });Usage
node demo-walkthrough.js 396231467 \
'{"Overview":"1942764543","Sales":"1942764544","Inventory":"1942764545","Performance":"1942764546"}' \
./demo-outputPost-Processing with ffmpeg
Convert WebM to MP4
Playwright records in WebM (VP8). Convert to MP4 (H.264) for universal compatibility:
ffmpeg -i walkthrough_raw.webm -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p -movflags +faststart walkthrough.mp4Stitch per-page videos into one
If using Mode 2 (per-page videos), concatenate them:
# Create concat list
echo "file 'Overview.webm'" > concat.txt
echo "file 'Sales.webm'" >> concat.txt
echo "file 'Inventory.webm'" >> concat.txt
echo "file 'Performance.webm'" >> concat.txt
# Concatenate and convert
ffmpeg -f concat -safe 0 -i concat.txt -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p demo.mp4Add crossfade transitions between pages
For a polished result with 0.5s crossfades between page clips:
ffmpeg \
-i Overview.webm -i Sales.webm -i Inventory.webm -i Performance.webm \
-filter_complex "\
[0:v]setpts=PTS-STARTPTS[v0]; \
[1:v]setpts=PTS-STARTPTS[v1]; \
[2:v]setpts=PTS-STARTPTS[v2]; \
[3:v]setpts=PTS-STARTPTS[v3]; \
[v0][v1]xfade=transition=fade:duration=0.5:offset=4[x01]; \
[x01][v2]xfade=transition=fade:duration=0.5:offset=8[x02]; \
[x02][v3]xfade=transition=fade:duration=0.5:offset=12[out]" \
-map "[out]" -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p demo.mp4Adjust offset values based on each clip's duration. The offset is the timestamp (in seconds) where the crossfade begins.
Trim to exact duration
ffmpeg -i demo.mp4 -t 30 -c copy demo_30s.mp4Add intro/outro title cards
Create a 2-second title card from a PNG:
# Generate title card video from image
ffmpeg -loop 1 -i title.png -c:v libx264 -t 2 -pix_fmt yuv420p -vf "scale=1440:900" title.mp4
# Prepend to walkthrough
ffmpeg -f concat -safe 0 -i <(echo -e "file 'title.mp4'\nfile 'walkthrough.mp4'") -c copy final.mp4Generate animated GIF (for Slack/docs)
ffmpeg -i walkthrough.mp4 -vf "fps=12,scale=720:-1:flags=lanczos" -loop 0 demo.gifTiming Recommendations
| Page content | holdPageMs | scrollDistance | loadWaitMs |
|---|---|---|---|
| KPI row + chart (typical) | 3000 | 400 | 8000 |
| Dense dashboard (many cards) | 4000 | 800 | 10000 |
| Single chart (pro-code) | 2500 | 200 | 8000 |
| Data table | 3000 | 600 | 6000 |
| First page (cold load) | 3000 | 600 | 10000 |
Total video duration estimates
| Pages | Per-page hold | Scroll time | Transitions | Total |
|---|---|---|---|---|
| 4 | ~5s each | ~3s each | ~2s each | ~38s |
| 4 (trimmed) | ~4s each | ~2s each | ~1s each | ~28s |
| 6 | ~4s each | ~2s each | ~1s each | ~42s |
Target 25-35 seconds for a polished demo. Under 20s feels rushed; over 45s loses attention.
Integration with Build Pipeline
After build_app.py completes (Steps 1-8 from app-build-process-summary), add a capture step:
import subprocess, json
# Step 9: Capture demo
page_ids = {
"Overview": str(overview_page_id),
"Sales": str(sales_page_id),
"Inventory": str(inventory_page_id),
"Performance": str(performance_page_id),
}
# Screenshots (always — for eval and deck slides)
subprocess.run([
"node", "screenshot.js", "app", str(app_id),
json.dumps(page_ids), f"./{app_name}/screenshots"
], check=True)
# Animated walkthrough video
subprocess.run([
"node", "demo-walkthrough.js", str(app_id),
json.dumps(page_ids), f"./{app_name}/demo"
], check=True)
# Post-process to MP4
subprocess.run([
"ffmpeg", "-i", f"./{app_name}/demo/walkthrough_raw.webm",
"-c:v", "libx264", "-preset", "slow", "-crf", "18",
"-pix_fmt", "yuv420p", "-movflags", "+faststart",
f"./{app_name}/demo/walkthrough.mp4"
], check=True)Comparison with basic-custom-app-build-w-video
| Aspect | app-studio-demo-capture (this skill) | basic-custom-app-build-w-video |
|---|---|---|
| Captures | Real deployed App Studio app in browser | React components in Remotion |
| Data | Live Domo data | Synthetic TypeScript arrays |
| Chrome | Full App Studio (nav, layout, cards, theme) | Component only (no nav/layout) |
| Auth | Domo session ID required | No auth needed |
| Tooling | Playwright + ffmpeg | Remotion + @remotion/cli |
| Code changes | None — captures any deployed app | Requires Remotion-safe styling constraints |
| Cursor animation | Not included (real browser cursor) | AnimatedCursor component |
| Best for | App Studio apps, eval screenshots, stakeholder demos | Standalone custom apps, product marketing |
Use both together: basic-custom-app-build-w-video for component-level hero shots with animated cursor, app-studio-demo-capture for the full-chrome production walkthrough. Stitch both into a final video with ffmpeg.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Pro-code cards show blank/white | iframes haven't loaded yet | Increase loadWaitMs to 10000-12000 |
| Charts missing data | Data query still in flight | Increase loadWaitMs; add page.waitForSelector('.recharts-wrapper') |
| Left-nav collapsed/hidden | Viewport too narrow | Ensure viewport width >= 1280 |
| Auth redirect to login page | Expired SID | Re-run domo login; re-obtain SID |
| Video is choppy/laggy | Headless rendering under load | Use --disable-gpu flag; reduce scrollStepPx to 1 |
| WebM won't play | Player doesn't support VP8 | Convert to MP4 with ffmpeg |
| Screenshots too large | deviceScaleFactor: 2 + fullPage: true | Use deviceScaleFactor: 1 for video; keep 2x for screenshots |
Output Checklist
After running the pipeline, verify:
- Screenshots exist for every page (no
_error.pngfiles) - Pro-code banners and charts are visible (not blank)
- Theme is applied (correct colors, fonts, nav styling)
- Video is smooth (no frame drops or loading spinners captured)
- Video duration is 25-35s
- MP4 plays in QuickTime/VLC (H.264 baseline profile)
- Left-nav is visible and shows correct page names/icons
- Data is populated (charts have lines/bars, KPIs have values)