Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,17 @@ which is also what makes the localhost demo work with nothing installed.
`editor` is scene-centric — you edit one scene at a time, like slides, with a derived clip
timeline underneath.

- **Every mutation is a pure `EditOp` with `apply(state, op) → {state, inverse}`.** Nothing
mutates in place. The inverse is the backbone of undo/redo, version history and audit.
- **Every mutation is a pure `EditOp` with `apply(workspace, op) → {workspace, inverse}`.**
Nothing mutates in place. The inverse is the backbone of undo/redo, version history and audit.
- The workspace is **the document *and* its locale bundles**. Editing text is the commonest
edit there is, and words live in bundles (R4) — so both move under one history. With two
histories, undo after "rename the heading, then reorder the scenes" walks back through them
in an order that belongs to neither. Operations that only touch structure are still written
against the document alone and lifted by `documentOp`, so they cannot alter a bundle by
accident.
- The package has two entry points: `@bingoo.ai/explainer-editor/ops` is pure and pulls in no
renderer, for a server applying edits or a script running a migration. The default entry
adds the panels.
- The clip model is **derived from the cue/beat document** — the timeline never invents
timing, so dragging a clip is translated back into `EditOp`s on beats and cues.
- The author never sees cue strings, verbs or canvas fractions. They see clips, effects and
Expand Down
5 changes: 4 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ const PURE_PACKAGES = [
'packages/quality/**',
'packages/providers/**',
'packages/generator/**',
'packages/editor/**',
'packages/editor/src/op.ts',
'packages/editor/src/ops.ts',
'packages/editor/src/ops/**',
'packages/editor/src/history.ts',
]

const FORBIDDEN_IN_PURE = [
Expand Down
19 changes: 19 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./ops": {
"types": "./dist/ops.d.ts",
"import": "./dist/ops.js",
"require": "./dist/ops.cjs"
}
},
"scripts": {
Expand All @@ -34,10 +39,24 @@
"dependencies": {
"@bingoo.ai/explainer-core": "workspace:*",
"@bingoo.ai/explainer-kernel": "workspace:*",
"@bingoo.ai/explainer-react": "workspace:*",
"zod": "^4.4.3"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"peerDependencies": {
"react": "^18.3.0 || ^19.0.0",
"react-dom": "^18.3.0 || ^19.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^25.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@testing-library/jest-dom": "^6.6.3"
}
}
237 changes: 237 additions & 0 deletions packages/editor/src/clips.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: 2026 Bingoo.ai
// SPDX-License-Identifier: Apache-2.0

import type { Element, Program, Scene, SceneSlot, VisualAction } from '@bingoo.ai/explainer-core'
import { stripCues } from '@bingoo.ai/explainer-core'

/**
* The clip model: a `Program` seen the way a video editor sees it.
*
* A document says *what happens* — "reveal the diagram when the narrator reaches this word".
* A timeline has to show *when things exist*, as bars you can look along. This turns one
* into the other: an element becomes a clip that starts at its entrance and ends at its
* exit, and clips that overlap get stacked onto separate rows.
*
* Every time here is derived from the compiled timeline. Nothing is invented and nothing is
* stored, so a clip cannot drift from what playback will actually do — which is the failure
* mode of every editor that keeps its own copy of the timings.
*
* This module is pure and holds no React, so a server can build the same view for an API
* response or a diff without pulling a renderer in with it.
*/

export interface ClipEffect {
/** Seconds from the start of the program. */
readonly at: number
readonly verb: string
readonly beatId: string
}

export interface Clip {
readonly key: string
readonly sceneId: string
readonly elementId: string
readonly label: string
readonly type: string
readonly start: number
readonly end: number
readonly sceneStart: number
readonly sceneEnd: number
readonly effects: readonly ClipEffect[]
}

/** A row of clips that do not overlap, so they can share a lane. */
export interface Track {
readonly row: number
readonly clips: readonly Clip[]
}

export interface NarrationClip {
readonly sceneId: string
readonly ref: string
readonly start: number
readonly end: number
readonly text: string
readonly voiced: boolean
}

export interface GateClip {
readonly sceneId: string
readonly start: number
readonly end: number
readonly kind: string
}

export interface SceneBound {
readonly id: string
readonly start: number
readonly end: number
readonly kind: string
}

export interface Project {
readonly duration: number
readonly scenes: readonly SceneBound[]
readonly tracks: readonly Track[]
readonly narration: readonly NarrationClip[]
readonly gates: readonly GateClip[]
}

/**
* Verbs that bring an element on screen.
*
* A set rather than a list of `if`s because a kit may register its own reveal-like verb, and
* a timeline that did not know about it would draw the clip as starting at the scene's
* beginning — visible, wrong, and hard to explain (R1).
*/
export const ENTRANCE_VERBS: ReadonlySet<string> = new Set(['reveal', 'drawOn', 'revealItem', 'revealAll'])
export const EXIT_VERBS: ReadonlySet<string> = new Set(['exit'])

export function buildProject(program: Program): Project {
const clips: Clip[] = []
const narration: NarrationClip[] = []
const gates: GateClip[] = []

for (const slot of program.slots) {
if (slot.scene.gate) {
gates.push({
sceneId: slot.scene.id,
start: slot.start + (slot.gateOpensAt ?? 0),
end: slot.end,
kind: slot.scene.gate.kind,
})
}

for (const segment of slot.narrations) {
narration.push({
sceneId: slot.scene.id,
ref: segment.ref,
start: slot.start + segment.at,
end: slot.start + segment.at + segment.duration,
text: stripCues(segment.narration.script).replace(/\s+/g, ' ').trim(),
voiced: segment.narration.words.length > 0,
})
}

for (const element of slot.scene.elements) {
clips.push(clipFor(slot, element, program))
}
}

return {
duration: program.duration,
scenes: program.slots.map((slot) => ({
id: slot.scene.id,
start: slot.start,
end: slot.end,
kind: slot.scene.kind,
})),
tracks: pack(clips),
narration,
gates,
}
}

/** The shortest a clip may be drawn. Below this it is a sliver nobody can grab. */
const MIN_CLIP_SECONDS = 0.3

function clipFor(slot: SceneSlot, element: Element, program: Program): Clip {
const beatAt = new Map(slot.timeline.beatTimes.map((beat) => [beat.id, beat.at] as const))
const effects: ClipEffect[] = []

for (const beat of slot.scene.beats) {
const at = beatAt.get(beat.id)
if (at === undefined) continue

for (const action of beat.actions) {
if (!targets(action, element.id)) continue
effects.push({ at: slot.start + at, verb: action.verb, beatId: beat.id })
}
}

const entrances = effects.filter((effect) => ENTRANCE_VERBS.has(effect.verb))
const exits = effects.filter((effect) => EXIT_VERBS.has(effect.verb))

// An element with no entrance is on screen from the start — which is what `startVisible`
// means, and also what an unauthored element does. Both read the same on a timeline.
const start = entrances.length > 0 ? Math.min(...entrances.map((e) => e.at)) : slot.start
const end = exits.length > 0 ? Math.max(...exits.map((e) => e.at)) : slot.end

return {
key: `${slot.scene.id}:${element.id}`,
sceneId: slot.scene.id,
elementId: element.id,
label: clipLabel(element, program),
type: element.type,
start,
end: Math.max(end, start + MIN_CLIP_SECONDS),
sceneStart: slot.start,
sceneEnd: slot.end,
effects: effects.sort((a, b) => a.at - b.at),
}
}

/** Whether an action acts on this element. `connect` names two, and both ends of an arrow
* are things the arrow happens *to*. */
function targets(action: VisualAction, elementId: string): boolean {
if ('target' in action) return action.target === elementId
if (action.verb === 'connect') return action.from === elementId || action.to === elementId
return false
}

/**
* What to call a clip.
*
* The element's own words where it has any, because that is how an author recognises it —
* a strip of clips labelled `text`, `text`, `text` is a strip you have to click through.
*/
function clipLabel(element: Element, program: Program): string {
const key = (element.content as { key?: string }).key
const phrase = key === undefined ? undefined : program.bundle.strings[key]
if (phrase !== undefined && phrase !== '') return truncate(stripCues(phrase))
return element.type
}

const LABEL_LIMIT = 24

function truncate(text: string): string {
const clean = text.replace(/\s+/g, ' ').trim()
return clean.length > LABEL_LIMIT ? `${clean.slice(0, LABEL_LIMIT - 1)}…` : clean
}

/**
* Greedy row packing, so clips that overlap in time end up on separate rows.
*
* Sorted by start, then each clip goes on the first row whose last clip has already
* finished. This is the standard interval-partitioning result: it uses the fewest rows
* possible, which matters because every extra row is timeline height taken away from the
* preview above it.
*/
function pack(clips: readonly Clip[]): Track[] {
const sorted = [...clips].sort((a, b) => a.start - b.start || a.key.localeCompare(b.key))
const rows: Clip[][] = []

for (const clip of sorted) {
const row = rows.find((candidate) => candidate[candidate.length - 1]!.end <= clip.start + 0.001)
if (row) row.push(clip)
else rows.push([clip])
}

return rows.map((clips, row) => ({ row, clips }))
}

/** The clip a given element belongs to, if it is on the timeline at all. */
export function clipOf(project: Project, sceneId: string, elementId: string): Clip | undefined {
for (const track of project.tracks) {
const found = track.clips.find((clip) => clip.sceneId === sceneId && clip.elementId === elementId)
if (found) return found
}
return undefined
}

/** The scene covering a program time, for turning a click on the timeline into a selection. */
export function sceneAt(project: Project, t: number): SceneBound | undefined {
return project.scenes.find((scene) => t >= scene.start && t < scene.end) ?? project.scenes.at(-1)
}

export type { Scene }
21 changes: 10 additions & 11 deletions packages/editor/src/history.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
// SPDX-FileCopyrightText: 2026 Bingoo.ai
// SPDX-License-Identifier: Apache-2.0

import type { ExplainerDocument } from '@bingoo.ai/explainer-core'
import type { EditOp, OpRegistry } from './op.js'
import type { EditOp, OpRegistry, Workspace } from './op.js'

/**
* Undo and redo, built entirely from the inverses the operations already produced.
Expand All @@ -18,15 +17,15 @@ import type { EditOp, OpRegistry } from './op.js'
*/

export interface HistoryState {
readonly document: ExplainerDocument
readonly workspace: Workspace
/** Inverses of what has been done, most recent last. */
readonly past: readonly EditOp[]
/** Operations undone and available to redo, most recent last. */
readonly future: readonly EditOp[]
}

export function initialHistory(document: ExplainerDocument): HistoryState {
return { document, past: [], future: [] }
export function initialHistory(workspace: Workspace): HistoryState {
return { workspace, past: [], future: [] }
}

export interface HistoryLimits {
Expand All @@ -47,12 +46,12 @@ export function commit(
op: EditOp,
limits: HistoryLimits = {},
): HistoryState {
const result = registry.apply(state.document, op)
const result = registry.apply(state.workspace, op)
const depth = limits.depth ?? 200
const past = [...state.past, result.inverse]

return {
document: result.document,
workspace: result.workspace,
past: past.length > depth ? past.slice(past.length - depth) : past,
future: [],
}
Expand All @@ -62,9 +61,9 @@ export function undo(registry: OpRegistry, state: HistoryState): HistoryState {
const inverse = state.past[state.past.length - 1]
if (!inverse) return state

const result = registry.apply(state.document, inverse)
const result = registry.apply(state.workspace, inverse)
return {
document: result.document,
workspace: result.workspace,
past: state.past.slice(0, -1),
// The inverse of the inverse is the original operation, so redo needs no separate
// record — and cannot disagree with what undo actually did.
Expand All @@ -76,9 +75,9 @@ export function redo(registry: OpRegistry, state: HistoryState): HistoryState {
const op = state.future[state.future.length - 1]
if (!op) return state

const result = registry.apply(state.document, op)
const result = registry.apply(state.workspace, op)
return {
document: result.document,
workspace: result.workspace,
past: [...state.past, result.inverse],
future: state.future.slice(0, -1),
}
Expand Down
Loading
Loading