关于 app-studio-demo-capture
基于 Playwright 的技能,可在通过身份验证的浏览器中录制已部署的 Domo App Studio 应用。它能捕获真实页面和实时数据,生成截图或演示视频,并支持使用 ffmpeg 后期处理。
主要功能
- 01捕获应用各页面的截图
- 02录制逐页滚动视频
- 03生成跨多页的连续演示
- 04捕获经身份验证的实时 Domo 应用视图
- 05使用 ffmpeg 后期处理和合并媒体
适用场景
- 01制作供利益相关者评审的应用演示
- 02为评估或文档捕获截图
- 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 应用后,想要制作演示视频 - 用户提到“演示”“操作演示”“动态预览”“录制应用”或“让我看看效果”
- 生成用于对比或文档的评估截图
- 为演示文稿、利益相关者评审或营销制作内容
前提条件
- 应用已部署——Domo 实例中已存在 App Studio 应用,且包含所有页面、卡片、主题和数据
- 已安装 Playwright——在项目目录中运行
npm install playwright - Domo 身份验证——可通过
domo login或upload_bridge.get_sid()助手获取有效的会话 ID - 已安装 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
时间设置建议
| 页面内容 | holdPageMs | scrollDistance | loadWaitMs |
|---|---|---|---|
| KPI 行 + 图表(典型情况) | 3000 | 400 | 8000 |
| 密集型仪表板(卡片较多) | 4000 | 800 | 10000 |
| 单个图表(专业代码) | 2500 | 200 | 8000 |
| 数据表格 | 3000 | 600 | 6000 |
| 首页(冷启动加载) | 3000 | 600 | 10000 |
视频总时长估算
| 页面数 | 每页停留时间 | 滚动时间 | 转场 | 总时长 |
|---|---|---|---|---|
| 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 + ffmpeg | Remotion + @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 有数值)