mcpField
A
app-studio-demo-capture

app-studio-demo-capture

github.com/stahura创意与媒体2435 次安装26 周前更新
代码仓库

关于 app-studio-demo-capture

基于 Playwright 的技能,可在通过身份验证的浏览器中录制已部署的 Domo App Studio 应用。它能捕获真实页面和实时数据,生成截图或演示视频,并支持使用 ffmpeg 后期处理。

主要功能

  1. 01捕获应用各页面的截图
  2. 02录制逐页滚动视频
  3. 03生成跨多页的连续演示
  4. 04捕获经身份验证的实时 Domo 应用视图
  5. 05使用 ffmpeg 后期处理和合并媒体

适用场景

  1. 01制作供利益相关者评审的应用演示
  2. 02为评估或文档捕获截图
  3. 03为已部署的 App Studio 应用制作演示视频

安装

复制下面的命令,在终端中运行。

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

安装后在 Claude Code 中输入/app-studio-demo-capture确认已加载。

详细介绍

App Studio 演示录制

这是什么:一个基于 Playwright 的工作流,可在经过身份验证的无头浏览器中打开真实的 Domo App Studio 应用,浏览页面、滚动并录制精致的演示视频。完整捕捉生产环境中的体验——左侧导航、本机卡片、专业代码 iframe、已应用的主题和实时数据。

这不是什么:这不是 Remotion。它不会在 Node.js 中渲染 React 组件,也不会使用示例数据。如果你需要使用合成数据和动画光标进行组件级渲染,请改用 basic-custom-app-build-w-video。

适用场景

  • 通过 app-studio + app-studio-pro-code 工作流部署 App Studio 应用后,想要制作演示视频
  • 用户提到“演示”“操作演示”“动态预览”“录制应用”或“让我看看效果”
  • 生成用于对比或文档的评估截图
  • 为演示文稿、利益相关者评审或营销制作内容

前提条件

  1. 应用已部署——Domo 实例中已存在 App Studio 应用,且包含所有页面、卡片、主题和数据
  2. 已安装 Playwright——在项目目录中运行 npm install playwright
  3. Domo 身份验证——可通过 domo login 或 upload_bridge.get_sid() 助手获取有效的会话 ID
  4. 已安装 ffmpeg(用于后期处理)——在 macOS 上运行 brew install ffmpeg

工作流概览

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

三种录制模式,按复杂程度排序:

模式方法输出最适合
截图每个页面调用 page.screenshot()PNG 文件静态评估、演示文稿幻灯片、文档
滚动视频Playwright recordVideo + 脚本控制滚动每个页面一个原始 MP4 文件流畅的页面演示
完整演示Playwright recordVideo + 浏览导航 + 滚动浏览所有页面单个 MP4 文件精致的演示视频

模式 1:多页面截图(已验证方案)

这是现有的 screenshot.js 方案,已在数十个 App Studio 应用中经过实战验证。

用法
node screenshot.js app <appId> '<json-pageIds>' [outputDir]
示例
node screenshot.js app 396231467 '{"Overview":"1942764543","Sales":"1942764544","Inventory":"1942764545","Performance":"1942764546"}' ./demo-output/screenshots
集成到 Python 构建脚本中
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)
关键实现细节(来自经过验证的 screenshot.js)
  • 视口:1440x900,deviceScaleFactor: 2(生成 2880x1800 的视网膜屏 PNG)
  • 身份验证:通过 page.route('**/*') 拦截,为请求注入 X-Domo-Authentication 标头
  • 等待策略:使用 waitUntil: 'networkidle',并额外调用 waitForTimeout(8000),等待专业代码 iframe 渲染
  • 完整页面:fullPage: true 可捕捉首屏以下的内容
  • 错误恢复:导航失败时,生成 _error.png 以便调试
同时捕捉辅助资源
# 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

模式 2:逐页滚动视频

逐页录制流畅的滚动演示。每个页面生成一个 MP4 文件。

脚本: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); });
用法
node demo-capture.js 396231467 '{"Overview":"1942764543","Sales":"1942764544"}' ./demo-output 800

模式 3:完整演示视频

录制一段连续视频,依次浏览所有页面,模拟用户点击左侧导航的操作。这种模式的输出最为精致。

脚本: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); });
用法
node demo-walkthrough.js 396231467 \
  '{"Overview":"1942764543","Sales":"1942764544","Inventory":"1942764545","Performance":"1942764546"}' \
  ./demo-output

使用 ffmpeg 进行后期处理

将 WebM 转换为 MP4

Playwright 会以 WebM(VP8)格式录制。转换为 MP4(H.264)以确保广泛兼容:

ffmpeg -i walkthrough_raw.webm -c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p -movflags +faststart walkthrough.mp4
将逐页视频拼接为一个视频

如果使用模式 2(逐页录制视频),可将视频片段拼接起来:

# 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
在页面之间添加交叉淡化转场

若要获得更精致的效果,可在页面片段之间添加 0.5 秒的交叉淡化:

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

根据每个片段的时长调整 offset 值。offset 是交叉淡化开始时的时间戳(以秒为单位)。

修剪到指定时长
ffmpeg -i demo.mp4 -t 30 -c copy demo_30s.mp4
添加片头/片尾标题卡

从 PNG 创建一张 2 秒的标题卡:

# 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
生成动画 GIF(用于 Slack/文档)
ffmpeg -i walkthrough.mp4 -vf "fps=12,scale=720:-1:flags=lanczos" -loop 0 demo.gif

时间设置建议

页面内容holdPageMsscrollDistanceloadWaitMs
KPI 行 + 图表(典型情况)30004008000
密集型仪表板(卡片较多)400080010000
单个图表(专业代码)25002008000
数据表格30006006000
首页(冷启动加载)300060010000
视频总时长估算
页面数每页停留时间滚动时间转场总时长
4每页约 5 秒每页约 3 秒每次约 2 秒约 38 秒
4(精简版)每页约 4 秒每页约 2 秒每次约 1 秒约 28 秒
6每页约 4 秒每页约 2 秒每次约 1 秒约 42 秒

精致演示的目标时长为 25-35 秒。少于 20 秒会显得仓促;超过 45 秒则容易让观众失去注意力。


与构建流水线集成

build_app.py 完成后(app-build-process-summary 中的步骤 1-8),添加一个捕获步骤:

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)

与 basic-custom-app-build-w-video 的比较

方面app-studio-demo-capture(此技能)basic-custom-app-build-w-video
捕获内容浏览器中的真实已部署 App Studio 应用Remotion 中的 React 组件
数据实时 Domo 数据合成 TypeScript 数组
Chrome完整的 App Studio(导航、布局、卡片、主题)仅组件(无导航/布局)
身份验证需要 Domo 会话 ID无需身份验证
工具链Playwright + ffmpegRemotion + @remotion/cli
代码变更无——可捕获任何已部署的应用需要遵守 Remotion 安全样式约束
光标动画不包含(使用真实浏览器光标)AnimatedCursor 组件
最适用于App Studio 应用、评估截图、利益相关方演示独立定制应用、产品营销

两者搭配使用:使用 basic-custom-app-build-w-video 制作带动画光标的组件级主视觉镜头,使用 app-studio-demo-capture 制作带完整 Chrome 界面的生产环境演示。用 ffmpeg 将两者拼接成最终视频。


故障排除

症状原因解决方法
专业代码卡片显示空白/白屏iframe 尚未加载将 loadWaitMs 增加到 10000-12000
图表缺少数据数据查询仍在进行增加 loadWaitMs;添加 page.waitForSelector('.recharts-wrapper')
左侧导航栏折叠/隐藏视口太窄确保视口宽度 >= 1280
身份验证重定向到登录页SID 已过期重新运行 domo login;重新获取 SID
视频卡顿/延迟负载过高时的无头渲染使用 --disable-gpu 标志;将 scrollStepPx 减小到 1
WebM 无法播放播放器不支持 VP8使用 ffmpeg 转换为 MP4
截图过大deviceScaleFactor: 2 + fullPage: true视频使用 deviceScaleFactor: 1;截图保留 2x

输出检查清单

运行流水线后,请确认:

  • 每个页面都有截图(没有 _error.png 文件)
  • 专业代码横幅和图表清晰可见(非空白)
  • 已应用主题(颜色、字体、导航样式正确)
  • 视频流畅(没有丢帧或捕获到加载动画)
  • 视频时长为 25-35 秒
  • MP4 可在 QuickTime/VLC 中播放(H.264 baseline 配置文件)
  • 左侧导航栏可见,且显示正确的页面名称/图标
  • 数据已填充(图表中有折线/柱形,KPI 有数值)

评论

支持 Markdown