Skip to content

Commit f103889

Browse files
committed
feat: add recording title image generation and template
1 parent a713587 commit f103889

5 files changed

Lines changed: 206 additions & 1 deletion

File tree

85.3 KB
Loading

agent.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,13 @@ abstract: |
6868
npm run generate:onsite _events/2026-01-20/index.md
6969
npm run generate:remote _events/2026-01-20/index.md
7070
ruby scripts/generate_meetup_image.rb
71+
npm run generate:recording-title -- _events/2026-01-20/index.md
7172
```
73+
74+
Recording title image notes:
75+
76+
- The generator reads `title`, `date`, and `speakers` from event front matter.
77+
- It writes `_events/<slug>/Title for Recording.png` and overwrites existing output.
78+
- The background template is `templates/Title for Recording.png`.
79+
- Text rendering uses Consolas.
80+
- Validate correctness by comparing generated outputs for `_events/2026-01-20/Title for Recording.png` and `_events/2025-12-16/Title for Recording.png` with their existing examples; only minor anti-aliasing/font-rendering differences are acceptable.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"serve": "bundle exec jekyll serve --incremental",
1313
"test:ci": "npm run build && playwright test",
1414
"generate:onsite": "node scripts/generate.js templates/onsite_event.txt",
15-
"generate:remote": "node scripts/generate.js templates/remote_event.txt"
15+
"generate:remote": "node scripts/generate.js templates/remote_event.txt",
16+
"generate:recording-title": "node scripts/generate_recording_title_image.js"
1617
},
1718
"devDependencies": {
1819
"@playwright/test": "^1.40.0"
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const path = require('path');
5+
const matter = require('gray-matter');
6+
const { chromium } = require('@playwright/test');
7+
8+
const ROOT_DIR = path.join(__dirname, '..');
9+
const TEMPLATE_PATH = path.join(ROOT_DIR, 'templates', 'Title for Recording.png');
10+
const OUTPUT_FILENAME = 'Title for Recording.png';
11+
12+
function printUsageAndExit() {
13+
console.error('Missing parameter: event markdown file path is required.');
14+
console.error('Usage: node scripts/generate_recording_title_image.js <event-file.md>');
15+
console.error('Example: node scripts/generate_recording_title_image.js _events/2026-01-20/index.md');
16+
process.exit(1);
17+
}
18+
19+
function assertFileExists(filePath, label) {
20+
if (!fs.existsSync(filePath)) {
21+
console.error(`Error: ${label} not found: ${filePath}`);
22+
process.exit(1);
23+
}
24+
}
25+
26+
function formatMeetupLine(dateValue) {
27+
const date = new Date(dateValue);
28+
if (Number.isNaN(date.getTime())) {
29+
console.error(`Error: Invalid frontmatter date: ${dateValue}`);
30+
process.exit(1);
31+
}
32+
33+
const monthYear = date.toLocaleDateString('en-US', {
34+
month: 'long',
35+
year: 'numeric'
36+
});
37+
38+
return `.NET Meetup ${monthYear}`;
39+
}
40+
41+
function normalizeSpeakers(speakersValue) {
42+
if (!Array.isArray(speakersValue) || speakersValue.length === 0) {
43+
console.error('Error: Frontmatter field "speakers" must be a non-empty array.');
44+
process.exit(1);
45+
}
46+
47+
return speakersValue
48+
.map(name => String(name).trim())
49+
.filter(Boolean)
50+
.join(', ');
51+
}
52+
53+
function escapeHtml(value) {
54+
return String(value)
55+
.replace(/&/g, '&amp;')
56+
.replace(/</g, '&lt;')
57+
.replace(/>/g, '&gt;')
58+
.replace(/"/g, '&quot;')
59+
.replace(/'/g, '&#039;');
60+
}
61+
62+
function buildHtml({ meetupLine, title, speakerLine, templateBase64 }) {
63+
return `<!DOCTYPE html>
64+
<html>
65+
<head>
66+
<meta charset="UTF-8" />
67+
<style>
68+
html, body {
69+
margin: 0;
70+
width: 1920px;
71+
height: 1080px;
72+
overflow: hidden;
73+
font-family: Consolas, "Liberation Mono", Menlo, Monaco, monospace;
74+
color: #6E2B7E;
75+
}
76+
77+
.canvas {
78+
position: relative;
79+
width: 1920px;
80+
height: 1080px;
81+
background-image: url('data:image/png;base64,${templateBase64}');
82+
background-size: cover;
83+
background-position: center;
84+
background-repeat: no-repeat;
85+
}
86+
87+
.content {
88+
position: absolute;
89+
left: 140px;
90+
right: 140px;
91+
top: 215px;
92+
text-align: center;
93+
display: flex;
94+
flex-direction: column;
95+
gap: 44px;
96+
align-items: center;
97+
}
98+
99+
.meetup {
100+
font-size: 54px;
101+
line-height: 1.14;
102+
font-weight: 700;
103+
max-width: 1560px;
104+
word-break: break-word;
105+
}
106+
107+
.title {
108+
font-size: 96px;
109+
line-height: 1.1;
110+
font-weight: 700;
111+
max-width: 1560px;
112+
word-break: break-word;
113+
}
114+
115+
.by {
116+
font-size: 44px;
117+
line-height: 1.16;
118+
font-weight: 700;
119+
max-width: 1560px;
120+
word-break: break-word;
121+
}
122+
</style>
123+
</head>
124+
<body>
125+
<div class="canvas">
126+
<div class="content">
127+
<div class="meetup">${escapeHtml(meetupLine)}</div>
128+
<div class="title">${escapeHtml(title)}</div>
129+
<div class="by">by ${escapeHtml(speakerLine)}</div>
130+
</div>
131+
</div>
132+
</body>
133+
</html>`;
134+
}
135+
136+
async function main() {
137+
const eventFileArg = process.argv[2];
138+
if (!eventFileArg) {
139+
printUsageAndExit();
140+
}
141+
142+
const eventFilePath = path.resolve(ROOT_DIR, eventFileArg);
143+
assertFileExists(eventFilePath, 'Event file');
144+
assertFileExists(TEMPLATE_PATH, 'Background template');
145+
146+
const raw = fs.readFileSync(eventFilePath, 'utf8');
147+
const parsed = matter(raw);
148+
149+
const { title, date, speakers } = parsed.data;
150+
151+
if (!title || String(title).trim() === '') {
152+
console.error('Error: Frontmatter field "title" is required.');
153+
process.exit(1);
154+
}
155+
156+
if (!date) {
157+
console.error('Error: Frontmatter field "date" is required.');
158+
process.exit(1);
159+
}
160+
161+
const meetupLine = formatMeetupLine(date);
162+
const speakerLine = normalizeSpeakers(speakers);
163+
const templateBase64 = fs.readFileSync(TEMPLATE_PATH).toString('base64');
164+
165+
const browser = await chromium.launch();
166+
167+
try {
168+
const page = await browser.newPage({
169+
viewport: { width: 1920, height: 1080 },
170+
deviceScaleFactor: 1
171+
});
172+
173+
const html = buildHtml({
174+
meetupLine,
175+
title: String(title).trim(),
176+
speakerLine,
177+
templateBase64
178+
});
179+
180+
await page.setContent(html, { waitUntil: 'networkidle' });
181+
182+
const outputPath = path.join(path.dirname(eventFilePath), OUTPUT_FILENAME);
183+
await page.screenshot({ path: outputPath, type: 'png' });
184+
185+
console.log(`Generated: ${outputPath}`);
186+
} finally {
187+
await browser.close();
188+
}
189+
}
190+
191+
main().catch(error => {
192+
console.error('Error while generating recording image:');
193+
console.error(error instanceof Error ? error.message : String(error));
194+
process.exit(1);
195+
});

templates/Title for Recording.png

26.4 KB
Loading

0 commit comments

Comments
 (0)