@@ -1088,7 +1088,7 @@
"provider access and facility trail": "Infrastructure-side evidence remains necessary even when the application stack is self-managed."
},
"overview": {
- "position": "first",
+ "position": "last",
"title": "Compliant GPU Blueprint",
"body": "A step-by-step walkthrough of what a compliant external GPU deployment looks like\nfor clinical AI: who controls what, where data crosses boundaries, and what evidence\nmust be in place before production traffic is allowed.\n\nUse the numbered steps to walk through the key decision points, or click any node\nfor detail on that component.\n"
}
diff --git a/examples/vLLM/deployment.story.yaml b/examples/vLLM/deployment.story.yaml
index 6bd27fe..08918a5 100644
--- a/examples/vLLM/deployment.story.yaml
+++ b/examples/vLLM/deployment.story.yaml
@@ -3,7 +3,7 @@ meta:
d2_source: deployment.d2
overview:
- position: first
+ position: last
title: "Compliant GPU Blueprint"
body: |
A step-by-step walkthrough of what a compliant external GPU deployment looks like
diff --git a/package-lock.json b/package-lock.json
index e83adff..ad98ed0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"esbuild": "^0.21.0",
+ "playwright": "^1.44.0",
"typescript": "^5.4.0",
"vitest": "^1.5.0"
}
@@ -1435,6 +1436,53 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/playwright": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
+ "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.58.2"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.58.2",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
+ "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
diff --git a/package.json b/package.json
index e32e438..0873e0f 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,8 @@
"scripts": {
"build": "tsc && node esbuild.mjs",
"build:tsc": "tsc",
- "docs:prepare-assets": "mkdir -p docs-site/public/examples/vllm && cp examples/vLLM/deployment.d2 docs-site/public/examples/vllm/deployment.d2 && cp examples/vLLM/deployment.story.yaml docs-site/public/examples/vllm/deployment.story.yaml && cp examples/vLLM/README.md docs-site/public/examples/vllm/README.md && node dist/cli/index.js build examples/vLLM/deployment.d2 examples/vLLM/deployment.story.yaml -o docs-site/public/examples/vllm/deployment.html && cp docs-site/public/examples/vllm/deployment.html examples/vLLM/deployment.html",
+ "demo:record": "node scripts/record-demo.mjs",
+ "docs:prepare-assets": "mkdir -p docs-site/public/examples/vllm && cp examples/vLLM/deployment.d2 docs-site/public/examples/vllm/deployment.d2 && cp examples/vLLM/deployment.story.yaml docs-site/public/examples/vllm/deployment.story.yaml && cp examples/vLLM/README.md docs-site/public/examples/vllm/README.md && node dist/cli/index.js build examples/vLLM/deployment.d2 examples/vLLM/deployment.story.yaml -o docs-site/public/examples/vllm/deployment.html && cp docs-site/public/examples/vllm/deployment.html examples/vLLM/deployment.html && ([ -f docs/demo.gif ] && cp docs/demo.gif docs-site/public/demo.gif || true)",
"docs:dev": "npm --prefix docs-site run dev",
"docs:build": "npm run build && npm run docs:prepare-assets && npm --prefix docs-site run build",
"test": "vitest run",
@@ -48,6 +49,7 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0",
"esbuild": "^0.21.0",
+ "playwright": "^1.44.0",
"typescript": "^5.4.0",
"vitest": "^1.5.0"
}
diff --git a/scripts/record-demo.mjs b/scripts/record-demo.mjs
new file mode 100644
index 0000000..a8ea770
--- /dev/null
+++ b/scripts/record-demo.mjs
@@ -0,0 +1,139 @@
+#!/usr/bin/env node
+/**
+ * Record a walkthrough GIF of the DiaScope viewer stepping through the vLLM example.
+ *
+ * Usage:
+ * node scripts/record-demo.mjs [--out
]
+ *
+ * Requires:
+ * - playwright (npm install)
+ * - npx playwright install chromium
+ * - ffmpeg on PATH
+ *
+ * Default output: docs/demo.gif
+ */
+
+import { chromium } from 'playwright';
+import { execSync, spawnSync } from 'child_process';
+import { mkdirSync, rmSync, existsSync, readdirSync } from 'fs';
+import { join, resolve, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
+
+// Parse --out flag
+const outIdx = process.argv.indexOf('--out');
+const OUT_GIF = outIdx !== -1
+ ? resolve(process.argv[outIdx + 1])
+ : join(ROOT, 'docs', 'demo.gif');
+
+const HTML = join(ROOT, 'examples', 'vLLM', 'deployment.html');
+const FRAMES_DIR = join(ROOT, '.playwright-mcp', 'demo-recording');
+
+// Viewport — wide enough to show both diagram and panel clearly.
+const W = 1200;
+const H = 680;
+
+// Timing (ms)
+const INITIAL_LOAD_MS = 1500; // viewer mount + first zoom-in
+const FIRST_STEP_DWELL_MS = 1800;
+const STEP_ANIM_MS = 1100; // zoom/pan animation per step
+const STEP_DWELL_MS = 1400; // hold to read narration
+const FINAL_DWELL_MS = 2200;
+
+// GIF output settings
+const GIF_FPS = 15;
+const GIF_WIDTH = 1200; // keep at full width, GitHub renders it fine
+
+function hasFfmpeg() {
+ return spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0;
+}
+
+async function countSteps(page) {
+ return page.evaluate(() => {
+ const btns = [...document.querySelectorAll('#step-nav button')];
+ // exclude the "All" button
+ return btns.filter(b => b.textContent?.trim() !== 'All').length;
+ });
+}
+
+async function main() {
+ if (!existsSync(HTML)) {
+ console.error(`\nBuilt HTML not found at:\n ${HTML}\n\nRun first:\n npm run build\n npm run docs:prepare-assets\n`);
+ process.exit(1);
+ }
+
+ if (!hasFfmpeg()) {
+ console.error('\nffmpeg is required but not found on PATH.\n macOS: brew install ffmpeg\n Ubuntu: sudo apt-get install -y ffmpeg\n');
+ process.exit(1);
+ }
+
+ mkdirSync(FRAMES_DIR, { recursive: true });
+ // Clean any previous recording artefacts
+ for (const f of readdirSync(FRAMES_DIR)) rmSync(join(FRAMES_DIR, f), { recursive: true });
+
+ mkdirSync(dirname(OUT_GIF), { recursive: true });
+
+ console.log('Launching browser…');
+ const browser = await chromium.launch();
+ const context = await browser.newContext({
+ viewport: { width: W, height: H },
+ recordVideo: { dir: FRAMES_DIR, size: { width: W, height: H } },
+ });
+ const page = await context.newPage();
+
+ console.log(`Opening ${HTML}`);
+ await page.goto(`file://${HTML}`);
+
+ // Wait for the viewer shell to be ready
+ await page.waitForSelector('#btn-next', { state: 'visible' });
+ console.log(`Waiting ${INITIAL_LOAD_MS}ms for initial render + zoom-in…`);
+ await page.waitForTimeout(INITIAL_LOAD_MS);
+
+ const stepCount = await countSteps(page);
+ console.log(`Found ${stepCount} steps.`);
+
+ // Dwell on step 1
+ await page.waitForTimeout(FIRST_STEP_DWELL_MS);
+
+ for (let i = 1; i < stepCount; i++) {
+ console.log(`Step ${i + 1} / ${stepCount}`);
+ await page.click('#btn-next');
+ await page.waitForTimeout(STEP_ANIM_MS + STEP_DWELL_MS);
+ }
+
+ // Final dwell
+ await page.waitForTimeout(FINAL_DWELL_MS);
+
+ console.log('Closing browser (finalises video)…');
+ await context.close();
+ await browser.close();
+
+ // Locate the recorded .webm
+ const webm = readdirSync(FRAMES_DIR).find(f => f.endsWith('.webm'));
+ if (!webm) {
+ console.error('No .webm file found in recording dir.');
+ process.exit(1);
+ }
+ const webmPath = join(FRAMES_DIR, webm);
+ console.log(`Video recorded: ${webmPath}`);
+
+ // Convert to palette-optimised GIF
+ console.log(`Converting to GIF → ${OUT_GIF}`);
+ const ffmpegFilter = [
+ `fps=${GIF_FPS}`,
+ `scale=${GIF_WIDTH}:-2:flags=lanczos`,
+ `split[s0][s1]`,
+ `[s0]palettegen=stats_mode=full[p]`,
+ `[s1][p]paletteuse=dither=bayer:bayer_scale=5`,
+ ].join(',');
+
+ execSync(
+ `ffmpeg -y -i "${webmPath}" -vf "${ffmpegFilter}" -loop 0 "${OUT_GIF}"`,
+ { stdio: 'inherit' },
+ );
+
+ console.log(`\n✓ Done: ${OUT_GIF}`);
+}
+
+main().catch(e => { console.error(e); process.exit(1); });