Segment demo video by scene with embedded GIF segments (#990)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
This commit is contained in:
Copilot
2025-12-23 23:11:18 +01:00
committed by GitHub
parent 79a8cdf1fd
commit 43ff3e81f0
9 changed files with 482 additions and 22 deletions

106
scripts/cutVideoSegments.sh Executable file
View File

@@ -0,0 +1,106 @@
#!/bin/bash
set -e
# Read scenes.json and cut video into segments as GIFs
if [ ! -f "scenes.json" ]; then
echo "scenes.json not found"
exit 1
fi
if [ ! -f "ui-test.mp4" ]; then
echo "ui-test.mp4 not found"
exit 1
fi
echo "Cutting video into GIF segments based on scenes.json..."
GIF_SCALE="1024"
# Parse scenes.json and cut video segments as GIFs
node -e "
const fs = require('fs');
const { spawn } = require('child_process');
const scenes = JSON.parse(fs.readFileSync('scenes.json', 'utf8'));
console.log('Creating GIF segments...');
// Sanitize scene name to prevent path traversal and command injection
function sanitizeName(name) {
// Remove any characters that aren't alphanumeric, dash, or underscore
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
}
async function cutSegmentAsGif(scene, index) {
const safeName = sanitizeName(scene.name);
const segmentName = \`segment-\${String(index + 1).padStart(2, '0')}-\${safeName}\`;
const paletteFile = \`\${segmentName}-palette.png\`;
const outputFile = \`\${segmentName}.gif\`;
const startTime = scene.start / 1000; // Convert ms to seconds
const duration = scene.duration / 1000; // Convert ms to seconds
console.log(\`Creating \${outputFile} (start: \${startTime}s, duration: \${duration}s)\`);
// Step 1: Generate palette for this segment
await new Promise((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', [
'-y',
'-ss', startTime.toString(),
'-t', duration.toString(),
'-i', 'ui-test.mp4',
'-vf', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos,palettegen',
paletteFile
]);
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve();
} else {
console.error(\`Failed to create palette for \${outputFile}\`);
reject(new Error(\`ffmpeg palette generation exited with code \${code}\`));
}
});
});
// Step 2: Create GIF using the palette
await new Promise((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', [
'-y',
'-ss', startTime.toString(),
'-t', duration.toString(),
'-i', 'ui-test.mp4',
'-i', paletteFile,
'-filter_complex', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos[x];[x][1:v]paletteuse',
outputFile
]);
ffmpeg.on('close', (code) => {
// Clean up palette file
try {
fs.unlinkSync(paletteFile);
} catch (e) {
// Ignore cleanup errors
}
if (code === 0) {
resolve();
} else {
console.error(\`Failed to create \${outputFile}\`);
reject(new Error(\`ffmpeg GIF creation exited with code \${code}\`));
}
});
});
}
(async () => {
for (let i = 0; i < scenes.length; i++) {
await cutSegmentAsGif(scenes[i], i);
}
console.log('All GIF segments created successfully');
})().catch(err => {
console.error(err);
process.exit(1);
});
"
echo "Video segments created successfully"

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
const fs = require('fs');
// Read scenes.json
const scenes = JSON.parse(fs.readFileSync('scenes.json', 'utf8'));
// Get base URL from command line arguments
const baseUrl = process.argv[2];
if (!baseUrl) {
console.error('Usage: node generateMarkdownSummary.js <base-url>');
process.exit(1);
}
// Sanitize scene name to prevent path traversal
function sanitizeName(name) {
// Remove any characters that aren't alphanumeric, dash, or underscore
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
}
// Generate markdown
let markdown = '## 🎬 Demo Video Generated\n\n';
markdown += `### Full Video\n\n`;
markdown += `[📥 Download Full Video (MP4)](${baseUrl}/ui-test.mp4) | [GIF](${baseUrl}/ui-test.gif)\n\n`;
markdown += `---\n\n`;
markdown += `### 📑 Video Segments\n\n`;
markdown += `<details>\n`;
markdown += `<summary>Click to expand segments</summary>\n\n`;
scenes.forEach((scene, index) => {
const safeName = sanitizeName(scene.name);
const segmentFile = `segment-${String(index + 1).padStart(2, '0')}-${safeName}.gif`;
const title = scene.title || scene.name;
const duration = (scene.duration / 1000).toFixed(1);
markdown += `<details>\n`;
markdown += `<summary><strong>${index + 1}. ${title}</strong> (${duration}s)</summary>\n\n`;
markdown += `![${title}](${baseUrl}/${segmentFile})\n\n`;
markdown += `</details>\n\n`;
});
markdown += `</details>\n\n`;
markdown += `_Videos will expire in 90 days._`;
console.log(markdown);

View File

@@ -25,3 +25,12 @@ rm ffmpeg_info palette*.png qrawvideorgb24.yuv
mv app.mp4 ui-test.mp4
mv app720.gif ui-test.gif
# Cut video into segments based on scenes.json
echo "Cutting video into segments..."
if [ -f "scenes.json" ]; then
./scripts/cutVideoSegments.sh
else
echo "Warning: scenes.json not found, skipping segment creation"
fi

185
scripts/testVideoSegmentation.js Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env node
/**
* Integration test for video segmentation workflow
*
* This test validates:
* 1. SceneBuilder generates scenes with titles
* 2. Scenes are saved to scenes.json
* 3. cutVideoSegments script processes scenes correctly
* 4. generateMarkdownSummary creates proper output
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
function testSceneGeneration() {
console.log('Testing scene generation...');
// Check if compiled files exist
const distPath = path.join(__dirname, '../dist/src/spec/SceneBuilder.js');
if (!fs.existsSync(distPath)) {
console.log(' ⚠ Skipping test - TypeScript not compiled. Run "npx tsc" first.');
return null;
}
// Import and test SceneBuilder
const SceneBuilder = require('../dist/src/spec/SceneBuilder').SceneBuilder;
const SCENE_TITLES = require('../dist/src/spec/SceneBuilder').SCENE_TITLES;
// Verify SCENE_TITLES exist
const titleKeys = Object.keys(SCENE_TITLES);
if (titleKeys.length === 0) {
throw new Error('SCENE_TITLES is empty');
}
console.log(` ✓ Found ${titleKeys.length} scene titles`);
// Create a sample scene
const builder = new SceneBuilder();
const testScenes = [
{ name: 'connect', start: 0, stop: 1000, duration: 1000, title: SCENE_TITLES.connect },
{ name: 'numeric_plots', start: 1000, stop: 2000, duration: 1000, title: SCENE_TITLES.numeric_plots },
{ name: 'end', start: 2000, stop: 3000, duration: 1000, title: SCENE_TITLES.end },
];
// Manually populate scenes for testing
builder.scenes = testScenes;
// Save to JSON
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-workflow-'));
const scenesPath = path.join(tempDir, 'scenes.json');
fs.writeFileSync(scenesPath, JSON.stringify(builder.scenes, null, 2));
console.log(` ✓ Saved scenes to ${scenesPath}`);
return { tempDir, scenesPath, scenes: builder.scenes };
}
function testCuttingLogic(tempDir, scenes) {
console.log('Testing cutting logic...');
// Create dummy video file
const videoPath = path.join(tempDir, 'ui-test.mp4');
fs.writeFileSync(videoPath, 'dummy video content');
// Process scenes like the cutting script would (now creates GIFs)
const expectedSegments = [];
scenes.forEach((scene, index) => {
const outputFile = `segment-${String(index + 1).padStart(2, '0')}-${scene.name}.gif`;
const startTime = scene.start / 1000;
const duration = scene.duration / 1000;
expectedSegments.push({
filename: outputFile,
start: startTime,
duration: duration,
title: scene.title
});
});
console.log(` ✓ Processed ${expectedSegments.length} segments`);
// Verify segment naming
expectedSegments.forEach(seg => {
if (!seg.filename.match(/^segment-\d{2}-.+\.gif$/)) {
throw new Error(`Invalid segment filename: ${seg.filename}`);
}
});
console.log(' ✓ All segment filenames are valid');
return expectedSegments;
}
function testMarkdownGeneration(tempDir) {
console.log('Testing markdown generation...');
const baseUrl = 'https://example.com/test-pr-123';
const scenesPath = path.join(tempDir, 'scenes.json');
// Run markdown generation script
const originalCwd = process.cwd();
process.chdir(tempDir);
let markdown;
try {
markdown = execSync(
`node "${path.join(__dirname, 'generateMarkdownSummary.js')}" "${baseUrl}"`,
{ encoding: 'utf8' }
);
} finally {
process.chdir(originalCwd);
}
// Verify markdown content
if (!markdown.includes('## 🎬 Demo Video Generated')) {
throw new Error('Markdown missing title');
}
if (!markdown.includes('### Full Video')) {
throw new Error('Markdown missing full video section');
}
if (!markdown.includes('### 📑 Video Segments')) {
throw new Error('Markdown missing segments section');
}
if (!markdown.includes('<details>')) {
throw new Error('Markdown missing collapsible sections');
}
if (!markdown.includes('Connecting to MQTT Broker')) {
throw new Error('Markdown missing scene title');
}
if (!markdown.includes(baseUrl)) {
throw new Error('Markdown missing base URL');
}
console.log(' ✓ Markdown structure is valid');
console.log(' ✓ Markdown contains all required sections');
return markdown;
}
function cleanup(tempDir) {
console.log('Cleaning up...');
fs.rmSync(tempDir, { recursive: true, force: true });
console.log(' ✓ Temporary files removed');
}
// Run tests
try {
console.log('=== Video Segmentation Integration Test ===\n');
const result = testSceneGeneration();
if (!result) {
console.log('\n=== Test Skipped (TypeScript not compiled) ===\n');
console.log('Run "npx tsc" to compile TypeScript before running this test.');
process.exit(0);
}
const { tempDir, scenesPath, scenes } = result;
const segments = testCuttingLogic(tempDir, scenes);
const markdown = testMarkdownGeneration(tempDir);
cleanup(tempDir);
console.log('\n=== All Tests Passed ✓ ===\n');
// Display sample output
console.log('Sample Markdown Output:');
console.log('─'.repeat(60));
console.log(markdown.split('\n').slice(0, 25).join('\n'));
console.log('...');
console.log('─'.repeat(60));
process.exit(0);
} catch (error) {
console.error('\n✗ Test failed:', error.message);
console.error(error.stack);
process.exit(1);
}