Skip to content

Commit 2d7f081

Browse files
Screenshots script
1 parent 9c08b72 commit 2d7f081

2 files changed

Lines changed: 267 additions & 0 deletions

File tree

screenshot-catalog.json

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
{
2+
"generated": "2026-04-21T10:22:44.270Z",
3+
"screenshots": [
4+
{
5+
"path": "screenshots-output/series-3-angular-material/employee-create-form.png",
6+
"audioPath": "screenshots-output/series-3-angular-material/employee-create-form.wav",
7+
"series": "series-3-angular-material",
8+
"filename": "employee-create-form.png",
9+
"capturedAt": "2026-04-21T10:23:08.161Z",
10+
"description": "Material dialog showing Create Employee reactive form — fields for name, email, department, position, hire date, gender — with inline validation.",
11+
"narration": "Clicking Create opens a Material dialog with a reactive form. All fields use Angular Material form controls with built-in validation. Errors appear inline as you type, following the Material Design specification.",
12+
"articles": [
13+
"3.2",
14+
"3.3"
15+
],
16+
"tags": [
17+
"employee-form",
18+
"reactive-forms",
19+
"mat-dialog",
20+
"validation",
21+
"angular-material"
22+
],
23+
"useFor": "Illustrate the reactive form inside a Material dialog for the forms and dialogs articles."
24+
},
25+
{
26+
"path": "screenshots-output/series-3-angular-material/department-list-table.png",
27+
"audioPath": "screenshots-output/series-3-angular-material/department-list-table.wav",
28+
"series": "series-3-angular-material",
29+
"filename": "department-list-table.png",
30+
"capturedAt": "2026-04-21T10:23:29.738Z",
31+
"description": "Department management page — Material data table with department names and edit/delete action buttons.",
32+
"narration": "The department list follows the same Material table pattern as the employee list. Managers can create, edit, and delete departments. The table refreshes automatically after each operation.",
33+
"articles": [
34+
"3.1"
35+
],
36+
"tags": [
37+
"department-list",
38+
"data-table",
39+
"crud",
40+
"angular-material"
41+
],
42+
"useFor": "Illustrate the department management feature alongside the employee list."
43+
},
44+
{
45+
"path": "screenshots-output/series-3-angular-material/position-list-table.png",
46+
"audioPath": "screenshots-output/series-3-angular-material/position-list-table.wav",
47+
"series": "series-3-angular-material",
48+
"filename": "position-list-table.png",
49+
"capturedAt": "2026-04-21T10:23:49.468Z",
50+
"description": "Position management page — HRAdmin-only table of job positions with title, department, and salary range columns.",
51+
"narration": "Positions are visible only to the HRAdmin role. The ngx-permissions directive hides this page from Managers and Employees entirely — both in the sidebar and via route guard.",
52+
"articles": [
53+
"1.4",
54+
"3.1"
55+
],
56+
"tags": [
57+
"position-list",
58+
"hradmin",
59+
"role-based-ui",
60+
"data-table",
61+
"ngx-permissions"
62+
],
63+
"useFor": "Demonstrate HRAdmin-only feature access for role-based UI articles."
64+
},
65+
{
66+
"path": "screenshots-output/series-3-angular-material/salary-ranges-table.png",
67+
"audioPath": "screenshots-output/series-3-angular-material/salary-ranges-table.wav",
68+
"series": "series-3-angular-material",
69+
"filename": "salary-ranges-table.png",
70+
"capturedAt": "2026-04-21T10:24:08.874Z",
71+
"description": "Salary Range management page restricted to HRAdmin — table with range label, minimum and maximum salary columns.",
72+
"narration": "Salary ranges are an HRAdmin-only feature. They define the pay bands that Positions reference, creating a hierarchy from Salary Range down to Position down to Employee.",
73+
"articles": [
74+
"1.4",
75+
"3.1"
76+
],
77+
"tags": [
78+
"salary-ranges",
79+
"hradmin",
80+
"role-based-ui",
81+
"data-table"
82+
],
83+
"useFor": "Show the HRAdmin-exclusive salary range management feature."
84+
},
85+
{
86+
"path": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card.png",
87+
"audioPath": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card.wav",
88+
"series": "series-6-ai-app-features",
89+
"filename": "dashboard-ai-insights-card.png",
90+
"capturedAt": "2026-04-21T10:24:31.787Z",
91+
"description": "Dashboard with AI Insights mat-card at the top — LLM-generated plain-English executive summary of live workforce metrics from Ollama.",
92+
"narration": "With AI enabled, the dashboard now opens with an executive summary generated by Ollama. The card appears above the metric cards and automatically refreshes each time the dashboard loads with the latest workforce data.",
93+
"articles": [
94+
"6.4"
95+
],
96+
"tags": [
97+
"dashboard",
98+
"ai-insights",
99+
"ollama",
100+
"executive-summary",
101+
"mat-card"
102+
],
103+
"useFor": "Hero image for Article 6.4; shows AI card in context above the metric cards."
104+
},
105+
{
106+
"path": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card-closeup.png",
107+
"audioPath": "screenshots-output/series-6-ai-app-features/dashboard-ai-insights-card-closeup.wav",
108+
"series": "series-6-ai-app-features",
109+
"filename": "dashboard-ai-insights-card-closeup.png",
110+
"capturedAt": "2026-04-21T10:24:47.463Z",
111+
"description": "Close-up of the AI Insights mat-card — smart_toy icon, title, and generated executive summary text.",
112+
"narration": "The AI insights card uses the smart toy Material icon, a card title, and the generated summary text. The summary is typically three to four sentences and references the actual numbers from the database.",
113+
"articles": [
114+
"6.4"
115+
],
116+
"tags": [
117+
"dashboard",
118+
"ai-insights",
119+
"mat-card",
120+
"closeup",
121+
"smart-toy-icon"
122+
],
123+
"useFor": "Inline image for the Article 6.4 step-by-step walkthrough showing the finished card."
124+
}
125+
]
126+
}

scripts/build-video.ps1

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
param(
2+
[string]$InputDir = "",
3+
[string]$OutputFile = "blog-narrated-video.mp4",
4+
[string]$Series = "",
5+
[switch]$AllSeries,
6+
[int]$PadSeconds = 1
7+
)
8+
9+
# build-video.ps1
10+
#
11+
# Stitches PNG screenshots + WAV narrations into a narrated MP4 slideshow.
12+
# Requires FFmpeg on PATH: https://ffmpeg.org/download.html
13+
#
14+
# Usage:
15+
# # Single series
16+
# .\build-video.ps1 -Series "series-1-authentication" -OutputFile "auth.mp4"
17+
#
18+
# # All series into one video
19+
# .\build-video.ps1 -AllSeries -OutputFile "blog-full-series.mp4"
20+
#
21+
# # Custom input folder
22+
# .\build-video.ps1 -InputDir "screenshots-output\series-6-ai-app-features" -OutputFile "ai.mp4"
23+
24+
Set-StrictMode -Version Latest
25+
$ErrorActionPreference = 'Stop'
26+
27+
# Verify FFmpeg is available
28+
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
29+
Write-Error "FFmpeg not found on PATH. Download from https://ffmpeg.org/download.html and add to PATH."
30+
exit 1
31+
}
32+
33+
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
34+
$SubmoduleRoot = Split-Path -Parent $ScriptDir
35+
$ScreenshotsRoot = Join-Path $SubmoduleRoot "screenshots-output"
36+
37+
# Resolve which folders to process
38+
$folders = @()
39+
if ($InputDir -ne "") {
40+
$folders = @($InputDir)
41+
} elseif ($AllSeries) {
42+
$folders = Get-ChildItem -Path $ScreenshotsRoot -Directory | Sort-Object Name | Select-Object -ExpandProperty FullName
43+
} elseif ($Series -ne "") {
44+
$folders = @(Join-Path $ScreenshotsRoot $Series)
45+
} else {
46+
Write-Error "Specify -Series <name>, -AllSeries, or -InputDir <path>."
47+
exit 1
48+
}
49+
50+
# Collect PNG+WAV pairs across all resolved folders
51+
$pairs = @()
52+
foreach ($folder in $folders) {
53+
if (-not (Test-Path $folder)) {
54+
Write-Warning "Folder not found, skipping: $folder"
55+
continue
56+
}
57+
$pngs = Get-ChildItem -Path $folder -Filter "*.png" | Sort-Object Name
58+
foreach ($png in $pngs) {
59+
$wav = [System.IO.Path]::ChangeExtension($png.FullName, ".wav")
60+
if (Test-Path $wav) {
61+
$pairs += [PSCustomObject]@{ PNG = $png.FullName; WAV = $wav }
62+
} else {
63+
Write-Warning "No WAV for $($png.Name) — skipping (run the screenshot tests first)."
64+
}
65+
}
66+
}
67+
68+
if ($pairs.Count -eq 0) {
69+
Write-Error "No PNG+WAV pairs found. Run: npx playwright test --project=screenshots"
70+
exit 1
71+
}
72+
73+
Write-Host "Building video from $($pairs.Count) slides..."
74+
75+
# Temp folder for per-slide clips
76+
$TempDir = Join-Path $env:TEMP "pw-slides-$(Get-Date -Format 'yyyyMMddHHmmss')"
77+
New-Item -ItemType Directory -Path $TempDir | Out-Null
78+
79+
$clipList = Join-Path $TempDir "clips.txt"
80+
$clips = @()
81+
82+
for ($i = 0; $i -lt $pairs.Count; $i++) {
83+
$pair = $pairs[$i]
84+
$clipOut = Join-Path $TempDir "clip_$($i.ToString('D4')).mp4"
85+
86+
# Get WAV duration so the image is shown for exactly that long + padding
87+
$durationJson = & ffprobe -v quiet -print_format json -show_streams $pair.WAV 2>&1 | ConvertFrom-Json
88+
$wavDuration = [double]($durationJson.streams[0].duration)
89+
$slideDuration = [Math]::Round($wavDuration + $PadSeconds, 2)
90+
91+
Write-Host " Slide $($i+1)/$($pairs.Count): $([System.IO.Path]::GetFileNameWithoutExtension($pair.PNG)) ($($slideDuration)s)"
92+
93+
# Build one clip: PNG scaled to 1920x1080 (letterboxed) + WAV + pad silence
94+
& ffmpeg -y `
95+
-loop 1 -t $slideDuration -i $pair.PNG `
96+
-i $pair.WAV `
97+
-vf "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black" `
98+
-c:v libx264 -preset fast -crf 22 `
99+
-c:a aac -b:a 128k `
100+
-shortest `
101+
-pix_fmt yuv420p `
102+
$clipOut 2>&1 | Out-Null
103+
104+
if (-not (Test-Path $clipOut)) {
105+
Write-Warning "FFmpeg failed on slide $($i+1), skipping."
106+
continue
107+
}
108+
109+
$clips += $clipOut
110+
Add-Content -Path $clipList -Value "file '$clipOut'"
111+
}
112+
113+
if ($clips.Count -eq 0) {
114+
Write-Error "No clips were created. Check FFmpeg output above."
115+
exit 1
116+
}
117+
118+
# Resolve output path relative to submodule root if not absolute
119+
if (-not [System.IO.Path]::IsPathRooted($OutputFile)) {
120+
$OutputFile = Join-Path $SubmoduleRoot $OutputFile
121+
}
122+
123+
Write-Host ""
124+
Write-Host "Concatenating $($clips.Count) clips into: $OutputFile"
125+
126+
& ffmpeg -y -f concat -safe 0 -i $clipList -c copy $OutputFile 2>&1 | Out-Null
127+
128+
# Cleanup temp clips
129+
Remove-Item -Recurse -Force $TempDir
130+
131+
if (Test-Path $OutputFile) {
132+
$size = [Math]::Round((Get-Item $OutputFile).Length / 1MB, 1)
133+
Write-Host ""
134+
Write-Host "Done: $OutputFile ($size MB)"
135+
Write-Host ""
136+
Write-Host "Play with: Start-Process '$OutputFile'"
137+
Write-Host "Or open in any media player."
138+
} else {
139+
Write-Error "Output file was not created. Check FFmpeg errors above."
140+
exit 1
141+
}

0 commit comments

Comments
 (0)