Skip to content

Source code

Source code

src/scripts/presentation/render-slides.mjs

This file is included in this documentation build. It is displayed as code and is not executed.

Download source

/**
 * 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<object>} 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<void>} 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);
}