/** * Regenerate editable decks and versioned video frames using an explicitly supplied artifact runtime. * * @author Laurent Declercq l.declercq@agon-innovation.ch * @version 20260921 */ import fs from 'node:fs/promises'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; import { spawnSync } from 'node:child_process'; const { values: args } = parseArgs({ options: { 'locale': { type: 'string' }, 'output': { type: 'string' }, 'source': { type: 'string' }, 'update-artifacts': { type: 'boolean' }, 'help': { type: 'boolean' } } }); if (args.help) { console.log('Usage: yarn presentation:slides [--locale en|de|fr] [--output NEW_DIRECTORY | --update-artifacts] [--source DIRECTORY]\nRequires ATRIUM_PRESENTATION_NODE_MODULES, ATRIUM_PRESENTATION_SKILL, and ATRIUM_PRESENTATION_PYTHON. Writes refreshed PNG frames and validated PPTX files. --update-artifacts replaces SOURCE/artifacts/decks after validation.'); process.exit(0); } if (args.output && args['update-artifacts']) { throw new Error('Choose either --output for a draft or --update-artifacts for the versioned decks.'); } const root = path.resolve(import.meta.dirname, '../../..'); const source = path.resolve(args.source || path.join(root, 'docs/presentation')); const runtime = process.env.ATRIUM_PRESENTATION_NODE_MODULES; const skill = process.env.ATRIUM_PRESENTATION_SKILL; const python = process.env.ATRIUM_PRESENTATION_PYTHON; if (!runtime || !skill || !python) { throw new Error('Set ATRIUM_PRESENTATION_NODE_MODULES, ATRIUM_PRESENTATION_SKILL, and ATRIUM_PRESENTATION_PYTHON. See docs/presentation/README.md. Video-only builds do not require these.'); } const requireRuntime = createRequire(path.join(path.resolve(runtime), '..', 'package.json')); // The finalizer's child import check uses its own documented runtime variable. process.env.RUNTIME_NODE_MODULES = path.resolve(runtime); const sharp = requireRuntime('sharp'); const { Presentation, PresentationFile } = await import(pathToFileURL(requireRuntime.resolve('@oai/artifact-tool')).href); const { finalizePresentation } = await import(pathToFileURL(path.join(skill, 'container_tools/artifact_tool_utils.mjs')).href); await fs.mkdir(path.join(root, '.build/presentation'), { recursive: true }); const tmp = await fs.mkdtemp(path.join(root, '.build/presentation/render-')); await fs.mkdir(path.join(tmp, 'slides')); const output = path.resolve(args.output || path.join(tmp, 'decks')); if (output !== root && !output.startsWith(root + path.sep)) { throw new Error('Keep the validated PPTX output inside the repository workspace.'); } await fs.mkdir(output, { recursive: true }); const story = JSON.parse(await fs.readFile(path.join(source, 'storyboard.json'), 'utf8')); if (args.locale && !Object.hasOwn(story.languages, args.locale)) { throw new Error('Unknown presentation language.'); } const manifestPath = path.join(source, 'source/slides/manifest.json'); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); const logo = await sharp(path.join(root, 'src/showcase/public/favicon.svg')).resize(240, 240).png().toBuffer(); const cover = await fs.readFile(path.join(source, 'source/cover.png')); /** * Hash stable slide inputs for stale-frame detection. * * @param {string | Buffer} value - Content to hash. * @returns {string} SHA-256 digest. */ function sha(value) { return createHash('sha256').update(value).digest('hex'); } /** * Load authoritative narration through the same parser used by video generation. * * @param {string} file - Language TXT file. * @param {object[]} slides - Ordered storyboard slides. * @returns {Promise} Narration indexed by stable slide ID. */ async function narrationSections(file, slides) { const ids = []; for (const slide of slides) { ids.push(slide.id); } const script = 'import sys,json; from pathlib import Path; sys.path.insert(0,sys.argv[1]); from pipeline import parse_narration; print(json.dumps(parse_narration(Path(sys.argv[2]).read_text(encoding=\'utf-8\'),json.loads(sys.argv[3]))))'; const result = spawnSync(python, [ '-c', script, import.meta.dirname, file, JSON.stringify(ids) ], { encoding: 'utf8' }); if (result.error || result.status !== 0) { throw new Error(result.stderr || 'Cannot read narration.'); } return JSON.parse(result.stdout); } const font = 'Arial'; /** * Add an editable branded text box to a slide. * * @param {object} slide - Target slide. * @param {string} copy - Visible copy. * @param {number} x - Left coordinate. * @param {number} y - Top coordinate. * @param {number} w - Width. * @param {number} h - Height. * @param {number} size - Font size. * @param {string} [color] - Text color. * @param {boolean} [bold] - Whether text is bold. * @returns {object} Editable text shape. */ function text(slide, copy, x, y, w, h, size, color = '#F5F7FA', bold = false) { const s = slide.shapes.add({ geometry: 'textbox', position: { left: x, top: y, width: w, height: h }, fill: 'none', line: { fill: 'none', width: 0 } }); s.text = copy; s.text.style = { typeface: font, fontSize: size, color, bold, autoFit: 'none', verticalAlignment: 'middle', wrap: true }; return s; } /** * Place an original screenshot without altering its aspect ratio. * * @param {object} slide - Target slide. * @param {string} locale - Screenshot language. * @param {string} name - Screenshot basename. * @param {number} x - Left coordinate. * @param {number} y - Top coordinate. * @param {number} w - Width. * @param {number} h - Height. * @returns {Promise} Resolves after the screenshot is added. */ async function picture(slide, locale, name, x, y, w, h) { const bytes = await fs.readFile(path.join(source, `source/screenshots/${locale}-${name}.png`)); slide.images.add({ blob: bytes, contentType: 'image/png', alt: `Agon Atrium ${name} (${locale})`, fit: 'contain', position: { left: x, top: y, width: w, height: h } }); } for (const [ locale, slides ] of Object.entries(story.languages)) { if (args.locale && args.locale !== locale) { continue; } const narration = await narrationSections(path.join(source, `narration/${locale}.txt`), slides); const records = {}; const p = Presentation.create({ slideSize: { width: 1920, height: 1080 } }); for (const [ i, data ] of slides.entries()) { const s = p.slides.add(); s.background.fill = '#080D15'; const isCover = data.id === 'cover' || data.id === 'close'; if (isCover) { s.images.add({ blob: cover, contentType: 'image/png', alt: 'Technology workspace overlooking an Alpine lake, conceptual brand artwork', position: { left: 0, top: 0, width: 1920, height: 1080 }, fit: 'cover' }); s.images.add({ blob: logo, contentType: 'image/png', alt: 'Agon Partners Innovation AG logo', position: { left: 100, top: 95, width: 105, height: 105 }, fit: 'contain' }); text(s, 'AGON PARTNERS\nINNOVATION AG', 235, 95, 600, 110, 38, '#FFFFFF', true); text(s, data.title, 100, data.id === 'cover' ? 342 : 324, 765, 310, data.id === 'cover' ? 122 : 76, '#FFFFFF', true); text(s, data.body, 105, 652, 700, 180, 40, '#E2EDF4'); if (data.id === 'cover') { text(s, ({ en: 'ADMIN DASHBOARD TEMPLATE', de: 'ADMIN-DASHBOARD-VORLAGE', fr: 'MODÈLE DE TABLEAU DE BORD' })[locale], 105, 902, 850, 45, 23, '#92DDEA'); } } else { s.images.add({ blob: logo, contentType: 'image/png', alt: 'Agon logo', position: { left: 96, top: 60, width: 60, height: 60 }, fit: 'contain' }); text(s, 'Agon Ātrium', 176, 64, 420, 58, 32, '#F5F7FA', true); text(s, `${String(i + 1).padStart(2, '0')} / ${slides.length}`, 1715, 76, 112, 36, 22, '#7E91A7'); if ([ 'terminal', 'vnc' ].includes(data.id)) { text(s, data.title.replace('\n', ' '), 96, 190, 1728, 105, 64, '#FFFFFF', true); text(s, data.body, 100, 307, 1700, 90, 31, '#AEC0D3'); await picture(s, locale, data.screenshots[0], 220, 408, 1480, 590); } else if (data.id === 'charts') { text(s, data.title.replace('\n', ' '), 96, 185, 1728, 110, 70, '#FFFFFF', true); text(s, data.body, 100, 298, 1700, 76, 32, '#AEC0D3'); await picture(s, locale, 'chart', 96, 410, 1138, 590); await picture(s, locale, 'chart-donut', 1284, 474, 540, 518); } else if (data.id === 'dialogs') { text(s, data.title.replace('\n', ' '), 96, 185, 1728, 110, 70, '#FFFFFF', true); text(s, data.body, 100, 298, 1700, 76, 32, '#AEC0D3'); await picture(s, locale, 'dialog', 96, 427, 840, 525); await picture(s, locale, 'drawer', 984, 427, 840, 525); } else if (data.id === 'library') { text(s, data.title, 96, 250, 490, 210, 62, '#FFFFFF', true); text(s, data.body, 100, 525, 452, 180, 33, '#AEC0D3'); await picture(s, locale, 'icons', 700, 260, 1124, 703); await picture(s, locale, 'buttons', 96, 795, 550, 174); } else if (data.id === 'responsive') { text(s, data.title, 96, 210, 640, 280, 60, '#FFFFFF', true); text(s, data.body, 100, 535, 540, 155, 33, '#AEC0D3'); await picture(s, locale, 'login', 674, 394, 756, 472); await picture(s, locale, 'mobile', 1460, 254, 363, 726); } else { text(s, data.title, 96, 251, 470, 300, 58, '#FFFFFF', true); text(s, data.body, 100, 602, 449, 245, 32, '#AEC0D3'); await picture(s, locale, data.screenshots[0], 612, 218, 1212, 758); } text(s, 'AGON PARTNERS INNOVATION AG', 96, 1024, 790, 34, 19, '#7891A6'); text(s, ({ en: 'SHOWCASE · SAMPLE DATA', de: 'SHOWCASE · BEISPIELDATEN', fr: 'SHOWCASE · DONNÉES DE DÉMONSTRATION' })[locale], 1120, 1024, 705, 34, 18, '#7891A6'); } s.speakerNotes.textFrame.setText(`${narration[data.id]}\n\nSource: actual Agon Atrium production showcase, repository snapshot 2026-09-19. Screenshots: ${data.screenshots.join(', ')}. UI and business data are illustrative showcase fixtures. Opening artwork: AI-generated concept inspired by the user-provided great.png reference. Original Agon logo preserved. Narration is synthetic.`); const rendered = await p.export({ slide: s, format: 'png', scale: 1 }); await fs.writeFile(path.join(tmp, `slides/${locale}-${String(i + 1).padStart(2, '0')}.png`), new Uint8Array(await rendered.arrayBuffer())); const key = `${locale}-${String(i + 1).padStart(2, '0')}`; const visual = JSON.stringify([ data.id, data.title, data.body, data.screenshots ]); const assetInputs = [ sha(await fs.readFile(path.join(root, 'src/showcase/public/favicon.svg'))), isCover ? sha(cover) : null ]; for (const name of data.screenshots) { assetInputs.push([ name, sha(await fs.readFile(path.join(source, `source/screenshots/${locale}-${name}.png`))) ]); } records[key] = { visualHash: sha(visual), imageHash: sha(await fs.readFile(path.join(tmp, `slides/${key}.png`))), assetHash: sha(JSON.stringify(assetInputs)) }; console.log('Rendered', locale, i + 1); } const candidatePath = path.join(tmp, `${locale}-candidate.pptx`); await (await PresentationFile.exportPptx(p)).save(candidatePath); const finalPath = path.join(output, `agon-atrium-${locale}.pptx`); await finalizePresentation({ workspaceDir: root, candidatePath, finalPath, pythonExecutable: python, integrityValidatorPath: path.join(skill, 'container_tools/inspect_presentation_package_integrity.py'), layoutValidatorPath: path.join(skill, 'container_tools/inspect_presentation_layout_geometry.py'), layoutArgs: [ '--expected-slide-size-emu', '18288000,10287000', '--validate-heading-fit' ], fontPolicy: { basis: 'design', families: [ font ] }, verifyArtifactToolImport: true, receiptPath: path.join(tmp, `${locale}-validation.json`) }); if (args['update-artifacts']) { const decks = path.join(source, 'artifacts/decks'); await fs.mkdir(decks, { recursive: true }); await fs.copyFile(finalPath, path.join(decks, `agon-atrium-${locale}.pptx`)); console.log('Updated deck', path.join(decks, `agon-atrium-${locale}.pptx`)); } for (const [ key, record ] of Object.entries(records)) { await fs.copyFile(path.join(tmp, `slides/${key}.png`), path.join(source, `source/slides/${key}.png`)); manifest[key] = record; } await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); console.log('Finalized', locale, finalPath); }