feat(editor): emit a deck as a hyperframes video composition

A deck's structured slide markup already carries everything a
frame-driven renderer needs; this walks the same emitter into a
composition file — one scene per visible board in slideshow order,
hold time driven by how much text a viewer must read (clamped to a
3-10s window), hard cuts with a short content fade, and zero external
references. The renderer runs it deterministically to an mp4; two
renders of the shipped template hash identically.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-08-05 00:48:46 +08:00
parent cc9b7889e2
commit 12bb05243f
5 changed files with 1092 additions and 1 deletions

View file

@ -94,7 +94,11 @@ pub fn export_deck_html(state: &EditorState, target: &Path) -> Result<DeckHtmlEx
///
/// Read from the document rather than the scene: the resolved
/// `SceneNode` carries geometry and paint, not the authored layer name.
fn board_name(state: &EditorState, board_id: &str) -> String {
///
/// Shared with `export_hyperframes`: a board must be labelled the same
/// in the slideshow a presenter opens and in the composition a renderer
/// walks, or the two artifacts disagree about what a slide is called.
pub(crate) fn board_name(state: &EditorState, board_id: &str) -> String {
state
.active_children()
.iter()

View file

@ -0,0 +1,408 @@
//! Deck → Hyperframes composition — the same resolved-scene markup the
//! slideshow export writes, laid out on a TIME axis instead of a key
//! press.
//!
//! # Division of labour with `export_html`
//!
//! [`crate::export_html`] writes a PLAYER: one file a presenter opens
//! and drives, where a slide changes because a human pressed a key.
//! This module writes RENDER STOCK: the same boards, each pinned to a
//! `data-start` / `data-duration` window so a headless renderer can walk
//! the deck frame by frame and encode it as video. Nothing about the
//! slide markup itself differs — both call
//! [`crate::export_html_structured::board_slide_markup`], so a slide
//! that presents correctly renders correctly, and a fix to the emitter
//! reaches both artifacts at once.
//!
//! # Why the timeline is integers
//!
//! The renderer is frame-driven (`frame = floor(time × fps)`), so the
//! only thing that can desynchronise a cut is our own arithmetic.
//! Durations are therefore computed in whole [`TICKS_PER_SECOND`]ths of
//! a second and ACCUMULATED AS INTEGERS: `start[i+1]` is literally
//! `start[i] + duration[i]`, in the attribute text as much as in the
//! maths. Summing floats would eventually emit a start that is a
//! hair short of the previous scene's end, and a one-frame gap shows up
//! on screen as a black flash.
//!
//! # Why every scene animates itself
//!
//! The renderer hides and shows a scene from its `class="clip"` plus
//! its window attributes, and that remains the mechanism. On top of it
//! each scene ALSO carries a CSS animation whose delay is its own start
//! and whose duration is its own window, with
//! `animation-fill-mode:none` so the element falls back to hidden the
//! instant that window closes. Belt and braces on purpose: the file is
//! opened by humans too (a browser, the studio preview) where no
//! runtime is driving anything, and a composition that reads as every
//! slide stacked on the last one outside the renderer is a composition
//! nobody can eyeball before spending a render on it. CSS animations
//! are seekable, which is what keeps the redundancy safe under
//! frame-driven capture — the renderer sets the clock, the animation
//! state follows from it, and the same frame index always produces the
//! same pixels.
//!
//! # Cuts, not transitions
//!
//! Scene changes are hard cuts: the incoming slide's box, background
//! and all, is fully painted on the first frame of its window. The only
//! motion is a 0.3 s fade-in of what sits ON that slide, which is the
//! entrance tween a cut is normally given. Fading the slide box itself
//! is what NOT to do, and it is a mistake with no symptom until you
//! render: the slide's own background goes with it, so every cut
//! becomes a black frame. No shader transitions, no audio track.
//!
//! # Self-containment
//!
//! Identical to the slideshow export: not one URL may be emitted. The
//! markup comes from the same emitter (which embeds anything it cannot
//! express as a `data:` PNG), and the page's stylesheet is inlined by
//! [`markup`]. The renderer opens the file in a sandboxed browser, so a
//! resource it would have to fetch is a frame it would have to capture
//! without.
use std::path::{Path, PathBuf};
use op_editor_core::preview_slideshow::active_page_boards;
use op_editor_core::EditorState;
use crate::export::ExportError;
use crate::export_html::board_name;
use crate::export_html_structured::{board_slide_markup, css_num};
mod markup;
#[cfg(test)]
#[path = "export_hyperframes/tests.rs"]
mod tests;
/// Timeline resolution. Tenths of a second divide evenly into every
/// common capture rate (3 frames at 30 fps, 6 at 60), so a scene
/// boundary always lands ON a frame rather than inside one.
pub const TICKS_PER_SECOND: u32 = 10;
/// Reading budget, in text units per second. A unit is one CJK
/// character; Latin words are converted at [`LATIN_WORD_UNITS`], which
/// puts English at ~160 words per minute — the usual comfortable
/// silent-reading rate, and the rate the CJK figure was chosen against.
const UNITS_PER_SECOND: f32 = 8.0;
/// One Latin word costs this many text units. Three keeps the two
/// scripts on one scale instead of making a slide of English words race
/// past at CJK character speed.
const LATIN_WORD_UNITS: f32 = 3.0;
/// Time a slide is held before its text is counted at all: the beat a
/// viewer spends registering that the slide CHANGED, before reading
/// starts.
const BASE_SECONDS: f32 = 1.5;
/// Floor on a scene. Below three seconds a viewer who blinked at the
/// cut has no chance to recover, however little text the slide carries
/// — a title slide of two words still needs to be seen.
const MIN_SECONDS: f32 = 3.0;
/// Ceiling on a scene. Past ten seconds a static frame reads as a
/// stall; a slide with that much text is a slide that should be split,
/// and stretching its hold would hide the problem rather than fix it.
const MAX_SECONDS: f32 = 10.0;
/// Entrance tween, in ticks (0.3 s). The upper end of the renderer
/// guideline's 0.10.3 s entrance range — long enough to read as
/// motion, short enough that the cut still feels hard.
const FADE_TICKS: u32 = 3;
/// Capture rate written onto the composition root. Thirty is the
/// renderer's own default; declaring it makes the frame grid a property
/// of the artifact rather than of whichever CLI version renders it.
const FPS: u32 = 30;
/// File names written by [`export_deck_hyperframes`].
///
/// The composition is `index.html` because that is what the renderer
/// means by a project: `hyperframes render <dir>` looks for exactly
/// that name, and a second file carrying `data-composition-id` beside
/// it is a lint ERROR (two discoverable entry points). So the directory
/// IS the deliverable, and it holds one composition.
pub const COMPOSITION_FILE: &str = "index.html";
pub const RENDER_NOTES_FILE: &str = "RENDER.md";
/// Fallback composition id / title for an unnamed deck.
const DEFAULT_ID: &str = "deck";
/// One board's slot on the timeline.
#[derive(Debug, Clone, PartialEq)]
pub struct Scene {
/// Authored board name — the scene's accessible label.
pub name: String,
/// Offset from the start of the composition, in ticks.
pub start_ticks: u32,
/// How long the scene holds, in ticks.
pub duration_ticks: u32,
/// Board size in doc px.
pub width: f32,
pub height: f32,
/// The slide's inner markup, in board-local coordinates.
pub body: String,
}
/// A built composition, before it is written anywhere.
#[derive(Debug, Clone, PartialEq)]
pub struct Composition {
/// The single self-contained HTML file.
pub html: String,
/// Companion notes: how to render this file.
pub render_notes: String,
/// Video canvas size in px — the first visible board's size.
pub width: f32,
pub height: f32,
/// Scenes emitted, in presentation order.
pub scenes: usize,
/// Whole-composition length in ticks.
pub total_ticks: u32,
/// Nodes emitted as real elements across the deck.
pub structured_nodes: usize,
/// Nodes that had to be embedded as a raster image instead.
pub raster_fallbacks: usize,
}
impl Composition {
/// Composition length in seconds. Exact: the tick count is an
/// integer and the divisor is a power-of-ten constant.
pub fn total_seconds(&self) -> f32 {
self.total_ticks as f32 / TICKS_PER_SECOND as f32
}
}
/// What one export wrote, and where.
#[derive(Debug, Clone, PartialEq)]
pub struct HyperframesExport {
pub composition_path: PathBuf,
pub render_notes_path: PathBuf,
pub composition: Composition,
}
/// Build the active page's deck as a Hyperframes composition.
///
/// Page order comes from [`active_page_boards`] — the same single source
/// the native slideshow and the HTML export present from — so the video
/// runs in exactly the order Preview does, and a board the author hid is
/// skipped in all three.
///
/// A board that fails to render aborts the build rather than being
/// dropped, for the reason the slideshow export gives: the artifact is
/// one file whose timeline would silently close over the hole, and the
/// missing slide would only surface in the finished video.
pub fn deck_composition(state: &EditorState) -> Result<Composition, ExportError> {
let scene = op_pen_loader::editor_state_to_active_page_layout_scene(state);
let page = scene.active_page().ok_or(ExportError::NoActivePage)?;
let mut scenes: Vec<Scene> = Vec::new();
let mut structured_nodes = 0;
let mut raster_fallbacks = 0;
let mut cursor = 0;
for board_id in active_page_boards(state) {
let Some(node) = page.find(&board_id) else {
return Err(ExportError::NodeNotFoundOnPage {
node_id: board_id,
page_id: page.id.clone(),
});
};
// Hiding a board is the author saying it is not part of the
// deck — it costs no time on the timeline either.
if node.hidden {
continue;
}
let duration_ticks = hold_ticks(text_units(node));
let markup = board_slide_markup(page, &board_id, board_name(state, &board_id))?;
structured_nodes += markup.structured_nodes;
raster_fallbacks += markup.raster_fallbacks();
scenes.push(Scene {
name: markup.name,
start_ticks: cursor,
duration_ticks,
width: markup.width,
height: markup.height,
body: markup.body,
});
cursor += duration_ticks;
}
let (first_width, first_height) = match scenes.first() {
Some(first) => (first.width, first.height),
None => return Err(ExportError::NothingToExport),
};
let title = composition_title(state, &scenes);
let html =
markup::render_composition(&title, &slug(&title), first_width, first_height, &scenes);
let render_notes = markup::render_notes(&title, cursor, scenes.len());
Ok(Composition {
html,
render_notes,
width: first_width,
height: first_height,
scenes: scenes.len(),
total_ticks: cursor,
structured_nodes,
raster_fallbacks,
})
}
/// Build the composition and write both files into `dir`.
///
/// The notes file ships beside the composition rather than inside it:
/// the HTML is an input to a renderer that would have to be taught to
/// ignore a comment block, and a `.md` next to it is what a human opens.
pub fn export_deck_hyperframes(
state: &EditorState,
dir: &Path,
) -> Result<HyperframesExport, ExportError> {
let composition = deck_composition(state)?;
std::fs::create_dir_all(dir).map_err(|e| ExportError::Write(e.to_string()))?;
let composition_path = dir.join(COMPOSITION_FILE);
let render_notes_path = dir.join(RENDER_NOTES_FILE);
std::fs::write(&composition_path, &composition.html)
.map_err(|e| ExportError::Write(e.to_string()))?;
std::fs::write(&render_notes_path, &composition.render_notes)
.map_err(|e| ExportError::Write(e.to_string()))?;
Ok(HyperframesExport {
composition_path,
render_notes_path,
composition,
})
}
/// How long a slide carrying `units` of text is held, in ticks.
///
/// Rounding to a whole tick happens HERE, once, so that every later use
/// of the number — the attribute, the animation delay, the running
/// start — is the same integer. Rounding at the formatting step instead
/// would let a scene's printed start disagree with the sum of the
/// printed durations before it.
fn hold_ticks(units: f32) -> u32 {
let seconds = (BASE_SECONDS + units / UNITS_PER_SECOND).clamp(MIN_SECONDS, MAX_SECONDS);
(seconds * TICKS_PER_SECOND as f32).round() as u32
}
/// Text units under `node`, counting the visible subtree.
///
/// Hidden nodes are excluded for the same reason hidden boards are: a
/// paragraph the author turned off is not read on screen, so paying for
/// it in hold time would stretch the video against something nobody
/// sees.
fn text_units(node: &op_editor_ui::layout_scene::SceneNode) -> f32 {
if node.hidden {
return 0.0;
}
let own = node.text.as_deref().map(units_in).unwrap_or(0.0);
node.children
.iter()
.fold(own, |total, child| total + text_units(child))
}
/// Reading cost of one string: CJK characters count one each, runs of
/// Latin/digit characters count as one word each.
fn units_in(text: &str) -> f32 {
let mut cjk = 0u32;
let mut words = 0u32;
let mut inside_word = false;
for c in text.chars() {
if is_cjk(c) {
cjk += 1;
inside_word = false;
} else if c.is_alphanumeric() {
if !inside_word {
words += 1;
inside_word = true;
}
} else {
inside_word = false;
}
}
cjk as f32 + words as f32 * LATIN_WORD_UNITS
}
/// Whether `c` is read one character at a time rather than one word at
/// a time. The ranges cover CJK ideographs and their extensions, kana,
/// Hangul, CJK punctuation and the fullwidth forms — everything a deck
/// in an East Asian language is actually set in.
fn is_cjk(c: char) -> bool {
matches!(c,
'\u{2E80}'..='\u{9FFF}'
| '\u{A960}'..='\u{A97F}'
| '\u{AC00}'..='\u{D7FF}'
| '\u{F900}'..='\u{FAFF}'
| '\u{FE30}'..='\u{FE4F}'
| '\u{FF00}'..='\u{FFEF}'
| '\u{20000}'..='\u{3FFFF}')
}
/// Composition title: the document name, else the first scene's name
/// (which for a generated deck is the deck's own title), else a neutral
/// constant.
fn composition_title(state: &EditorState, scenes: &[Scene]) -> String {
state
.doc
.name
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.or_else(|| scenes.first().map(|scene| scene.name.clone()))
.unwrap_or_else(|| DEFAULT_ID.to_string())
}
/// An ASCII id for `data-composition-id`, derived from the title.
///
/// The attribute is an identifier a renderer may put in a log line or a
/// file name, so it is reduced to lowercase ASCII words joined by
/// hyphens. A title with no ASCII at all (a Chinese deck name, most
/// often) reduces to nothing, and falls back to the constant rather
/// than to an empty attribute.
fn slug(title: &str) -> String {
let mut out = String::new();
for c in title.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
}
let trimmed = out.trim_end_matches('-');
if trimmed.is_empty() {
DEFAULT_ID.to_string()
} else {
trimmed.to_string()
}
}
/// A tick count as CSS/attribute seconds: `42` → `"4.2"`, `30` → `"3"`.
///
/// Hand-formatted rather than routed through a float: the timeline is
/// integers precisely so that no printed number can round away from the
/// value the arithmetic used, and `{:.1}` on an `f32` would re-introduce
/// exactly that risk. Also guarantees the CSS-legal spelling — a decimal
/// POINT (never a locale comma) and never an exponent.
fn seconds(ticks: u32) -> String {
let whole = ticks / TICKS_PER_SECOND;
match ticks % TICKS_PER_SECOND {
0 => whole.to_string(),
fraction => format!("{whole}.{fraction}"),
}
}
/// Scale + offset that fits a `w × h` board into the `cw × ch` video
/// canvas, as a `(scale, left, top)` triple.
///
/// A deck whose boards are all one size — the normal case — gets
/// `(1, 0, 0)` and no transform worth mentioning. A deck that mixes
/// sizes is letterboxed HERE, at export time, rather than by a script at
/// playback: the renderer captures frames, and a fit computed in Rust
/// cannot land differently on the frame the encoder is on.
fn fit(w: f32, h: f32, cw: f32, ch: f32) -> (f32, f32, f32) {
if w <= 0.0 || h <= 0.0 || cw <= 0.0 || ch <= 0.0 {
return (1.0, 0.0, 0.0);
}
let scale = (cw / w).min(ch / h);
(scale, (cw - w * scale) / 2.0, (ch - h * scale) / 2.0)
}

View file

@ -0,0 +1,172 @@
//! The composition page and its companion notes.
//!
//! Split from the exporter next door so that module stays about the
//! timeline (what holds for how long) and this one about the file (what
//! that timeline is spelled as). Everything is inline: the composition
//! is handed to a renderer that opens it in a sandboxed browser, and a
//! stylesheet it would have to fetch is a frame it would have to
//! capture without.
use op_util::xml_escape::escape_html;
use std::fmt::Write as _;
use super::{css_num, fit, seconds, Scene, FADE_TICKS, FPS};
/// Build the whole composition page.
///
/// `canvas_w` / `canvas_h` are the video frame size — the first visible
/// board's size, which for a deck is every board's size. Boards that
/// differ are fitted into it, letterboxed against the root's black.
///
/// # What the renderer requires, and why each piece is here
///
/// Three attributes are not decoration — each one is a lint error or a
/// wasted render without it, and all three were confirmed against
/// `hyperframes lint` rather than assumed:
///
/// - **`class="clip"` on every timed element.** The runtime keys its
/// show/hide off that class, NOT off `data-start` alone. A timed
/// element without it stays on screen for the whole composition, so
/// the deck would render as every slide stacked on the last one.
/// - **`data-no-timeline` on the root.** The renderer otherwise polls
/// for a `window.__timelines` registration for 45 seconds before
/// giving up — a 45 s tax on every render for a composition that has
/// no scripted timeline to register. Ours has none by construction:
/// the animation is CSS, which the frame clock drives directly.
/// - **`data-fps`.** Pinned rather than left to the CLI default, so the
/// frame grid the renderer walks is a property of the artifact and a
/// later default change cannot silently resample the deck.
///
/// Scene ids are emitted for the same reason a slide has a name: they
/// are the stable handle the renderer's studio and its agent tooling
/// address a scene by.
pub fn render_composition(
title: &str,
composition_id: &str,
canvas_w: f32,
canvas_h: f32,
scenes: &[Scene],
) -> String {
let mut tracks = String::new();
for (i, scene) in scenes.iter().enumerate() {
let (scale, left, top) = fit(scene.width, scene.height, canvas_w, canvas_h);
let start = seconds(scene.start_ticks);
let _ = write!(
tracks,
"\n<div class=\"clip hf-scene\" id=\"scene-{n}\" role=\"group\" \
aria-label=\"{label}\" \
data-start=\"{start}\" data-duration=\"{duration}\" data-track-index=\"0\" \
style=\"--hf-start:{start}s;animation-delay:{start}s;\
animation-duration:{duration}s\">\
<div class=\"hf-slide\" style=\"left:{left}px;top:{top}px;\
width:{w}px;height:{h}px;transform:scale({scale})\">{body}</div></div>",
n = i + 1,
label = escape_html(&scene.name),
duration = seconds(scene.duration_ticks),
left = css_num(left),
top = css_num(top),
w = css_num(scene.width),
h = css_num(scene.height),
scale = css_num(scale),
body = scene.body,
);
}
let total = seconds(scenes.iter().map(|scene| scene.duration_ticks).sum());
let width = css_num(canvas_w);
let height = css_num(canvas_h);
format!(
"<!doctype html>\n\
<html lang=\"en\">\n\
<head>\n\
<meta charset=\"utf-8\">\n\
<title>{title}</title>\n\
<style>{css}</style>\n\
</head>\n\
<body>\n\
<div id=\"root\" data-composition-id=\"{id}\" data-no-timeline data-start=\"0\" \
data-duration=\"{total}\" data-fps=\"{FPS}\" \
data-width=\"{width}\" data-height=\"{height}\" \
style=\"width:{width}px;height:{height}px\">{tracks}\n</div>\n\
</body>\n\
</html>\n",
title = escape_html(title),
id = escape_html(composition_id),
css = composition_css(),
)
}
/// The composition's stylesheet.
///
/// Two shared `@keyframes` carry the whole timeline: every scene uses
/// the same pair and differs only in the inline `animation-delay` /
/// `animation-duration` the emitter wrote. Per-scene keyframes would
/// grow the file with one rule per slide and say nothing extra.
///
/// `hf-hold` animates `visibility` from visible to visible, which reads
/// as a no-op and is the point: paired with `animation-fill-mode:none`
/// (never `both`), the scene takes the animated value only INSIDE its
/// window and falls back to the hidden base rule on either side. That
/// is what makes each scene's own attributes the single source of when
/// it is on screen — the failure mode this replaces is the middle
/// scenes of a composition all staying stacked on top of each other.
///
/// The entrance fade deliberately does NOT touch the slide box. Fading
/// the box in from zero was the first attempt, and rendering it showed
/// what that actually means: at every cut the frame is the root's
/// black, because the incoming slide's own background is inside the
/// thing being faded. So the box hard-cuts and only its CONTENTS
/// animate — `.hf-slide > .n > *` is the board's children, i.e.
/// everything except the board's own fill. The cut lands on a fully
/// painted slide, and the content arrives over the next 0.3 s.
///
/// The per-scene delay reaches those children through the inherited
/// `--hf-start` custom property, because animation longhands do not
/// inherit and the children are written by the shared slide emitter,
/// which knows nothing about timelines and must not have to.
fn composition_css() -> String {
format!(
"html,body{{margin:0;background:#000}}\
#root{{position:relative;overflow:hidden;background:#000}}\
.hf-scene{{position:absolute;left:0;top:0;width:100%;height:100%;\
visibility:hidden;animation-name:hf-hold;animation-timing-function:linear;\
animation-fill-mode:none}}\
.hf-slide{{position:absolute;transform-origin:0 0;overflow:hidden;background:#fff}}\
.hf-slide .n{{position:absolute;box-sizing:border-box}}\
.hf-slide .k{{pointer-events:none}}\
.hf-slide > .n > *{{animation-name:hf-enter;animation-duration:{fade}s;\
animation-timing-function:ease-out;animation-fill-mode:none;\
animation-delay:var(--hf-start,0s)}}\
@keyframes hf-hold{{from{{visibility:visible}}to{{visibility:visible}}}}\
@keyframes hf-enter{{from{{opacity:0}}to{{opacity:1}}}}",
fade = seconds(FADE_TICKS),
)
}
/// The `RENDER.md` written beside the composition.
///
/// Deliberately two commands and the facts needed to sanity-check the
/// result: the renderer is a Node tool this host does not bundle, so
/// what ships is the instruction, not the MP4.
pub fn render_notes(title: &str, total_ticks: u32, scenes: usize) -> String {
format!(
"# Render `{title}`\n\
\n\
{scenes} scene(s), {total}s total, {FPS} fps. `index.html` is a Hyperframes\n\
composition: plain HTML with `data-start` / `data-duration` on each scene and\n\
no external resource of any kind, so it renders offline and frame for frame\n\
the same on every run.\n\
\n\
Run both commands from THIS directory the renderer takes a project\n\
directory and reads `index.html` out of it, not an HTML path.\n\
\n\
```sh\n\
npx hyperframes render . --output deck.mp4\n\
npx hyperframes preview .\n\
```\n\
\n\
Needs Node 22 or newer. Edit the deck in OpenPencil and export again the\n\
composition is generated, not authored, so edits made to `index.html` are\n\
lost on the next export.\n",
total = seconds(total_ticks),
)
}

View file

@ -0,0 +1,506 @@
//! Timeline + emission tests for the Hyperframes composition export.
//!
//! The slide MARKUP itself is not re-tested here — it is the structured
//! exporter's, and covered by its own suite. What is tested is
//! everything this module adds on top: which boards become scenes, how
//! long each one holds, that the windows tile the timeline exactly, and
//! that every number lands in the file in a spelling a browser and a
//! renderer both accept.
use super::*;
use op_editor_core::scene_template_catalog::TemplateScene;
fn deck_state(source: &str) -> EditorState {
let doc = jian_ops_schema::load_str(source)
.expect("fixture JSON parses")
.value;
let mut state = EditorState::from_document(doc);
state.editor_ui.scenario = Some(TemplateScene::Slides);
state
}
/// A deck of `boards`, each a 1920x1080 frame carrying `text`.
fn deck_of(boards: &[&str]) -> EditorState {
let children: Vec<String> = boards
.iter()
.enumerate()
.map(|(i, text)| {
format!(
r##"{{"type":"frame","id":"f{i}","name":"slide {i}","x":{x},"y":0,
"width":1920,"height":1080,"fill":[{{"type":"solid","color":"#ffffff"}}],
"children":[
{{"type":"text","id":"t{i}","x":100,"y":100,"width":1400,"height":200,
"content":"{text}","fontSize":48,
"fill":[{{"type":"solid","color":"#101828"}}]}}
]}}"##,
x = i * 2000,
)
})
.collect();
deck_state(&format!(
r#"{{"version":"1.0.0","children":[{}]}}"#,
children.join(",")
))
}
/// An attribute's seconds value back as ticks. Comparing timelines in
/// ticks keeps the assertions on integers: accumulating the parsed
/// decimals as floats is exactly the drift the exporter avoids, and a
/// test that reintroduced it could fail on arithmetic rather than on
/// the behaviour under test.
fn ticks_of(value: &str) -> u32 {
(value.parse::<f64>().expect("attribute is a number") * f64::from(TICKS_PER_SECOND)).round()
as u32
}
/// Every `(start, duration)` pair the composition declares, as written.
fn windows(html: &str) -> Vec<(String, String)> {
html.split("class=\"clip hf-scene\"")
.skip(1)
.map(|scene| {
let attr = |name: &str| {
scene
.split(&format!("{name}=\""))
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or_else(|| panic!("scene is missing {name}"))
.to_string()
};
(attr("data-start"), attr("data-duration"))
})
.collect()
}
#[test]
fn every_visible_board_becomes_exactly_one_scene() {
let state = deck_of(&["one", "two", "three"]);
let composition = deck_composition(&state).expect("deck builds");
assert_eq!(composition.scenes, 3);
assert_eq!(
composition.html.matches("class=\"clip hf-scene\"").count(),
3
);
assert_eq!(composition.html.matches("class=\"hf-slide\"").count(), 3);
// Board order is the authored child order, same as the slideshow.
let labels: Vec<&str> = composition
.html
.split("aria-label=\"")
.skip(1)
.filter_map(|rest| rest.split('"').next())
.collect();
assert_eq!(labels, vec!["slide 0", "slide 1", "slide 2"]);
}
#[test]
fn scene_windows_tile_the_timeline_with_no_gap_and_no_overlap() {
// Three different text lengths, so the durations differ and a
// hard-coded stride could not pass.
let state = deck_of(&[
"hi",
"一二三四五六七八九十一二三四五六",
&"word ".repeat(30),
]);
let composition = deck_composition(&state).expect("deck builds");
let windows = windows(&composition.html);
assert_eq!(windows.len(), 3);
let mut expected = 0;
for (start, duration) in &windows {
assert_eq!(
ticks_of(start),
expected,
"scene must start exactly where the previous one ended: {windows:?}"
);
assert!(
ticks_of(duration) > 0,
"a scene with no duration never shows"
);
expected += ticks_of(duration);
}
assert_eq!(
expected, composition.total_ticks,
"the declared total must be the sum of the windows"
);
// The durations really did differ — otherwise this test would pass
// on a constant-hold implementation.
assert!(
windows[0].1 != windows[1].1 && windows[1].1 != windows[2].1,
"{windows:?}"
);
}
#[test]
fn the_root_declares_the_canvas_and_the_whole_length() {
let state = deck_of(&["one", "two"]);
let composition = deck_composition(&state).expect("deck builds");
assert!(
composition
.html
.contains("data-composition-id=\"slide-0\" data-no-timeline data-start=\"0\""),
"{}",
composition.html
);
assert!(
composition
.html
.contains("data-width=\"1920\" data-height=\"1080\""),
"{}",
composition.html
);
let total = seconds(composition.total_ticks);
assert!(
composition
.html
.contains(&format!("data-duration=\"{total}\" data-fps=")),
"root duration missing: {}",
composition.html
);
assert_eq!(composition.width, 1920.0);
assert_eq!(composition.height, 1080.0);
}
#[test]
fn a_nearly_empty_slide_still_holds_the_readable_floor() {
let state = deck_of(&["hi"]);
let composition = deck_composition(&state).expect("deck builds");
assert_eq!(composition.total_ticks, 30, "floor is {MIN_SECONDS}s");
assert!(composition.html.contains("data-duration=\"3\""));
}
#[test]
fn a_wall_of_text_is_capped_rather_than_stretched() {
let state = deck_of(&[&"".repeat(2000)]);
let composition = deck_composition(&state).expect("deck builds");
assert_eq!(composition.total_ticks, 100, "ceiling is {MAX_SECONDS}s");
assert!(composition.html.contains("data-duration=\"10\""));
}
#[test]
fn hold_time_between_the_bounds_follows_the_reading_formula() {
// 40 CJK characters: 1.5 + 40/8 = 6.5s.
let cjk = deck_of(&[&"".repeat(40)]);
assert_eq!(deck_composition(&cjk).expect("builds").total_ticks, 65);
// 16 Latin words at 3 units each = 48 units: 1.5 + 48/8 = 7.5s.
let latin = deck_of(&[&"word ".repeat(16)]);
assert_eq!(deck_composition(&latin).expect("builds").total_ticks, 75);
}
#[test]
fn text_the_author_hid_is_not_paid_for_in_hold_time() {
let visible = deck_state(
r##"{"version":"1.0.0","children":[
{"type":"frame","id":"f1","name":"s","x":0,"y":0,"width":1920,"height":1080,
"fill":[{"type":"solid","color":"#ffffff"}],"children":[
{"type":"text","id":"t1","x":10,"y":10,"width":800,"height":80,
"content":"一二三四五六七八九十一二三四五六一二三四五六七八九十一二三四五六",
"fontSize":48,"fill":[{"type":"solid","color":"#101828"}]}
]}
]}"##,
);
let hidden = deck_state(
r##"{"version":"1.0.0","children":[
{"type":"frame","id":"f1","name":"s","x":0,"y":0,"width":1920,"height":1080,
"fill":[{"type":"solid","color":"#ffffff"}],"children":[
{"type":"text","id":"t1","x":10,"y":10,"width":800,"height":80,"visible":false,
"content":"一二三四五六七八九十一二三四五六一二三四五六七八九十一二三四五六",
"fontSize":48,"fill":[{"type":"solid","color":"#101828"}]}
]}
]}"##,
);
let with_text = deck_composition(&visible).expect("builds").total_ticks;
let without = deck_composition(&hidden).expect("builds").total_ticks;
assert_eq!(with_text, 55, "32 CJK chars: 1.5 + 32/8 = 5.5s");
assert_eq!(without, 30, "hidden text reads as an empty slide");
}
#[test]
fn a_hidden_board_takes_no_time_on_the_timeline_at_all() {
let state = deck_state(
r##"{"version":"1.0.0","children":[
{"type":"frame","id":"f1","name":"one","x":0,"y":0,"width":1920,"height":1080,
"fill":[{"type":"solid","color":"#ff0000"}]},
{"type":"frame","id":"f2","name":"skipped","x":2000,"y":0,"width":1920,"height":1080,
"visible":false,"fill":[{"type":"solid","color":"#00ff00"}]},
{"type":"frame","id":"f3","name":"two","x":4000,"y":0,"width":1920,"height":1080,
"fill":[{"type":"solid","color":"#0000ff"}]}
]}"##,
);
let composition = deck_composition(&state).expect("deck builds");
assert_eq!(composition.scenes, 2);
assert!(!composition.html.contains("aria-label=\"skipped\""));
// The second scene starts when the FIRST one ends, not after a
// slot left open by the hidden board.
assert_eq!(
windows(&composition.html),
vec![
("0".to_string(), "3".to_string()),
("3".to_string(), "3".to_string())
]
);
assert_eq!(composition.total_ticks, 60);
}
#[test]
fn a_deck_with_no_visible_board_refuses_to_build() {
let state = deck_state(
r##"{"version":"1.0.0","children":[
{"type":"frame","id":"f1","name":"gone","x":0,"y":0,"width":1920,"height":1080,
"visible":false,"fill":[{"type":"solid","color":"#ff0000"}]}
]}"##,
);
assert_eq!(deck_composition(&state), Err(ExportError::NothingToExport));
}
#[test]
fn every_emitted_number_is_spelled_the_way_css_reads_numbers() {
// Sizes that a naive `{}` on an f32 would print as `1.0e-5` or with
// a long mantissa, and a text length that lands the hold time on a
// fraction.
let state = deck_state(
r##"{"version":"1.0.0","children":[
{"type":"frame","id":"f1","name":"a","x":0,"y":0,"width":1920,"height":1080,
"fill":[{"type":"solid","color":"#ffffff"}],"children":[
{"type":"text","id":"t1","x":0.00001,"y":0,"width":1400,"height":200,
"content":"一二三四五六七八","fontSize":48,
"fill":[{"type":"solid","color":"#101828"}]}
]},
{"type":"frame","id":"f2","name":"b","x":3000,"y":0,"width":2000,"height":1000,
"fill":[{"type":"solid","color":"#ffffff"}]}
]}"##,
);
let composition = deck_composition(&state).expect("deck builds");
let html = &composition.html;
// A locale comma or an exponent in a length or a time is the way
// this silently breaks: the browser drops the declaration and the
// scene is mispositioned or never shown.
for marker in [
"data-start=\"",
"data-duration=\"",
"--hf-start:",
"animation-delay:",
"animation-duration:",
] {
for rest in html.split(marker).skip(1) {
let value = rest
.split(['"', ';', 's'])
.next()
.expect("value is delimited");
// The one delay that is not a literal is the rule that
// forwards the inherited custom property; its VALUE is
// checked through the `--hf-start:` marker above.
if value.starts_with("var(") {
continue;
}
assert!(
value.parse::<f64>().is_ok(),
"{marker}{value:?} is not a number: {html}"
);
assert!(
value.chars().all(|c| c.is_ascii_digit() || c == '.'),
"{marker}{value:?} must be plain decimal: {html}"
);
}
}
// The second board is 2000x1000 in a 1920x1080 frame, so the fit
// maths really ran rather than being short-circuited by two equal
// sizes: 1920/2000 = 0.96, centred vertically at (1080-960)/2.
assert!(html.contains("transform:scale(0.96)"), "{html}");
assert!(html.contains("top:60px"), "{html}");
}
#[test]
fn a_scene_is_visible_only_inside_its_own_window() {
let state = deck_of(&["one", "two"]);
let html = deck_composition(&state).expect("deck builds").html;
// The stacking failure this guards: `fill-mode:both` would hold the
// last keyframe forever and leave every scene on screen at the end.
assert!(html.contains("animation-fill-mode:none"), "{html}");
assert!(!html.contains("animation-fill-mode:both"), "{html}");
assert!(html.contains(".hf-scene{position:absolute"), "{html}");
assert!(html.contains("visibility:hidden"), "{html}");
assert!(
html.contains("@keyframes hf-hold{from{visibility:visible}"),
"{html}"
);
}
/// The three attributes `hyperframes lint` reports as errors when they
/// are missing. Each one is a silent, expensive failure rather than a
/// cosmetic nit, so they are asserted rather than left to a manual lint
/// run: no `clip` renders every slide stacked from frame one, and no
/// `data-no-timeline` adds a 45-second poll to every render.
#[test]
fn the_composition_carries_what_the_renderer_requires() {
let state = deck_of(&["one", "two"]);
let html = deck_composition(&state).expect("deck builds").html;
assert_eq!(html.matches("class=\"clip hf-scene\"").count(), 2, "{html}");
assert!(html.contains("data-no-timeline"), "{html}");
assert!(html.contains(&format!("data-fps=\"{FPS}\"")), "{html}");
// Stable per-scene handles for the renderer's studio tooling.
assert!(html.contains("id=\"scene-1\""), "{html}");
assert!(html.contains("id=\"scene-2\""), "{html}");
// Exactly one element may carry a composition id, or the renderer
// discovers two entry points for one deck.
assert_eq!(html.matches("data-composition-id").count(), 1, "{html}");
}
#[test]
fn each_scene_enters_with_a_short_fade_and_no_transition_between_cuts() {
let state = deck_of(&["one", "two"]);
let html = deck_composition(&state).expect("deck builds").html;
assert!(
html.contains("@keyframes hf-enter{from{opacity:0}to{opacity:1}}"),
"{html}"
);
assert!(html.contains("animation-duration:0.3s"), "{html}");
// The fade targets the board's CHILDREN. Moving it up onto the
// slide box takes the board's own background with it and turns
// every cut into a black frame — a real regression this caught
// only once the composition was rendered to video.
assert!(
html.contains(".hf-slide > .n > *{animation-name:hf-enter"),
"{html}"
);
assert!(
html.contains(
".hf-slide{position:absolute;transform-origin:0 0;overflow:hidden;\
background:#fff}"
),
"the slide box must hard-cut, carrying no animation of its own: {html}"
);
// Animation longhands do not inherit; the per-scene delay reaches
// the children through this custom property or not at all.
assert!(html.contains("--hf-start:3s"), "{html}");
assert!(
html.contains("animation-delay:var(--hf-start,0s)"),
"{html}"
);
}
#[test]
fn the_composition_reaches_for_nothing_outside_itself() {
let state = deck_of(&["one"]);
let html = deck_composition(&state).expect("deck builds").html;
assert!(!html.contains("http://"), "{html}");
assert!(!html.contains("https://"), "{html}");
assert!(!html.contains("<link"), "{html}");
assert!(!html.contains("<script"), "{html}");
}
#[test]
fn deck_names_are_escaped_into_the_title_the_label_and_the_id() {
let state = deck_state(
r##"{"version":"1.0.0","children":[
{"type":"frame","id":"f1","name":"<script> & \"quotes\"","x":0,"y":0,
"width":1920,"height":1080,"fill":[{"type":"solid","color":"#ff0000"}]}
]}"##,
);
let html = deck_composition(&state).expect("deck builds").html;
assert!(
html.contains("aria-label=\"&lt;script&gt; &amp; &quot;quotes&quot;\""),
"{html}"
);
assert!(
html.contains("<title>&lt;script&gt; &amp; &quot;quotes&quot;</title>"),
"{html}"
);
assert!(
html.contains("data-composition-id=\"script-quotes\""),
"{html}"
);
assert!(!html.contains("<script> &"), "raw board name leaked");
}
#[test]
fn the_export_writes_the_composition_and_its_render_notes_side_by_side() {
let state = deck_of(&["one", "two"]);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"openpencil-hyperframes-{}-{nanos}",
std::process::id()
));
let written = export_deck_hyperframes(&state, &dir).expect("deck exports");
assert_eq!(written.composition_path, dir.join("index.html"));
assert_eq!(written.render_notes_path, dir.join("RENDER.md"));
let html = std::fs::read_to_string(&written.composition_path).expect("composition on disk");
let notes = std::fs::read_to_string(&written.render_notes_path).expect("notes on disk");
assert_eq!(html, written.composition.html);
assert!(
notes.contains("npx hyperframes render . --output deck.mp4"),
"{notes}"
);
assert!(notes.contains("npx hyperframes preview ."), "{notes}");
assert!(notes.contains("2 scene(s), 6s total"), "{notes}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hold_times_round_to_whole_ticks_so_starts_can_be_summed_as_integers() {
// 1.5 + 3/8 = 1.875s → clamped to the 3s floor.
assert_eq!(hold_ticks(3.0), 30);
// 1.5 + 41/8 = 6.625s → 6.6s, the nearest tick.
assert_eq!(hold_ticks(41.0), 66);
// 1.5 + 43/8 = 6.875s → 6.9s.
assert_eq!(hold_ticks(43.0), 69);
}
#[test]
fn seconds_never_emits_a_comma_or_an_exponent() {
assert_eq!(seconds(0), "0");
assert_eq!(seconds(30), "3");
assert_eq!(seconds(65), "6.5");
assert_eq!(seconds(1), "0.1");
assert_eq!(seconds(1005), "100.5");
}
#[test]
fn a_title_with_no_ascii_falls_back_to_a_usable_id() {
assert_eq!(slug("Quarterly Review"), "quarterly-review");
assert_eq!(slug(" spaced out "), "spaced-out");
assert_eq!(slug("季度回顾"), "deck");
assert_eq!(slug(""), "deck");
}
#[test]
fn latin_and_cjk_are_counted_on_one_scale() {
assert_eq!(units_in(""), 0.0);
assert_eq!(units_in("hello world"), 6.0);
assert_eq!(units_in("你好"), 2.0);
// Mixed: two CJK characters plus one Latin word.
assert_eq!(units_in("你好 world"), 5.0);
// A hyphen ends the run, so a compound counts as the two words a
// reader actually reads.
assert_eq!(units_in("well-known"), 6.0);
}

View file

@ -66,6 +66,7 @@ pub mod export_batch;
pub mod export_html;
mod export_html_structured;
mod export_html_template;
pub mod export_hyperframes;
pub mod export_pdf;
mod figma_convert;
mod figma_convert_error;