mcpField
A
app-studio-demo-capture

app-studio-demo-capture

github.com/stahuraCreative & media2435 installsUpdated 26w ago
Repository

About app-studio-demo-capture

A Playwright-based skill for recording deployed Domo App Studio apps in an authenticated browser. It captures real pages and live data as screenshots or walkthrough videos, with ffmpeg post-processing options.

Key features

  1. 01Capture screenshots across app pages
  2. 02Record per-page scroll videos
  3. 03Create continuous multi-page walkthroughs
  4. 04Capture authenticated, live Domo app views
  5. 05Post-process and combine media with ffmpeg

Use cases

  1. 01Produce app walkthroughs for stakeholder reviews
  2. 02Capture screenshots for evaluation or documentation
  3. 03Create demo videos of deployed App Studio apps

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/stahura/domo-ai-vibe-rules --skill app-studio-demo-capture

After installing, type/app-studio-demo-capturein Claude Code to confirm it loaded.

Details

From the project README

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-code pipeline) 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

  1. App is deployed — the App Studio app exists on the Domo instance with all pages, cards, themes, and data
  2. Playwright installed — npm install playwright in the project directory
  3. Domo authentication — a valid session ID obtainable via domo login or the upload_bridge.get_sid() helper
  4. ffmpeg installed (for post-processing) — brew install ffmpeg on macOS

Pipeline Overview

┌─────────────┐     ┌──────────────┐     ┌───────────────┐     ┌──────────────┐
│ Authenticate │ ──▶ │ Capture      │ ──▶ │ Post-process  │ ──▶ │ Output       │
│ (get SID)    │     │ (Playwright) │     │ (ffmpeg)      │     │ (.mp4/.gif)  │
└─────────────┘     └──────────────┘     └───────────────┘     └──────────────┘

Three capture modes, in order of complexity:

ModeMethodOutputBest for
Screenshotspage.screenshot() per pagePNGsStatic eval, deck slides, documentation
Scroll videoPlaywright recordVideo + scripted scrollRaw MP4 per pageSmooth page walkthroughs
Full walkthroughPlaywright recordVideo + nav + scroll across all pagesSingle MP4Polished 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/screenshots
Integration 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-Authentication header via page.route('**/*') interception
  • Wait strategy: waitUntil: 'networkidle' + additional waitForTimeout(8000) for pro-code iframes to render
  • Full page: fullPage: true captures content below the fold
  • Error recovery: On navigation failure, capture an _error.png for 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/urls

Mode 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 800

Mode 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-output

Post-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.mp4
Stitch 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.mp4
Add 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.mp4

Adjust 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.mp4
Add 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.mp4
Generate animated GIF (for Slack/docs)
ffmpeg -i walkthrough.mp4 -vf "fps=12,scale=720:-1:flags=lanczos" -loop 0 demo.gif

Timing Recommendations

Page contentholdPageMsscrollDistanceloadWaitMs
KPI row + chart (typical)30004008000
Dense dashboard (many cards)400080010000
Single chart (pro-code)25002008000
Data table30006006000
First page (cold load)300060010000
Total video duration estimates
PagesPer-page holdScroll timeTransitionsTotal
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

Aspectapp-studio-demo-capture (this skill)basic-custom-app-build-w-video
CapturesReal deployed App Studio app in browserReact components in Remotion
DataLive Domo dataSynthetic TypeScript arrays
ChromeFull App Studio (nav, layout, cards, theme)Component only (no nav/layout)
AuthDomo session ID requiredNo auth needed
ToolingPlaywright + ffmpegRemotion + @remotion/cli
Code changesNone — captures any deployed appRequires Remotion-safe styling constraints
Cursor animationNot included (real browser cursor)AnimatedCursor component
Best forApp Studio apps, eval screenshots, stakeholder demosStandalone 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

SymptomCauseFix
Pro-code cards show blank/whiteiframes haven't loaded yetIncrease loadWaitMs to 10000-12000
Charts missing dataData query still in flightIncrease loadWaitMs; add page.waitForSelector('.recharts-wrapper')
Left-nav collapsed/hiddenViewport too narrowEnsure viewport width >= 1280
Auth redirect to login pageExpired SIDRe-run domo login; re-obtain SID
Video is choppy/laggyHeadless rendering under loadUse --disable-gpu flag; reduce scrollStepPx to 1
WebM won't playPlayer doesn't support VP8Convert to MP4 with ffmpeg
Screenshots too largedeviceScaleFactor: 2 + fullPage: trueUse deviceScaleFactor: 1 for video; keep 2x for screenshots

Output Checklist

After running the pipeline, verify:

  • Screenshots exist for every page (no _error.png files)
  • 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)

Comments

Markdown supported