@deckio/deck-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/BottomBar.jsx +9 -0
- package/components/BottomBar.module.css +17 -0
- package/components/Navigation.jsx +195 -0
- package/components/Navigation.module.css +210 -0
- package/components/Slide.jsx +43 -0
- package/components/exportDeckPdf.js +142 -0
- package/components/exportDeckPptx.js +127 -0
- package/context/SlideContext.jsx +190 -0
- package/index.js +5 -0
- package/instructions/AGENTS.md +26 -0
- package/instructions/deck-config.instructions.md +34 -0
- package/instructions/deck-project.instructions.md +34 -0
- package/instructions/slide-css.instructions.md +96 -0
- package/instructions/slide-jsx.instructions.md +34 -0
- package/package.json +59 -0
- package/scripts/capture-screen.mjs +127 -0
- package/scripts/export-pdf.mjs +287 -0
- package/scripts/generate-image.mjs +110 -0
- package/scripts/init-project.mjs +214 -0
- package/skills/deck-add-slide/SKILL.md +236 -0
- package/skills/deck-delete-slide/SKILL.md +51 -0
- package/skills/deck-generate-image/SKILL.md +85 -0
- package/skills/deck-inspect/SKILL.md +60 -0
- package/skills/deck-sketch/SKILL.md +91 -0
- package/skills/deck-validate-project/SKILL.md +81 -0
- package/slides/GenericThankYouSlide.jsx +31 -0
- package/styles/global.css +392 -0
- package/themes/dark.css +151 -0
- package/themes/light.css +152 -0
- package/themes/shadcn.css +212 -0
- package/themes/theme-loader.js +47 -0
- package/vite.js +67 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Capture a screenshot of the deck app via headless Edge.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/capture-screen.mjs # project + port from state.md
|
|
7
|
+
* node scripts/capture-screen.mjs --project dev-plan # specific project
|
|
8
|
+
* node scripts/capture-screen.mjs --slide 3 # specific slide (1-based)
|
|
9
|
+
* node scripts/capture-screen.mjs --project dev-plan --slide 2 --label "cover-check"
|
|
10
|
+
*/
|
|
11
|
+
import puppeteer from 'puppeteer-core'
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
|
13
|
+
import path from 'path'
|
|
14
|
+
|
|
15
|
+
// Resolve browser path per-platform; prefer Edge, fall back to Chrome
|
|
16
|
+
function findBrowser() {
|
|
17
|
+
const platform = process.platform
|
|
18
|
+
if (platform === 'win32') {
|
|
19
|
+
return 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe'
|
|
20
|
+
}
|
|
21
|
+
if (platform === 'darwin') {
|
|
22
|
+
const edge = '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'
|
|
23
|
+
const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
24
|
+
return existsSync(edge) ? edge : chrome
|
|
25
|
+
}
|
|
26
|
+
// Linux ā common Chromium / Chrome / Edge paths
|
|
27
|
+
for (const p of ['/usr/bin/microsoft-edge', '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium']) {
|
|
28
|
+
if (existsSync(p)) return p
|
|
29
|
+
}
|
|
30
|
+
return 'google-chrome'
|
|
31
|
+
}
|
|
32
|
+
const BROWSER_PATH = findBrowser()
|
|
33
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
34
|
+
const args = process.argv.slice(2)
|
|
35
|
+
const arg = (name, fb) => {
|
|
36
|
+
const i = args.indexOf(`--${name}`)
|
|
37
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : fb
|
|
38
|
+
}
|
|
39
|
+
const numArg = (name, fb) => {
|
|
40
|
+
const v = arg(name, null)
|
|
41
|
+
return v != null ? Number(v) : fb
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const root = path.resolve(arg('root', process.cwd()))
|
|
45
|
+
const eyesDir = path.join(root, '.github', 'eyes')
|
|
46
|
+
const stateFile = path.join(root, '.github', 'memory', 'state.md')
|
|
47
|
+
|
|
48
|
+
const state = readState()
|
|
49
|
+
const PROJECT = arg('project', state.project || null)
|
|
50
|
+
const PORT = arg('port', state.port || '5175')
|
|
51
|
+
const SLIDE = numArg('slide', null)
|
|
52
|
+
const LABEL = arg('label', null)
|
|
53
|
+
const URL = `http://localhost:${PORT}/#/${PROJECT}`
|
|
54
|
+
|
|
55
|
+
if (!PROJECT) {
|
|
56
|
+
console.error('ā No project. Use --project <id> or set it in .github/memory/state.md')
|
|
57
|
+
process.exit(1)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readState() {
|
|
61
|
+
if (!existsSync(stateFile)) return {}
|
|
62
|
+
const c = readFileSync(stateFile, 'utf-8')
|
|
63
|
+
return {
|
|
64
|
+
project: c.match(/^project:\s*(.+)$/m)?.[1]?.trim() || '',
|
|
65
|
+
port: c.match(/^port:\s*(\d+)$/m)?.[1]?.trim() || '5175',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
mkdirSync(eyesDir, { recursive: true })
|
|
71
|
+
console.log(`šļø ${PROJECT} ${URL}${SLIDE ? ` slide ${SLIDE}` : ''}`)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetch(`http://localhost:${PORT}`, { signal: AbortSignal.timeout(3000) })
|
|
75
|
+
if (!response.ok) throw new Error('Dev server unavailable')
|
|
76
|
+
} catch {
|
|
77
|
+
console.error(`ā Dev server not reachable on port ${PORT}`)
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const browser = await puppeteer.launch({
|
|
82
|
+
executablePath: BROWSER_PATH,
|
|
83
|
+
headless: 'new',
|
|
84
|
+
args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
|
85
|
+
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 2 },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const page = await browser.newPage()
|
|
89
|
+
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 15000 })
|
|
90
|
+
await page.waitForFunction(() => document.querySelectorAll('.slide').length > 0, { timeout: 10000 })
|
|
91
|
+
await sleep(1500)
|
|
92
|
+
|
|
93
|
+
if (SLIDE && SLIDE > 1) {
|
|
94
|
+
const total = await page.evaluate(() => document.querySelectorAll('.slide').length)
|
|
95
|
+
for (let i = 0; i < total; i++) {
|
|
96
|
+
await page.keyboard.press('ArrowLeft')
|
|
97
|
+
await sleep(50)
|
|
98
|
+
}
|
|
99
|
+
for (let i = 0; i < SLIDE - 1; i++) {
|
|
100
|
+
await page.keyboard.press('ArrowRight')
|
|
101
|
+
await sleep(100)
|
|
102
|
+
}
|
|
103
|
+
await sleep(800)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await page.evaluate(() => {
|
|
107
|
+
document.querySelectorAll('[class*="nav"],[class*="Nav"],[class*="progress"],[class*="Progress"],[class*="hint"],[class*="Hint"],[class*="bottomBar"],[class*="BottomBar"]')
|
|
108
|
+
.forEach(el => {
|
|
109
|
+
if (el.tagName !== 'BODY' && el.tagName !== 'HTML') el.style.display = 'none'
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
await sleep(300)
|
|
113
|
+
|
|
114
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
115
|
+
const name = [PROJECT, SLIDE && `slide${SLIDE}`, LABEL, ts].filter(Boolean).join('-') + '.png'
|
|
116
|
+
const filepath = path.join(eyesDir, name)
|
|
117
|
+
await page.screenshot({ path: filepath, type: 'png' })
|
|
118
|
+
await browser.close()
|
|
119
|
+
|
|
120
|
+
const rel = path.relative(root, filepath).replace(/\\/g, '/')
|
|
121
|
+
console.log(`šø ${rel}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
main().catch(err => {
|
|
125
|
+
console.error('ā', err?.message || err)
|
|
126
|
+
process.exit(1)
|
|
127
|
+
})
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Export slides to PDF using Puppeteer + embedded Vite dev server.
|
|
4
|
+
*
|
|
5
|
+
* For project exports (--project), the script spins up its own Vite dev
|
|
6
|
+
* server so no separate `npm run dev` is needed.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node scripts/export-pdf.mjs --project dev-plan
|
|
10
|
+
* node scripts/export-pdf.mjs # ghcp slides (needs running dev server)
|
|
11
|
+
* node scripts/export-pdf.mjs --customer --customer-name Rabobank --from 12 --to 19
|
|
12
|
+
* node scripts/export-pdf.mjs --internal --from 2 --to 10
|
|
13
|
+
*/
|
|
14
|
+
import puppeteer from 'puppeteer'
|
|
15
|
+
import { existsSync, mkdirSync } from 'fs'
|
|
16
|
+
import path from 'path'
|
|
17
|
+
import { createServer } from 'vite'
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2)
|
|
20
|
+
const flag = (name) => args.includes(`--${name}`)
|
|
21
|
+
const getArg = (name, fallback) => {
|
|
22
|
+
const idx = args.indexOf(`--${name}`)
|
|
23
|
+
return idx !== -1 && args[idx + 1] ? Number(args[idx + 1]) : fallback
|
|
24
|
+
}
|
|
25
|
+
const getStringArg = (name, fallback) => {
|
|
26
|
+
const idx = args.indexOf(`--${name}`)
|
|
27
|
+
return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ROOT = path.resolve(getStringArg('root', process.cwd()))
|
|
31
|
+
const outDir = path.resolve(ROOT, getStringArg('out-dir', 'exports'))
|
|
32
|
+
const viteConfigPath = path.resolve(ROOT, getStringArg('config', 'vite.config.js'))
|
|
33
|
+
|
|
34
|
+
const PORT = getArg('port', 5173)
|
|
35
|
+
const FROM = getArg('from', null)
|
|
36
|
+
const TO = getArg('to', null)
|
|
37
|
+
const CUSTOMER_NAME = getStringArg('customer-name', null)
|
|
38
|
+
const PROJECT = getStringArg('project', null)
|
|
39
|
+
const isCustomer = flag('customer')
|
|
40
|
+
const slug = (s) => s.toLowerCase().replace(/\s+/g, '-')
|
|
41
|
+
const outFile = PROJECT
|
|
42
|
+
? (CUSTOMER_NAME
|
|
43
|
+
? `${slug(CUSTOMER_NAME)}-slides.pdf`
|
|
44
|
+
: `${PROJECT}-slides.pdf`)
|
|
45
|
+
: isCustomer
|
|
46
|
+
? `${slug(CUSTOMER_NAME || 'customer')}-slides.pdf`
|
|
47
|
+
: flag('internal') ? 'internal-slides.pdf' : 'slides.pdf'
|
|
48
|
+
|
|
49
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true })
|
|
50
|
+
|
|
51
|
+
async function hideNav(page) {
|
|
52
|
+
await page.evaluate(() => {
|
|
53
|
+
document.querySelectorAll(
|
|
54
|
+
'[class*="nav"], [class*="Nav"], [class*="progress"], ' +
|
|
55
|
+
'[class*="Progress"], [class*="hint"], [class*="Hint"]'
|
|
56
|
+
).forEach(el => {
|
|
57
|
+
if (el.tagName !== 'BODY' && el.tagName !== 'HTML') {
|
|
58
|
+
el.style.display = 'none'
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
65
|
+
|
|
66
|
+
async function goToSlide(page, targetIdx, totalSlides) {
|
|
67
|
+
for (let i = 0; i < totalSlides; i++) {
|
|
68
|
+
await page.keyboard.press('ArrowLeft')
|
|
69
|
+
await sleep(50)
|
|
70
|
+
}
|
|
71
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
72
|
+
await page.keyboard.press('ArrowRight')
|
|
73
|
+
await sleep(100)
|
|
74
|
+
}
|
|
75
|
+
await sleep(800)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function captureSlides(page, fromIdx, toIdx, totalSlides) {
|
|
79
|
+
await goToSlide(page, fromIdx, totalSlides)
|
|
80
|
+
const shots = []
|
|
81
|
+
for (let i = fromIdx; i <= toIdx; i++) {
|
|
82
|
+
console.log(` šø Slide ${i + 1}/${totalSlides}`)
|
|
83
|
+
await sleep(400)
|
|
84
|
+
shots.push(await page.screenshot({ type: 'png', encoding: 'binary' }))
|
|
85
|
+
if (i < toIdx) {
|
|
86
|
+
await page.keyboard.press('ArrowRight')
|
|
87
|
+
await sleep(700)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return shots
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function buildPDF(browser, screenshots, pdfPath) {
|
|
94
|
+
const pdfPage = await browser.newPage()
|
|
95
|
+
await pdfPage.setViewport({ width: 1920, height: 1080 })
|
|
96
|
+
|
|
97
|
+
const imgTags = screenshots.map(buf => {
|
|
98
|
+
const b64 = Buffer.from(buf).toString('base64')
|
|
99
|
+
return `<div class="page"><img src="data:image/png;base64,${b64}" /></div>`
|
|
100
|
+
}).join('\n')
|
|
101
|
+
|
|
102
|
+
await pdfPage.setContent(`<!DOCTYPE html>
|
|
103
|
+
<html><head><style>
|
|
104
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
105
|
+
html,body{width:1920px;overflow:hidden}
|
|
106
|
+
@page{size:1920px 1080px;margin:0}
|
|
107
|
+
.page{width:1920px;height:1080px;page-break-after:always;page-break-inside:avoid;overflow:hidden}
|
|
108
|
+
.page:last-child{page-break-after:avoid}
|
|
109
|
+
.page img{display:block;width:1920px;height:1080px;object-fit:fill}
|
|
110
|
+
</style></head><body>${imgTags}</body></html>`, { waitUntil: 'load' })
|
|
111
|
+
|
|
112
|
+
await pdfPage.pdf({
|
|
113
|
+
path: pdfPath,
|
|
114
|
+
width: '1920px',
|
|
115
|
+
height: '1080px',
|
|
116
|
+
printBackground: true,
|
|
117
|
+
preferCSSPageSize: true,
|
|
118
|
+
margin: { top: '0px', right: '0px', bottom: '0px', left: '0px' },
|
|
119
|
+
})
|
|
120
|
+
await pdfPage.close()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function exportProjectPDF() {
|
|
124
|
+
console.log(`\nš Exporting project: ${PROJECT}`)
|
|
125
|
+
|
|
126
|
+
const server = await createServer({
|
|
127
|
+
root: ROOT,
|
|
128
|
+
configFile: viteConfigPath,
|
|
129
|
+
server: { port: 0, strictPort: false, host: '127.0.0.1' },
|
|
130
|
+
logLevel: 'silent',
|
|
131
|
+
})
|
|
132
|
+
await server.listen()
|
|
133
|
+
const addr = server.httpServer.address()
|
|
134
|
+
const base = `http://127.0.0.1:${addr.port}`
|
|
135
|
+
console.log(` Vite server listening on ${base}`)
|
|
136
|
+
|
|
137
|
+
await sleep(2000)
|
|
138
|
+
|
|
139
|
+
const browser = await puppeteer.launch({
|
|
140
|
+
headless: true,
|
|
141
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
142
|
+
})
|
|
143
|
+
const page = await browser.newPage()
|
|
144
|
+
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 2 })
|
|
145
|
+
|
|
146
|
+
const url = `${base}/#/${PROJECT}`
|
|
147
|
+
console.log(`ā³ Loading ${url}`)
|
|
148
|
+
let loaded = false
|
|
149
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
150
|
+
try {
|
|
151
|
+
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 })
|
|
152
|
+
loaded = true
|
|
153
|
+
break
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.log(` Attempt ${attempt}/3 failed: ${err.message}`)
|
|
156
|
+
await sleep(2000)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!loaded) throw new Error('Could not load the Vite dev server after 3 attempts')
|
|
160
|
+
await page.goto(url, { waitUntil: 'networkidle0', timeout: 30000 })
|
|
161
|
+
|
|
162
|
+
await page.waitForFunction(
|
|
163
|
+
() => document.querySelectorAll('.slide').length > 0,
|
|
164
|
+
{ timeout: 15000 }
|
|
165
|
+
)
|
|
166
|
+
await sleep(1500)
|
|
167
|
+
|
|
168
|
+
await hideNav(page)
|
|
169
|
+
|
|
170
|
+
if (CUSTOMER_NAME) {
|
|
171
|
+
console.log(`š¤ Selecting customer: ${CUSTOMER_NAME}`)
|
|
172
|
+
const clicked = await page.evaluate(() => {
|
|
173
|
+
const btn = [...document.querySelectorAll('button')].find(b => b.textContent.includes('Customer Facing'))
|
|
174
|
+
if (btn) {
|
|
175
|
+
btn.click()
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
return false
|
|
179
|
+
})
|
|
180
|
+
if (!clicked) {
|
|
181
|
+
console.error('ā "Customer Facing" button not found')
|
|
182
|
+
await browser.close()
|
|
183
|
+
await server.close()
|
|
184
|
+
process.exit(1)
|
|
185
|
+
}
|
|
186
|
+
await sleep(800)
|
|
187
|
+
|
|
188
|
+
const picked = await page.evaluate(name => {
|
|
189
|
+
const btn = [...document.querySelectorAll('button')].find(b => b.textContent.trim().includes(name))
|
|
190
|
+
if (btn) {
|
|
191
|
+
btn.click()
|
|
192
|
+
return true
|
|
193
|
+
}
|
|
194
|
+
return false
|
|
195
|
+
}, CUSTOMER_NAME)
|
|
196
|
+
if (!picked) {
|
|
197
|
+
console.error(`ā Customer "${CUSTOMER_NAME}" not found`)
|
|
198
|
+
await browser.close()
|
|
199
|
+
await server.close()
|
|
200
|
+
process.exit(1)
|
|
201
|
+
}
|
|
202
|
+
await sleep(1000)
|
|
203
|
+
console.log('ā
Customer selected')
|
|
204
|
+
await hideNav(page)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const totalSlides = await page.evaluate(() => document.querySelectorAll('.slide').length)
|
|
208
|
+
const fromIdx = FROM ? FROM - 1 : 0
|
|
209
|
+
const toIdx = TO ? TO - 1 : totalSlides - 1
|
|
210
|
+
console.log(`š ${totalSlides} slides ā exporting ${fromIdx + 1}ā${toIdx + 1}`)
|
|
211
|
+
|
|
212
|
+
const screenshots = await captureSlides(page, fromIdx, toIdx, totalSlides)
|
|
213
|
+
|
|
214
|
+
const pdfPath = path.join(outDir, outFile)
|
|
215
|
+
await buildPDF(browser, screenshots, pdfPath)
|
|
216
|
+
|
|
217
|
+
await browser.close()
|
|
218
|
+
await server.close()
|
|
219
|
+
console.log(`\nā
PDF saved to ${pdfPath}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function exportDevServerPDF() {
|
|
223
|
+
const BASE = `http://localhost:${PORT}`
|
|
224
|
+
console.log(`\nā³ Connecting to dev server at ${BASE}`)
|
|
225
|
+
|
|
226
|
+
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
|
|
227
|
+
const page = await browser.newPage()
|
|
228
|
+
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 2 })
|
|
229
|
+
|
|
230
|
+
await page.goto(`${BASE}/#/ghcp`, { waitUntil: 'networkidle0', timeout: 30000 })
|
|
231
|
+
await page.waitForSelector('.slide.active', { timeout: 10000 })
|
|
232
|
+
|
|
233
|
+
if (isCustomer && CUSTOMER_NAME) {
|
|
234
|
+
console.log(`š¤ Selecting customer: ${CUSTOMER_NAME}`)
|
|
235
|
+
const clicked = await page.evaluate(() => {
|
|
236
|
+
const btn = [...document.querySelectorAll('button')].find(b => b.textContent.includes('Customer Facing'))
|
|
237
|
+
if (btn) {
|
|
238
|
+
btn.click()
|
|
239
|
+
return true
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
})
|
|
243
|
+
if (!clicked) {
|
|
244
|
+
console.error('ā "Customer Facing" button not found')
|
|
245
|
+
await browser.close()
|
|
246
|
+
process.exit(1)
|
|
247
|
+
}
|
|
248
|
+
await sleep(800)
|
|
249
|
+
|
|
250
|
+
const picked = await page.evaluate(name => {
|
|
251
|
+
const btn = [...document.querySelectorAll('button')].find(b => b.textContent.trim().includes(name))
|
|
252
|
+
if (btn) {
|
|
253
|
+
btn.click()
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
256
|
+
return false
|
|
257
|
+
}, CUSTOMER_NAME)
|
|
258
|
+
if (!picked) {
|
|
259
|
+
console.error(`ā Customer "${CUSTOMER_NAME}" not found`)
|
|
260
|
+
await browser.close()
|
|
261
|
+
process.exit(1)
|
|
262
|
+
}
|
|
263
|
+
await sleep(1000)
|
|
264
|
+
console.log('ā
Customer selected')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await hideNav(page)
|
|
268
|
+
|
|
269
|
+
const totalSlides = await page.evaluate(() => document.querySelectorAll('.slide').length)
|
|
270
|
+
const fromIdx = FROM ? FROM - 1 : 0
|
|
271
|
+
const toIdx = TO ? TO - 1 : totalSlides - 1
|
|
272
|
+
console.log(`š ${totalSlides} slides ā exporting ${fromIdx + 1}ā${toIdx + 1}`)
|
|
273
|
+
|
|
274
|
+
const screenshots = await captureSlides(page, fromIdx, toIdx, totalSlides)
|
|
275
|
+
|
|
276
|
+
const pdfPath = path.join(outDir, outFile)
|
|
277
|
+
await buildPDF(browser, screenshots, pdfPath)
|
|
278
|
+
|
|
279
|
+
await browser.close()
|
|
280
|
+
console.log(`\nā
PDF saved to ${pdfPath}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const run = PROJECT ? exportProjectPDF : exportDevServerPDF
|
|
284
|
+
run().catch(err => {
|
|
285
|
+
console.error('ā Export failed:', err.message)
|
|
286
|
+
process.exit(1)
|
|
287
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generate an image using OpenAI chatgpt-image-latest (Image API) and save
|
|
4
|
+
* it for use in slides.
|
|
5
|
+
*
|
|
6
|
+
* Requires OPENAI_API_KEY environment variable.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node scripts/generate-image.mjs --prompt "a bridge icon" --name bridge-icon
|
|
10
|
+
* node scripts/generate-image.mjs --prompt "..." --name hero --size 1536x1024
|
|
11
|
+
* node scripts/generate-image.mjs --prompt "..." --name icon --quality high
|
|
12
|
+
* node scripts/generate-image.mjs --prompt "..." --name x --model gpt-image-1.5
|
|
13
|
+
*/
|
|
14
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'
|
|
15
|
+
import path from 'path'
|
|
16
|
+
|
|
17
|
+
const root = process.cwd()
|
|
18
|
+
const args = process.argv.slice(2)
|
|
19
|
+
const arg = (name, fb) => {
|
|
20
|
+
const i = args.indexOf(`--${name}`)
|
|
21
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : fb
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const outputDir = arg('out-dir', path.join('src', 'data', 'generated'))
|
|
25
|
+
const imagesDir = path.resolve(root, outputDir)
|
|
26
|
+
const envPath = path.join(root, '.env')
|
|
27
|
+
|
|
28
|
+
if (existsSync(envPath)) {
|
|
29
|
+
for (const line of readFileSync(envPath, 'utf-8').split(/\r?\n/)) {
|
|
30
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.+)$/)
|
|
31
|
+
if (m) process.env[m[1]] = m[2].trim()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PROMPT = arg('prompt', null)
|
|
36
|
+
const NAME = arg('name', null)
|
|
37
|
+
const SIZE = arg('size', '1024x1024')
|
|
38
|
+
const QUALITY = arg('quality', 'auto')
|
|
39
|
+
const MODEL = arg('model', 'chatgpt-image-latest')
|
|
40
|
+
|
|
41
|
+
const API_KEY = process.env.OPENAI_API_KEY
|
|
42
|
+
if (!API_KEY) {
|
|
43
|
+
console.error('ā Set OPENAI_API_KEY environment variable')
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
if (!PROMPT) {
|
|
47
|
+
console.error('ā --prompt is required')
|
|
48
|
+
process.exit(1)
|
|
49
|
+
}
|
|
50
|
+
if (!NAME) {
|
|
51
|
+
console.error('ā --name is required (used as filename)')
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
mkdirSync(imagesDir, { recursive: true })
|
|
57
|
+
console.log(`šØ Generating image: "${PROMPT}"`)
|
|
58
|
+
console.log(` Model: ${MODEL} Size: ${SIZE} Quality: ${QUALITY}`)
|
|
59
|
+
|
|
60
|
+
const body = {
|
|
61
|
+
model: MODEL,
|
|
62
|
+
prompt: PROMPT,
|
|
63
|
+
n: 1,
|
|
64
|
+
size: SIZE,
|
|
65
|
+
quality: QUALITY,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const res = await fetch('https://api.openai.com/v1/images/generations', {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const err = await res.text()
|
|
79
|
+
console.error(`ā API error (${res.status}): ${err}`)
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const data = await res.json()
|
|
84
|
+
const imageB64 = data.data?.[0]?.b64_json || null
|
|
85
|
+
const imageUrl = data.data?.[0]?.url || null
|
|
86
|
+
|
|
87
|
+
if (!imageB64 && !imageUrl) {
|
|
88
|
+
console.error('ā Could not extract image from response.')
|
|
89
|
+
console.error(JSON.stringify(data).slice(0, 2000))
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const filepath = path.join(imagesDir, `${NAME}.png`)
|
|
94
|
+
let imgBuffer
|
|
95
|
+
if (imageB64) {
|
|
96
|
+
imgBuffer = Buffer.from(imageB64, 'base64')
|
|
97
|
+
} else {
|
|
98
|
+
const dl = await fetch(imageUrl)
|
|
99
|
+
imgBuffer = Buffer.from(await dl.arrayBuffer())
|
|
100
|
+
}
|
|
101
|
+
writeFileSync(filepath, imgBuffer)
|
|
102
|
+
console.log(`šø Saved: ${path.relative(root, filepath)}`)
|
|
103
|
+
console.log(`\nš¦ Import in a slide:`)
|
|
104
|
+
console.log(` import ${NAME}Img from '../../data/generated/${NAME}.png'`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
main().catch(err => {
|
|
108
|
+
console.error('ā', err?.message || err)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
})
|