Files
OpenMAIC/eslint.config.mjs
T
杨慎andClaude Opus 4.8 6d29241bb0 feat(video-export): fidelity polish — spotlight geometry, video clips, formulas, subtitles (#952)
* fix(video-export): bridge play_video element id → media ref

Generated-media records are keyed by the element's media ref (gen_vid_…),
but a play_video action targets the slide element by its .id. The app-side
compiler deps looked media/durations up by the raw element id, so every
generated video missed → present:false → no <video> emitted, no bytes
collected → the clip vanished from the export (only its static first frame,
baked into the base PNG, remained).

Build an elementId → mediaRef bridge across all slide scenes in
createVideoTimelineDeps (mirroring the live engine's resolveMediaPlaceholderId)
and resolve both media() and videoDurationMs() through it.

Refs #867.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): re-measure KaTeX fit-scale after fonts settle

KaTeX sizes large delimiters (\left\{, \begin{cases}) from its KaTeX_Size
faces, which load asynchronously. KatexContent measured its shrink-to-fit
scale once on mount, against the fallback font, and never recomputed — so a
cold slide snapshot baked a stale scale and the big brace desynced from the
piecewise body ("大括号后面的分段函数与大括号错位").

- Re-run the fit-scale measurement on document.fonts.ready and on each
  `loadingdone`, keeping the shrink-only (cap-at-1) behavior.
- slideToPng waits two extra frames before capture so the font-triggered
  React re-measure commits into the DOM html2canvas reads.

Refs #867.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): place spotlight/laser/video on the rendered content box

The pure geometry helper resolves an element's authored outer box against a
fixed 1000×562.5 base, but the live overlays — and the exported frame PNG —
measure the element's `.element-content` box, which for horizontal text is
auto-height plus 10px content padding. So a spotlight/laser sat offset from
where the text actually renders ("spotlight 与对应元素的位置有偏差", #867 item 5).

Add an optional GeometryProbe to the compiler's DI boundary: when supplied, the
geometry pass prefers each element's measured content-box geometry (video keeps
its rotation from the authored element), degrading to the pure authored-box calc
on a miss so the compiler stays deterministic in unit contexts.

App side: measureSlideElementGeometry (new, in @openmaic/renderer/snapshot)
mounts the slide off-screen and reads the same `.element-content`-against-
container box the live SpotlightOverlay uses; createVideoTimelineDeps
pre-measures every spotlight/laser/play_video target per scene and serves the
probe as a synchronous table lookup.

Tests: geometry.test.ts quantifies the outer-box vs content-box delta and proves
probe-wins / fallback / rotation-preservation; timeline-deps.test.ts covers the
pre-measure wiring.

Refs #867.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): decode a first-frame poster for posterless videos

A play_video clip only shows during its play window; the rest of the time the
static base-frame PNG backs that spot. But html2canvas can't draw a <video>, so
the snapshot swaps it for its poster <img> — and generated videos usually carry
no poster, leaving the video area blank whenever the clip isn't playing.

When a video record has no stored poster bytes, decode the video blob's first
frame (seek slightly off 0, draw to a canvas) and set element.poster before the
base snapshot, so that spot shows the first frame — the "paused on frame one"
look — instead of blank. Falls back to no poster on decode failure / timeout /
CORS taint, never failing the export.

Refs #867.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(video-export): split subtitles, make burn-in optional, centered modal

Burned-in captions used one cue per whole narration paragraph, so a long cue
became a 4–6 line block covering the lower half of the slide, and there was no
way to turn it off.

- Split cues (pure, compiler layer): lib/video-export/split-cue.ts splits each
  cue on sentence → clause → hard-wrap to ≤~40 CJK-equiv units, distributing the
  parent window by character weight (last piece pinned to the parent end, sub-1.2s
  slivers merged). Wired into the timeline pass, so the burned-in overlay and the
  SRT/VTT sidecar share one split track and can't drift.
- Burn-in optional (default off, #867 item 2): emitHyperframes gains
  burnInSubtitles; off → clean video + sidecar SRT/VTT only. Caption layout hard-
  clamped to 2 lines so an outlier can't grow tall again.
- Download subtitles without rendering: compileSubtitles() runs only the compiler
  (no assets/zip/service); new useDownloadSubtitles saves an .srt.
- Centered modal: video-export-dialog.tsx replaces the cramped dropdown section,
  grouping Output / Subtitles / Actions / Progress with a stable lifecycle for
  multi-minute renders. header-controls opens it from an "Export Video" entry;
  old video-export-menu.tsx removed. i18n keys added across all 8 locales.

Refs #867.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style: prettier format video-export fidelity changes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop unused playVideo helper in timeline-deps test

The media-bridge tests assert via deps.assets.media(...) directly, so the
playVideo helper (and its PlayVideoAction import) were dead. Flagged by the
code-quality bot on #952.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): resolve cross-review findings + lower subtitle band

- emit-hyperframes: drop duplicate `display:-webkit-box` on subtitle cues so
  they start truly `display:none` (a second declaration overrode the `none`,
  showing every cue stacked at t=0 when burn-in was enabled).
- geometry: don't feed the measured content-box AABB to a rotated video clip —
  it already encloses the rotation, so re-applying `rotate` doubled it. Rotated
  elements fall back to the authored box (one, un-doubled source of truth).
- build-export-zip: extract shared `compileStageIr` so buildExportZip and
  compileSubtitles can't drift on the timing/assets/geometry wiring.
- split-cue: join merged CJK cue pieces without a spurious ASCII space.
- subtitles: lower the caption band (bottomRatio 0.055 → 0.01) and hoist the
  band's magic numbers into a named SUBTITLE constants block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): stop the snapshot onclone override from reshaping KaTeX

The export snapshot injects a neutral-kerning / geometricPrecision style over
`.slide-renderer-prose *` to keep CJK prose+table glyphs from mis-advancing
under html2canvas-pro. KaTeX formulas (output:'html') also live inside
`.slide-renderer-prose`, so the override was reshaping their math glyph
advances — a burned-in formula rendered subtly differently from the live
canvas, which applies no such override.

Empirical check (Playwright + system Chrome, cases+fraction formula, native
paint vs html2canvas): the override raised the formula's mean per-pixel delta
vs the live paint from 120.1 to 139.2 (~16%) and moved ~75% of inked pixels.
Restoring native font-kerning/text-rendering for `.katex` subtrees brings the
formula's delta back to 120.1 (bit-identical to the no-override capture) while
prose and table text keep the override. The residual 120.1 is the deeper
html2canvas rasterization gap (vlist/frac-line/delimiter), a separate track.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): render slides via native paint, bundle KaTeX font embed

Root-fix the exported-vs-live formula distortion (and the filter/mask/video
gaps) by rasterizing slides with native Chrome paint instead of html2canvas's
reimplementation.

- slideToPng now captures via html-to-image (foreignObject → the same Chrome
  engine that paints the live classroom rasterizes the same DOM). KaTeX
  formulas, CSS filter, soft-edge masks, and mixed CJK/Latin text come out as
  the classroom shows them. html2canvas-pro stays as a fallback (with the
  filter/mask bakes + KaTeX text-rendering reset) for when native paint can't
  run — e.g. a cross-origin image taints the canvas.

- A foreignObject SVG can't reach the document font registry, so fonts must be
  inlined. KaTeX math faces are prepended from the bundled woff2
  (KATEX_FONT_EMBED_CSS, generated by scripts/generate-katex-fonts.mjs), NOT
  left to runtime getFontEmbedCSS — which reads cssRules and silently drops a
  cross-origin KaTeX stylesheet, collapsing a large brace to a fallback glyph
  (the observed "formula render failure" on e.g. a \begin{cases} with \text{}).
  A per-face guard verifies every KaTeX face the formula references is embedded,
  falling back to html2canvas otherwise.

Verified in a Playwright spike (native vs html2canvas vs html-to-image): formula
inked-delta 143→53; filter/mask/video match native; and with getFontEmbedCSS
forced empty, the bundled embed alone still renders the failing formula
correctly. Renderer package tests: 226 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): prettier-format the generated KaTeX font-embed module

The generator emitted an unformatted TS file (double quotes, long unwrapped CSS
literal) that failed CI `prettier --check`. Run the generated output through the
repo's Prettier config before writing, so every rebuild produces CI-clean,
stable output. Regenerated the committed file to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lint): rename reserved `module` var in katex-fonts generator; ignore .scratch

- generate-katex-fonts.mjs assigned to a variable named `module`, tripping
  @next/next/no-assign-module-variable (the one hard eslint error). Renamed to
  `fileContents`; generated output is byte-identical (deterministic).
- Add `.scratch/**` to the eslint globalIgnores (alongside .claude/.worktrees)
  so local throwaway verification scaffolding doesn't pollute `pnpm lint`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): address PR #952 review — scene-scoped media bridge, safer cue splitting

Resolves the two blocking correctness issues and three follow-ups from the
cross-review on #952.

- Scope the elementId→mediaRef bridge by scene (was deck-wide/last-writer-wins):
  slide-local ids like `video_001` now resolve within their own scene. `assets.media`
  uses its scene arg; `timing.videoDurationMs` resolves via action object identity.
  Adds a cross-scene collision test.
- Stop treating every ASCII period as a sentence boundary: a lone `.` splits only
  when not between word chars (3.14, v1.2) and not an abbreviation dot (e.g.),
  collapsing repeated-punctuation runs. Adds decimal/version/abbrev/ellipsis tests.
- Share the module-level in-flight guard across ZIP export and subtitle download
  (extracted to export-in-flight.ts); dialog `busy` folds in `downloading`.
- Skip the off-screen geometry render for the subtitles-only path (skipGeometry);
  duration probes are kept so sidecar cue timing stays in sync with the video.
- Align measure.ts font settling with slideToPng: force-load each used face before
  fonts.ready so measured content-boxes don't bake fallback advance widths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(video-export): prettier-format split-cue test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(video-export): enable beginFrame capture via PRODUCER_BROWSER_GPU_MODE=hardware

The producer defaults to `software` GPU mode, which silently force-enables the
CPU-bound `Page.captureScreenshot` fallback: SwiftShader pins every core on
rasterization and caps frame capture at ~10 fps. Selecting `hardware` keeps
Chromium's `HeadlessExperimental.beginFrame` capture active instead.

Measured on a 900-frame (30s) clip, no real GPU on the host:
- capture ~10 -> ~19 fps, total 100s -> 62s (~38% faster)
- CPU peak ~810% -> ~98%; peak mem 2.2 GiB -> 0.9 GiB
- static frames pixel-identical; animation frames differ only in sub-pixel
  edge antialiasing (same-path re-render is deterministic/inf PSNR)

No real GPU is required — the win is the faster capture API, not hardware
rasterization; beginFrame falls back gracefully if the host lacks a GPU. Also
eases the RENDER_JOB_DEADLINE_MS timeout risk on long videos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(video-export): sentence splitting via Intl.Segmenter + abbreviation merge

Addresses the PR #952 follow-up: separate sentence-boundary detection from cue
shaping and stop growing a custom punctuation heuristic.

- splitSentences now delegates boundary detection to Intl.Segmenter (locale-
  agnostic ICU), the multilingual/CJK baseline. It already preserves 3.14, v1.2,
  e.g., U.S., ellipses, and CJK 。!?… without any hand-rolled period scan.
- ICU still ends a sentence after a titlecase abbreviation before a capitalized
  word (Dr. Smith, Fig. 3) — the edge case the reviewer flagged. A small
  abbreviation-aware post-merge re-joins those, driven by an ABBREVIATIONS set.
- Semicolons are no longer sentence ends (they join clauses); moved ;; into the
  clause-level SECONDARY splitter so over-budget sentences still break there.
- Cue shaping (budget / clause / hard-wrap / weighted timing) is unchanged, so
  detection and shaping are now distinct stages.

Not using `sbd` as suggested: lib/video-export/*.ts sits behind the machine-
enforced eslint purity boundary (imports limited to @openmaic/dsl, zod,
../choreography, siblings), so a new npm dep is rejected. Intl.Segmenter is a
pure Node >=20.9 builtin, matching the reviewer's "small abbreviation-aware
post-merge layer" alternative.

Tests: add Dr. Smith / Fig. 3 / U.S. coverage; the rhetorical ellipsis now stays
one under-budget cue (was split); add a semicolon over-budget case. 22/22 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(video-export): prettier-format split-cue sentence refactor

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n(es-MX): add 7 subtitle/video export keys to align with en-US

main added the Spanish (Mexico) locale (#942) after this branch's subtitle
keys landed, so es-MX was the only locale missing export.subtitles* and
export.videoBurnSubtitles* — which failed the i18n key-alignment CI check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(video-export): restrict title post-merge so it never deletes real boundaries

Addresses the blocking re-review on the Intl.Segmenter change: the abbreviation
post-merge unconditionally rejoined the next ICU segment whenever the previous
one ended in a listed abbreviation, which erased genuine sentence boundaries —
`It was made by Acme Inc. Next point.` collapsed into one cue, and even an
explicit newline (`Acme Inc.\nNext topic`) was rejoined.

The merge now fires only for the case ICU actually mis-splits — a title
(Mr/Mrs/Ms/Dr/Prof/Sr/Jr) before a capitalized proper noun — and only when:
  - the previous segment does not end at a newline (hard boundary), and
  - the next segment starts with a capital letter.

Sentence-trailing abbreviations (Inc./Ltd./etc./vs./No.) are dropped from the
set entirely: they legitimately end sentences, and ICU already keeps a title
before a number (No. 5, Fig. 3) or a lowercase continuation (etc. and…) whole
without any merge, so nothing there needs rejoining.

Regression tests: Inc./etc. boundary preserved, newline-after-title preserved,
lowercase continuation kept whole, plus the retained Dr. Smith / Fig. 3 / U.S.
cases. 26 split-cue tests pass; full video-export suite 123 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 02:58:04 -04:00

368 lines
17 KiB
JavaScript

import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
// Third-party / vendored packages (not our code):
'packages/docs/**',
'packages/mathml2omml/**',
'packages/pptxgenjs/**',
// Our own @openmaic/* packages: lint the source, but skip build output,
// installed deps, and the vendored JS sources under importer/src1.
'packages/@openmaic/*/dist/**',
'packages/@openmaic/*/node_modules/**',
'packages/@openmaic/importer/src1/**',
// Generated importer bundle copied into public/ by the sync script (postinstall):
'public/vendor/**',
// Claude Code local files:
'.claude/**',
'.superpowers/**',
'.worktrees/**',
'.scratch/**',
// Playwright e2e tests (not React code):
'e2e/**',
// Isolated MP4 render service: its own package, tsconfig, and Node-only
// deps (@hyperframes/producer). Linted/typechecked under render-service/.
'render-service/**',
]),
{
rules: {
// Dynamic AI-generated image URLs from various providers are incompatible
// with next/image (requires known dimensions and whitelisted domains).
'@next/next/no-img-element': 'off',
// Allow unused vars/args prefixed with _ (common convention for intentionally
// unused destructured values, callback params, etc.)
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
},
],
},
},
// Package boundary (machine-enforced): @openmaic/renderer is a standalone,
// app-agnostic package. It must never reach back into the host app through
// the `@/…` path alias, so a deadline can't punch a "temporary"
// store/undo/media dependency through the package API. Host concerns
// (document + undo ownership, media resolution, i18n, hotkeys) are injected
// via props/callbacks instead.
//
// Policy: the package must contain NO `@/…` path-alias string at all. `@/` is
// exclusively the host-app import alias, and the package authors zero such
// strings, so we match the string prefix wherever it appears rather than
// chasing individual call shapes. This is complete against every single-literal
// module-reference form — static / `import type` / `export … from`, dynamic
// `import()`, `require()`, `require.resolve()`, `import.meta.resolve()`, their
// computed-property (`require['resolve']`) and template-literal variants, and
// string-concatenation operands (`'@/lib/' + x`) — because all of them contain
// a `@/` literal. One rule, one report per violation.
//
// Out of scope (undecidable by lint, evasion-only): a specifier assembled
// entirely from non-`@/` pieces (`'@' + '/x'`, a variable) and relative parent
// escapes (`../../app`). Those are caught by building/publishing the package in
// isolation (only `@openmaic/dsl` + declared peers external), not by this rule.
{
files: ['packages/@openmaic/renderer/**/*.{ts,tsx,js,jsx,mjs,cjs}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^@\\//]',
message:
'@openmaic/renderer must not reference a host-app path (@/…). This package authors no `@/…` strings — depend only on @openmaic/dsl and declared peers, and inject host concerns (stores, undo, media resolution, i18n, hotkeys) via props/callbacks.',
},
{
selector: 'TemplateElement[value.cooked=/^@\\//]',
message:
'@openmaic/renderer must not reference a host-app path (@/…) in a template literal. Depend only on @openmaic/dsl and declared peers; inject host concerns via props/callbacks.',
},
],
},
},
// Package boundary (machine-enforced): @openmaic/storage is a standalone,
// app-agnostic persistence package. Same policy as the @openmaic/renderer
// boundary above — it must contain NO `@/…` host-app path-alias string, so a
// deadline can't punch a "temporary" host dependency through the package API.
// It depends only on @openmaic/dsl; host wiring (which store persists where)
// lives in the app, which imports the package, never the reverse.
{
files: ['packages/@openmaic/storage/**/*.{ts,tsx,js,jsx,mjs,cjs}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^@\\//]',
message:
'@openmaic/storage must not reference a host-app path (@/…). This package authors no `@/…` strings — depend only on @openmaic/dsl. The app wires its stores through the package, not the reverse.',
},
{
selector: 'TemplateElement[value.cooked=/^@\\//]',
message:
'@openmaic/storage must not reference a host-app path (@/…) in a template literal. Depend only on @openmaic/dsl.',
},
],
},
},
// Module boundary (machine-enforced): lib/choreography is the shared
// orchestration spec (timing + action timeline). It lives in the app (not a
// package) because its semantics co-evolve with the playback engine, but it
// must stay pure so the classroom-video exporter can interpret it in a pure
// Node environment. Two guards, mirroring the package boundaries above:
// 1. NO `@/…` host-app path-alias string — it authors none; it depends only
// on @openmaic/dsl (types + the fire-and-forget partition) and relative
// siblings. The app and exporter import it, never the reverse.
// 2. NO React / DOM / render-backend runtime import (react, react-dom, gsap,
// framer-motion, motion) — these are bare specifiers the `@/` rule can't
// see. A descriptor describes animation; it never renders it.
{
files: ['lib/choreography/**/*.{ts,tsx,js,jsx,mjs,cjs}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^@\\//]',
message:
'lib/choreography must not reference a host-app path (@/…). It authors no `@/…` strings — depend only on @openmaic/dsl and relative siblings, so the exporter can interpret it in pure Node. The app and exporter import it, not the reverse.',
},
{
selector: 'TemplateElement[value.cooked=/^@\\//]',
message:
'lib/choreography must not reference a host-app path (@/…) in a template literal. Depend only on @openmaic/dsl and relative siblings.',
},
// Import allowlist (static imports/re-exports): the ONLY permitted
// sources are `@openmaic/dsl`(/subpaths), `zod`, and in-folder relatives
// (`./…`). Anything else — a parent-escape `../…` reaching back into the
// app, or any other bare package — fails. Enforced on Import/Export
// source string nodes via a negative-lookahead so the guard is a true
// allowlist, not a blocklist of known-bad names.
{
selector:
'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/).+/]',
message:
'lib/choreography may import only from @openmaic/dsl, zod, or in-folder relatives (./…). No parent-escape (../…) into the app and no other packages — keep it pure so the exporter runs in plain Node.',
},
{
selector:
'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/).+/]',
message:
'lib/choreography may re-export only from @openmaic/dsl, zod, or in-folder relatives (./…).',
},
{
selector:
'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/).+/]',
message:
'lib/choreography may re-export only from @openmaic/dsl, zod, or in-folder relatives (./…).',
},
// No dynamic import() or require() — they bypass the static allowlist and
// can pull in a render backend at runtime.
{
selector: 'ImportExpression',
message:
'lib/choreography must not use dynamic import() — it bypasses the static import allowlist. Use a top-level import from @openmaic/dsl, zod, or a relative sibling.',
},
{
selector: "CallExpression[callee.name='require']",
message:
'lib/choreography must not use require() — it bypasses the static import allowlist.',
},
],
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'react',
'react-dom',
'react/*',
'react-dom/*',
'gsap',
'gsap/*',
'framer-motion',
'motion',
'motion/*',
],
message:
'lib/choreography must stay render-backend-agnostic (pure Node): no React / DOM / GSAP / framer-motion. It describes timing and animation as data; the app effect components and the exporter render it.',
},
],
},
],
},
},
// Module boundary (machine-enforced): lib/video-export is the classroom-video
// compiler (issue #864). It produces the VideoTimeline IR with pure passes and
// must stay interpretable in pure Node (no FFmpeg / Chrome / DOM), so runtime
// app state enters only through the injected TimingProbe / AssetSource. Guards,
// mirroring lib/choreography: no `@/…` host path, no React/DOM/render backend,
// no dynamic import()/require(), and an import-source allowlist.
//
// The relative-path allowance is DEPTH-SPECIFIC, because a single `../…` means
// different things at different depths: from a module-root file it escapes the
// module (`lib/video-export/../action` = `lib/action`), but from `passes/` it
// stays inside (`lib/video-export/passes/../ir` = `lib/video-export/ir`). So
// the boundary is split into two disjoint file scopes (root `*` does not match
// `passes/`), each with its own source allowlist; everything else is shared and
// duplicated verbatim.
//
// Root files — lib/video-export/*.ts: relative imports may only be `./…`
// (children) or the declared cross-module dep `../choreography`. Any other
// `../…` (e.g. `../action/engine`) escapes the module and is rejected.
{
files: ['lib/video-export/*.{ts,tsx,js,jsx,mjs,cjs}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^@\\//]',
message:
'lib/video-export must not reference a host-app path (@/…). Live app state enters only through the injected TimingProbe / AssetSource — depend only on @openmaic/dsl, zod, ../choreography, and relative siblings, so the compiler runs in pure Node.',
},
{
selector: 'TemplateElement[value.cooked=/^@\\//]',
message:
'lib/video-export must not reference a host-app path (@/…) in a template literal. Depend only on @openmaic/dsl, zod, ../choreography, and relative siblings.',
},
{
selector:
'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/|\\.\\.\\/choreography(\\/|$)).+/]',
message:
'lib/video-export root files may import only from @openmaic/dsl, zod, ../choreography, or in-module children (./…). A ../… into anything but ../choreography escapes the module and is rejected.',
},
{
selector:
'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/|\\.\\.\\/choreography(\\/|$)).+/]',
message:
'lib/video-export root files may re-export only from @openmaic/dsl, zod, ../choreography, or in-module children (./…).',
},
{
selector:
'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/|\\.\\.\\/choreography(\\/|$)).+/]',
message:
'lib/video-export root files may re-export only from @openmaic/dsl, zod, ../choreography, or in-module children (./…).',
},
{
selector: 'ImportExpression',
message:
'lib/video-export must not use dynamic import() — it bypasses the static import allowlist. Use a top-level import from @openmaic/dsl, zod, ../choreography, or a relative sibling.',
},
{
selector: "CallExpression[callee.name='require']",
message:
'lib/video-export must not use require() — it bypasses the static import allowlist.',
},
],
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'react',
'react-dom',
'react/*',
'react-dom/*',
'gsap',
'gsap/*',
'framer-motion',
'motion',
'motion/*',
],
message:
'lib/video-export must stay render-backend-agnostic (pure Node): no React / DOM / GSAP / framer-motion. The compiler emits the IR as data; the downstream Hyperframes emitter renders it.',
},
],
},
],
},
},
// Pass files — lib/video-export/passes/**: one level deeper, so a single `../…`
// reaches a module-root file and STAYS inside the module; only a two-level
// `../../…` escapes, and that is allowed solely for `../../choreography`.
{
files: ['lib/video-export/passes/**/*.{ts,tsx,js,jsx,mjs,cjs}'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^@\\//]',
message:
'lib/video-export must not reference a host-app path (@/…). Live app state enters only through the injected TimingProbe / AssetSource — depend only on @openmaic/dsl, zod, ../../choreography, and relative siblings, so the compiler runs in pure Node.',
},
{
selector: 'TemplateElement[value.cooked=/^@\\//]',
message:
'lib/video-export must not reference a host-app path (@/…) in a template literal. Depend only on @openmaic/dsl, zod, ../../choreography, and relative siblings.',
},
// `./…` (children) and a single `../…` (a pass reaching a module-root
// file, which stays inside lib/video-export) are allowed; a two-level
// `../../…` escape is rejected UNLESS it is exactly `../../choreography`.
{
selector:
'ImportDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/|\\.\\.\\/\\.\\.\\/choreography(\\/|$)|\\.\\.\\/(?!\\.\\.\\/)).+/]',
message:
'lib/video-export passes may import only from @openmaic/dsl, zod, ../../choreography, or in-module relatives (./… or a single ../… that stays inside the module). A ../../ escape into the rest of the app is rejected.',
},
{
selector:
'ExportNamedDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/|\\.\\.\\/\\.\\.\\/choreography(\\/|$)|\\.\\.\\/(?!\\.\\.\\/)).+/]',
message:
'lib/video-export passes may re-export only from @openmaic/dsl, zod, ../../choreography, or in-module relatives.',
},
{
selector:
'ExportAllDeclaration > Literal.source[value=/^(?!@openmaic\\/dsl(\\/|$)|zod(\\/|$)|\\.\\/|\\.\\.\\/\\.\\.\\/choreography(\\/|$)|\\.\\.\\/(?!\\.\\.\\/)).+/]',
message:
'lib/video-export passes may re-export only from @openmaic/dsl, zod, ../../choreography, or in-module relatives.',
},
{
selector: 'ImportExpression',
message:
'lib/video-export must not use dynamic import() — it bypasses the static import allowlist. Use a top-level import from @openmaic/dsl, zod, ../../choreography, or a relative sibling.',
},
{
selector: "CallExpression[callee.name='require']",
message:
'lib/video-export must not use require() — it bypasses the static import allowlist.',
},
],
'no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'react',
'react-dom',
'react/*',
'react-dom/*',
'gsap',
'gsap/*',
'framer-motion',
'motion',
'motion/*',
],
message:
'lib/video-export must stay render-backend-agnostic (pure Node): no React / DOM / GSAP / framer-motion. The compiler emits the IR as data; the downstream Hyperframes emitter renders it.',
},
],
},
],
},
},
]);
export default eslintConfig;