Adds the render-and-export half of the slice: a Playwright-based render
worker that takes resolved BannerSpecs, builds self-contained HTML5 ad
units (absolute-positioned DOM, GSAP timeline, IAB clickTag, baked
typography + fonts), captures a backup PNG, and packages each into a zip
on disk. A new POST /api/export route drives it from /review via an
"Export ZIPs (N)" button.
What ships
- packages/render-worker (new package, promoted from the apps/ placeholder)
- build-runtime-html: pure function producing the self-contained
index.html. Inlines @font-face for Inter Regular/Bold, the resolved
spec as JSON, GSAP's minified bundle, and a runtime IIFE that mirrors
build-timeline.ts semantics (fade_in / fade_out / hold + padding tween).
Layers with a fade_in event ship with opacity:0 inline to avoid a
first-frame flash.
- render-to-zip: stages temp dir, opens Playwright file://, awaits
document.fonts.ready, pauses window.__tl and seeks to t=1.0s,
screenshots backup.png, assembles JSZip on disk under
exports/<safe(campaign_id)>/<safe(version_id)>.zip.
- render-many: single Chromium, min(N,3) concurrent contexts, fonts
+ GSAP read once. Returns { exported, errors } so a single bad spec
doesn't kill the batch.
- safe-slug: sanitises path segments to [A-Za-z0-9_-], 'unknown' fallback.
- 14 tests (8 build-runtime-html + 6 safe-slug). All green.
- apps/web/app/api/export/route.ts: validates each incoming spec with
BannerSpecSchema (failures land in the errors list, not a 400), dynamic-
imports the render-worker, calls renderMany, returns paths + byte sizes.
- apps/web/app/review/ReviewClient.tsx: export state machine (idle |
exporting | done | error), "Export ZIPs (N)" button beside Play-All,
success summary listing zip paths.
- apps/render-worker/ deleted; superseded by the package shape.
dropflow wasm wiring (Day-3 bug that surfaced on Day 4)
dropflow 0.5.1's wasm.js does `await environment.wasmLocator()` at module
top level. In Next.js 14.2.x, webpack's experiments.topLevelAwait flag is
ignored (regression vs 14.1.x), so any locator install that runs after
the dropflow chunk loads is too late — wasm.js's default locator throws
"Wasm location not configured" at module-init time.
Fix is a small pnpm patch on dropflow/dist/src/environment-browser.js,
which is imported by api.js BEFORE wasm.js via the `#register-default-
environment` browser-condition subpath. The patch installs a wasmLocator
that fetches /dropflow.wasm at exactly the right point in dropflow's
own initialization. patches/dropflow@0.5.1.patch is committed and wired
through pnpm.patchedDependencies.
Supporting changes in the layout-engine to keep the static module graph
dropflow-free where possible:
- browser/wasm-locator.ts: dynamic-imports dropflow/environment.js.
- browser/dropflow-wrapper.ts: dynamic-imports dropflow itself; uses an
inline structural type instead of `import type * as DropflowNs`.
- core/measure.ts: removed eager dropflow import; receives the namespace
via setDropflow() from whichever wrapper inits first.
- apps/web/lib/engine.ts: dynamic-imports the layout-engine browser
barrel so dropflow doesn't enter /review's synchronous graph.
- BannerCanvas.tsx + ParityClient.tsx: pull DEMO_TEMPLATE_300x250 from
the dropflow-free @banner-studio/layout-engine/templates subpath
rather than /browser. ParityClient also dynamic-imports resolveLayout.
Other plumbing
- next.config.mjs: server externals for playwright / playwright-core /
chromium-bidi / jszip / @banner-studio/render-worker so webpack doesn't
try to bundle dynamic CJS requires it can't trace.
- SLICE_DEVIATIONS.md: added §6 (render-worker as a package not an app),
§7 (hero image remote-referenced, not bundled), §8 (CM360 click-macro
deferred — IAB clickTag only).
Acceptance gate (Day 4)
- pnpm -r test: 40 tests across 4 packages green.
- pnpm -r build: clean, /api/export present in the Next route table.
- Manual smoke: /review → Export ZIPs → real .zip files in
exports/demo-campaign/. Unzipped index.html opens in Chrome, animates,
and the clickTag opens example.com/<row>.url in a new tab.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
136 lines
5.1 KiB
JavaScript
136 lines
5.1 KiB
JavaScript
// Next.js config. Two webpack concerns:
|
|
//
|
|
// 1. dropflow's Node entry tries to `require('canvas')`. We don't need canvas
|
|
// (we only use the wasm layout path). Mark it external + ignored.
|
|
//
|
|
// 2. The API route imports the Node dropflow build, and the export route
|
|
// pulls in Playwright. Keep dropflow, the Anthropic SDK, and Playwright
|
|
// out of the server-side bundle to avoid native-binding warnings and
|
|
// webpack's failed module traces.
|
|
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import fs from 'node:fs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Load repo-root .env. Next normally reads .env from the app dir (apps/web),
|
|
// but the slice keeps the API key at the monorepo root so it's shared. Walk
|
|
// up and parse it ourselves — minimal, no dotenv dep.
|
|
(function loadRepoRootEnv() {
|
|
const envPath = path.join(__dirname, '..', '..', '.env');
|
|
if (!fs.existsSync(envPath)) return;
|
|
const text = fs.readFileSync(envPath, 'utf8');
|
|
for (const line of text.split('\n')) {
|
|
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
if (!m) continue;
|
|
const [, k, v] = m;
|
|
if (!(k in process.env)) process.env[k] = v.replace(/^["']|["']$/g, '');
|
|
}
|
|
})();
|
|
|
|
/** @type {import('next').NextConfig} */
|
|
const nextConfig = {
|
|
reactStrictMode: true,
|
|
transpilePackages: [
|
|
'@banner-studio/types',
|
|
'@banner-studio/layout-engine',
|
|
'@banner-studio/api-lib',
|
|
'@banner-studio/prompts'
|
|
],
|
|
experimental: {
|
|
// Don't try to bundle these for the server runtime; require them at runtime.
|
|
serverComponentsExternalPackages: [
|
|
'@anthropic-ai/sdk',
|
|
'dropflow',
|
|
// playwright pulls in chromium-bidi via dynamic CJS requires that
|
|
// webpack can't trace; keep it out of the bundle so Node resolves it
|
|
// at runtime inside the /api/export route.
|
|
'playwright',
|
|
'playwright-core',
|
|
'chromium-bidi',
|
|
'jszip',
|
|
'@banner-studio/render-worker'
|
|
]
|
|
},
|
|
webpack(config, { isServer }) {
|
|
// asyncWebAssembly: dropflow ships a .wasm file we load via fetch; this
|
|
// also enables webpack's async-module semantics for any wasm-importing
|
|
// chain.
|
|
//
|
|
// topLevelAwait: REQUIRED. dropflow's wasm.js has
|
|
// const buffer = await environment.wasmLocator();
|
|
// at top level. Without this flag, webpack treats wasm.js as a regular
|
|
// sync module and the top-level await fires at module-evaluation time,
|
|
// BEFORE our `await import('dropflow')` can run the locator setup.
|
|
// With the flag on, wasm.js is an async module; `await import('dropflow')`
|
|
// properly waits for the wasm bundle to resolve, and our pre-import
|
|
// call to ensureBrowserLocator() runs first.
|
|
config.experiments = {
|
|
...config.experiments,
|
|
asyncWebAssembly: true,
|
|
topLevelAwait: true
|
|
};
|
|
|
|
if (isServer) {
|
|
// dropflow's Node env optionally requires `canvas` (a native dep we
|
|
// don't use). Tell webpack it's external so it stays an `import()` call
|
|
// resolved at runtime by Node, where the optional require can simply
|
|
// fail without breaking the build.
|
|
//
|
|
// Also externalize dropflow itself. Its environment-node.js does
|
|
// `readFileSync(new URL('../dropflow.wasm', import.meta.url))`. Webpack
|
|
// rewrites `import.meta.url` to a path that breaks the URL constructor
|
|
// contract `readFileSync` expects. Easier than patching webpack's
|
|
// rewrite: keep dropflow out of the bundle entirely and let Node
|
|
// resolve it at runtime where `import.meta.url` is the real file URL.
|
|
config.externals = [
|
|
...(Array.isArray(config.externals) ? config.externals : [config.externals]),
|
|
{ canvas: 'commonjs canvas' },
|
|
({ request }, cb) => {
|
|
if (request === 'dropflow' || request?.startsWith('dropflow/')) {
|
|
return cb(null, 'module ' + request);
|
|
}
|
|
// Playwright and its native helpers must resolve at runtime; webpack
|
|
// can't trace its dynamic requires and tries to bundle chromium-bidi
|
|
// CJS subpaths that aren't exported.
|
|
if (
|
|
request === 'playwright' ||
|
|
request?.startsWith('playwright/') ||
|
|
request === 'playwright-core' ||
|
|
request?.startsWith('playwright-core/') ||
|
|
request === 'chromium-bidi' ||
|
|
request?.startsWith('chromium-bidi/') ||
|
|
request === '@banner-studio/render-worker' ||
|
|
request?.startsWith('@banner-studio/render-worker/')
|
|
) {
|
|
return cb(null, 'module ' + request);
|
|
}
|
|
cb();
|
|
}
|
|
];
|
|
} else {
|
|
// Client bundles never need 'canvas'.
|
|
config.resolve.fallback = {
|
|
...config.resolve.fallback,
|
|
canvas: false
|
|
};
|
|
}
|
|
|
|
return config;
|
|
},
|
|
async headers() {
|
|
return [
|
|
{
|
|
source: '/dropflow.wasm',
|
|
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }]
|
|
},
|
|
{
|
|
source: '/fonts/(.*)',
|
|
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }]
|
|
}
|
|
];
|
|
}
|
|
};
|
|
|
|
export default nextConfig;
|