diff --git a/AGENTS.md b/AGENTS.md index d2dd180..3bb421b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,7 +320,8 @@ the scene it pulls back, so the last frame shows the whole idea. During each scene it also pushes in very slowly. While a shot holds, every card and label is either fully in the shot or out -of it. Arrows and lines may run off the edge. While the camera moves from +of it. Arrows and lines may run off the edge, but their labels stay whole, and +so do the labels of charts. While the camera moves from one shot to the next, things may pass the edge of the frame for a moment. Shots stay clear of the captions and of the parts the app covers on Shorts, TikTok and Reels. @@ -334,8 +335,9 @@ at the end, and let the camera find the close-ups: - Keep a box with its label, or an icon with its caption, together as one unit. Things far apart get their own shots. - Lines, arrows and things added inside an element already on screen, like - a line in a chart, get no close-up of their own. Use `s.focus` when one - should. + a line in a chart, get no close-up of their own. Neither does an icon + without a label or a box without words, because up close it shows nothing + new. Give it a label, or use `s.focus` when one should get a close-up. - When a scene goes on with the same picture, draw the carried elements with `at: -1` in the same places. The camera then goes on from where it stopped, and the two scenes fade into each other. diff --git a/engine/director.js b/engine/director.js index 13c1d7e..db3bb89 100644 --- a/engine/director.js +++ b/engine/director.js @@ -223,7 +223,9 @@ export function planCamera(elements, { dur, view, frame, pace = 1, voiceEnd = nu const hold = MIN_HOLD / pace; const visibleDuring = (t0, t1) => items.filter((e) => e.at <= t1 && e.out > t0 + 0.25); const picture = (list) => list.filter((e) => e.kind !== 'focus'); - const solid = (list) => list.filter((e) => !['arrow', 'line', 'focus'].includes(e.kind)); + // What the camera may move in on. An arrow's label is kept whole, but it + // is part of the arrow, which gets no close-up. + const solid = (list) => list.filter((e) => !['arrow', 'line', 'focus', 'label'].includes(e.kind)); // Everything on screen from t0 to t1, framed as one picture. const overview = (t0, t1) => { const v = picture(visibleDuring(t0, t1)); @@ -231,7 +233,8 @@ export function planCamera(elements, { dur, view, frame, pace = 1, voiceEnd = nu }; // Whether a shot already shows these elements whole, in the free part of // the frame, also at the end of its slow push. A beat of only arrows and - // lines fits when they are at least partly in view. + // lines fits when they are at least partly in view. An arrow's label has + // to be whole. const fits = (shot, list) => { const body = list.filter((e) => e.kind !== 'arrow' && e.kind !== 'line'); const tight = shot.drift === 0 ? shot : clampShot({ ...shot, z: shot.z * (1 + DRIFT) }, view, frame); @@ -323,14 +326,15 @@ export function planCamera(elements, { dur, view, frame, pace = 1, voiceEnd = nu } continue; } - const before = items.filter((e) => e.at < beat.start - 0.05 && e.out > beat.start + 0.3 && e.kind !== 'focus' && e.kind !== 'ink'); + const before = items.filter((e) => e.at < beat.start - 0.05 && e.out > beat.start + 0.3 && !['focus', 'ink', 'label'].includes(e.kind)); if (fits(cur, fresh)) { // It is already in the shot. The camera moves in only on a new card, // label or picture. A line, an arrow, or something drawn inside an // element that is already there, like a line in a chart, gets no - // close-up. It tries the new thing with its neighbours first, then on - // its own. - const own = solid(fresh).filter((e) => !before.some((f) => f.box.w * f.box.h >= e.box.w * e.box.h * 2 && contains(f.box, e.box))); + // close-up. Nor does an icon or shape without words: up close it shows + // nothing more. It tries the new thing with its neighbours first, then + // on its own. + const own = solid(fresh).filter((e) => !e.bare && !before.some((f) => f.box.w * f.box.h >= e.box.w * e.box.h * 2 && contains(f.box, e.box))); // It is on screen already, so the push may wait a moment for its turn. const at = Math.max(begin, last + hold); if (!own.length || at > beat.start + 1 / pace || at - zoomed < PUSH_GAP / pace) continue; @@ -447,9 +451,12 @@ export function cameraMoving(plan, t) { // Whether scene B picks up where scene A left off: what A's last frame // shows is mostly still there when B starts, in the same places. `last` is -// the part of the scene A's camera shows at the end. +// the part of the scene A's camera shows at the end. An arrow's label goes +// with its arrow, so it does not count on its own. export function continues(a, b, last) { const inView = (e) => e.box.x + e.box.w / 2 > last.x0 && e.box.x - e.box.w / 2 < last.x1 && e.box.y + e.box.h / 2 > last.y0 && e.box.y - e.box.h / 2 < last.y1; + a = a.filter((e) => e.kind !== 'label'); + b = b.filter((e) => e.kind !== 'label'); const end = a.filter((e) => e.out === Infinity && inView(e)); const carried = b.filter((e) => e.at <= 0.05); if (end.length < 2 || carried.length < 2) return false; diff --git a/engine/stage.js b/engine/stage.js index e2dafc4..cdc93f3 100644 --- a/engine/stage.js +++ b/engine/stage.js @@ -229,9 +229,10 @@ export class Stage { if (sc !== 1 || rad) box = { ...box, w: (Math.abs(box.w * Math.cos(rad)) + Math.abs(box.h * Math.sin(rad))) * sc, h: (Math.abs(box.w * Math.sin(rad)) + Math.abs(box.h * Math.cos(rad))) * sc }; // _pendingExtra is already measured with the element's transform. if (this._pendingExtra) box = unionBox(box, this._pendingExtra); - this._attach(kind, at, out, box); + this._attach(kind, at, out, box, this._pendingInfo); this._pendingBox = null; this._pendingExtra = null; + this._pendingInfo = null; } if (this._suppress > 0) return null; if (this.t < at) return null; @@ -368,6 +369,32 @@ export class Stage { return { x: (Math.min(...xs) + Math.max(...xs)) / 2, y: (Math.min(...ys) + Math.max(...ys)) / 2, w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }; } + // The box around text drawn beside an element, like a chart's labels, for + // the camera: where the letters land, as the browser measures them. Each + // entry: { text, x, y, font, align, base } with canvas textAlign and + // textBaseline values. + _textExtent(entries) { + let x0 = Infinity; + let y0 = Infinity; + let x1 = -Infinity; + let y1 = -Infinity; + const ctx = this.ctx; + ctx.save(); + for (const e of entries) { + if (!e.text) continue; + ctx.font = e.font; + ctx.textAlign = e.align ?? 'center'; + ctx.textBaseline = e.base ?? 'middle'; + const m = ctx.measureText(e.text); + x0 = Math.min(x0, e.x - m.actualBoundingBoxLeft - 2); + x1 = Math.max(x1, e.x + m.actualBoundingBoxRight + 2); + y0 = Math.min(y0, e.y - m.actualBoundingBoxAscent - 2); + y1 = Math.max(y1, e.y + m.actualBoundingBoxDescent + 2); + } + ctx.restore(); + return x1 > x0 ? this._designBox((x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0) : null; + } + _designBox(x, y, w, h) { const m = this._matrix(); const pts = [[x - w / 2, y - h / 2], [x + w / 2, y - h / 2], [x + w / 2, y + h / 2], [x - w / 2, y + h / 2]].map(([px, py]) => m.transformPoint(new DOMPoint(px, py))); @@ -821,6 +848,8 @@ export class Stage { const y = o.y ?? this.cy; const shape = o.shape ?? 'rect'; this._register(o.id, x, y, w, h, shape === 'ellipse' || shape === 'circle' ? 'ellipse' : 'rect'); + // A shape without words has no detail for the camera to move in on. + if (this.engine.layoutLog && !lay) this._pendingInfo = { bare: true }; const g = geom(x, y, w, h); const plain = lay ? String(label).replace(/\*/g, '') : ''; const life = this._life(o, 'shape', { label: plain.slice(0, 40) || o.icon }); @@ -922,7 +951,18 @@ export class Stage { if (this.engine.layoutLog) { const xs = [a.x, b.x, c.x]; const ys = [a.y, b.y, c.y]; - this._logLayout('arrow', this.time(o.at ?? 0), o.out === undefined ? Infinity : this.time(o.out), (Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2, Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)); + const at = this.time(o.at ?? 0); + const out = o.out === undefined ? Infinity : this.time(o.out); + this._logLayout('arrow', at, out, (Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2, Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)); + // The line may run off the edge of a close-up, but its label is text, + // so the camera keeps it whole. A label outside the frame is never + // seen, so the camera leaves it alone. + if (o.label) { + const label = this._arrowLabel(o, d, a, b, bend); + const box = this._textExtent([{ text: String(o.label), x: label.x, y: label.y, font: label.font }]); + const seen = box.x + box.w / 2 > 0 && box.x - box.w / 2 < this.W && box.y + box.h / 2 > 0 && box.y - box.h / 2 < this.H; + if (seen) this._attach('label', at, out, box); + } } const result = { from: a, to: b, control: c }; if (!life) return result; @@ -955,41 +995,16 @@ export class Stage { if ((head === 'start' || head === 'both') && prog > 0.2) drawHead(0.001, -1); } if (o.label) { - const mid = pointAt(d, L / 2); - // Put the label on the outer side of a curve, or above a straight arrow. - let nx; - let ny; - const bx = mid.x - (a.x + b.x) / 2; - const by = mid.y - (a.y + b.y) / 2; - if (bend && Math.hypot(bx, by) > 1) { - nx = bx / Math.hypot(bx, by); - ny = by / Math.hypot(bx, by); - } else { - const len = Math.hypot(dx, dy) || 1; - nx = -dy / len; - ny = dx / len; - if (ny > 0 || (ny === 0 && nx > 0)) { - nx = -nx; - ny = -ny; - } - } - const off = o.labelOffset ?? 34; - const ls = o.labelSize ?? 34; const la = clamp((life.p - 0.55) / 0.35); if (la > 0) { + const { x: lx, y: ly, w, size: ls, font } = this._arrowLabel(o, d, a, b, bend); ctx.save(); ctx.globalAlpha *= la; - ctx.font = fontString(th, o.labelFont ?? 'hand', ls); + ctx.font = font; ctx.fillStyle = colorOf(th, o.labelColor ?? o.color, 'muted'); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - const lw = ctx.measureText(String(o.label)).width; - // Push further out when the label runs along the arrow's direction. - const extra = Math.abs(nx) * lw * 0.5; - const lx = mid.x + nx * (off + extra); - const ly = mid.y + ny * off; ctx.fillText(String(o.label), lx, ly); - const w = ctx.measureText(String(o.label)).width; this._logText({ text: String(o.label), x0: lx - w / 2, y0: ly - ls / 2, x1: lx + w / 2, y1: ly + ls / 2, size: ls, fg: ctx.fillStyle, bg: null }); ctx.restore(); } @@ -998,6 +1013,40 @@ export class Stage { return result; } + // Where an arrow's label goes: on the outer side of a curve, or above a + // straight arrow. x, y is the label's center. + _arrowLabel(o, d, a, b, bend) { + const mid = pointAt(d, pathLength(d) / 2); + let nx; + let ny; + const bx = mid.x - (a.x + b.x) / 2; + const by = mid.y - (a.y + b.y) / 2; + if (bend && Math.hypot(bx, by) > 1) { + nx = bx / Math.hypot(bx, by); + ny = by / Math.hypot(bx, by); + } else { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + nx = -dy / len; + ny = dx / len; + if (ny > 0 || (ny === 0 && nx > 0)) { + nx = -nx; + ny = -ny; + } + } + const off = o.labelOffset ?? 34; + const size = o.labelSize ?? 34; + const font = fontString(this.theme, o.labelFont ?? 'hand', size); + this.ctx.save(); + this.ctx.font = font; + const w = this.ctx.measureText(String(o.label)).width; + this.ctx.restore(); + // Further out when the label runs along the arrow's direction. + const extra = Math.abs(nx) * w * 0.5; + return { x: mid.x + nx * (off + extra), y: mid.y + ny * off, w, size, font }; + } + connect(a, b, o = {}) { return this.arrow(a, b, o); } @@ -1037,6 +1086,8 @@ export class Stage { const y = o.y ?? this.cy; const bgSize = o.bg ? size * (o.bgScale ?? 1.7) : size; this._register(o.id, x, y, bgSize, bgSize, o.bg === 'square' ? 'rect' : o.bg ? 'ellipse' : 'rect'); + // Nor has an icon without a label. + if (this.engine.layoutLog && !o.label) this._pendingInfo = { bare: true }; if (this.engine.layoutLog && o.label) { // The label belongs to the icon for the camera, not for arrows. const ls = o.labelSize ?? 36; @@ -1355,6 +1406,22 @@ export class Stage { const at = this.time(o.at ?? 0); const stagger = (o.stagger ?? 0.22) / this.pace; this._register(o.id, x, y, w, h); + if (this.engine.layoutLog) { + // The labels under the bars and the values on top count for the + // camera, not for arrows. + const format = o.format ?? ((v) => (Number.isInteger(v) ? String(Math.round(v)) : v.toFixed(1))); + const ls = o.labelSize ?? 34; + const vs = o.valueSize ?? 40; + const text = this._textExtent(items.flatMap((d, i) => { + const cx = x - w / 2 + slot * i + slot / 2; + const labels = d.label !== undefined ? [{ text: String(d.label), x: cx, y: base + 16, font: fontString(th, 'body', ls), base: 'top' }] : []; + const values = o.values !== false ? [{ text: `${o.prefix ?? ''}${format(d.value)}${o.suffix ?? ''}`, x: cx, y: base - (d.value / max) * h - 12, font: fontString(th, 'display', vs), base: 'bottom' }] : []; + return [...labels, ...values]; + })); + // The axis reaches 20 pixels past the bars on each side. + const axis = this._designBox(x, y, w + 40 + th.stroke.width * 2, h + th.stroke.width * 2); + this._pendingExtra = text ? unionBox(axis, text) : axis; + } // Each bar can have its own `at` (seconds, a spoken word or "#marker"). const barAt = items.map((d, i) => (d.at !== undefined ? this.time(d.at) : at + 0.3 / this.pace + i * stagger)); items.forEach((d, i) => this._record('chart', barAt[i], 0.8, { sfx: o.sfx }, { label: `bar${i}` })); @@ -1421,6 +1488,15 @@ export class Stage { const hi = o.max ?? Math.max(...values) * 1.1; const pts = values.map((v, i) => [x - w / 2 + (w * i) / Math.max(1, values.length - 1), y + h / 2 - ((v - lo) / (hi - lo || 1)) * h]); this._register(o.id, x, y, w, h); + if (this.engine.layoutLog) { + // The ends of the axes, the dots and the labels under the axis count + // for the camera, not for arrows. + const pad = 12 + th.stroke.width * 1.3; + const around = this._designBox(x, y, w + pad * 2, h + pad * 2); + const ls = o.labelSize ?? 30; + const text = o.labels && this._textExtent(o.labels.slice(0, pts.length).map((l, i) => ({ text: String(l), x: pts[i][0], y: y + h / 2 + 14, font: fontString(th, 'body', ls), base: 'top' }))); + this._pendingExtra = text ? unionBox(around, text) : around; + } const life = this._life(o, 'chart', { enter: 'draw', dur: o.dur ?? 1.6 }); if (!life) return geom(x, y, w, h); ctx.save(); @@ -1478,6 +1554,23 @@ export class Stage { const hole = (o.donut ?? 0) * r; const total = items.reduce((a, d) => a + d.value, 0) || 1; this._register(o.id, x, y, r * 2, r * 2, 'ellipse'); + if (this.engine.layoutLog) { + // The outline of the slices and the labels around the pie count for + // the camera, not for arrows. + const ring = this._designBox(x, y, r * 2 + th.stroke.width * 4, r * 2 + th.stroke.width * 4); + const ls = o.labelSize ?? 32; + const lr = r + (o.labelOffset ?? 56); + let a0 = -Math.PI / 2; + const text = o.labels !== false && this._textExtent(items.map((d) => { + const full = (d.value / total) * Math.PI * 2; + const mid = a0 + full / 2; + a0 += full; + if (full <= 0.001) return {}; + const text = d.label !== undefined ? `${d.label} ${Math.round((d.value / total) * 100)}%` : `${Math.round((d.value / total) * 100)}%`; + return { text, x: x + Math.cos(mid) * lr, y: y + Math.sin(mid) * lr, font: fontString(th, 'body', ls, BOLD.body), align: Math.cos(mid) > 0.2 ? 'left' : Math.cos(mid) < -0.2 ? 'right' : 'center', base: 'middle' }; + })); + this._pendingExtra = text ? unionBox(ring, text) : ring; + } const life = this._life(o, 'chart', { enter: 'grow', dur: o.dur ?? 1.2 }); if (!life) return geom(x, y, r * 2, r * 2); ctx.save(); diff --git a/test/camera-browser.test.js b/test/camera-browser.test.js index e35f063..1f3d8a3 100644 --- a/test/camera-browser.test.js +++ b/test/camera-browser.test.js @@ -146,3 +146,80 @@ test('large type tracking stays with its own text', { skip: !chrome }, async () }); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('the camera measures the labels of arrows and charts', { skip: !chrome }, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'explainroo-labels-')); + try { + fs.writeFileSync(path.join(dir, 'video.json'), JSON.stringify({ theme: 'clean', captions: false, watermark: false, music: false, sfx: false })); + fs.writeFileSync(path.join(dir, 'script.md'), '## demo {min=8}\n'); + fs.writeFileSync(path.join(dir, 'scenes.js'), 'export default { demo(s) {} };'); + const project = loadProject(dir); + await withEngine(project, buildTimeline(project, {}), {}, async (page) => { + const r = await page.evaluate(async () => { + const { Engine, fetchState } = await import('/engine/runtime.js'); + const state = await fetchState(); + const engine = (demo, canvas = document.createElement('canvas')) => new Engine({ canvas, state, scenes: { demo }, icons: { dot: ['M0 0L24 24'] }, images: new Map() }); + const elements = (demo) => { + const e = engine(demo); + e.planCameras(); + return e.cameraPlans.get(0)?.elements ?? []; + }; + // Where a scene paints anything, compared with an empty scene. + const pixels = (demo) => { + const c = document.createElement('canvas'); + c.width = 1920; + c.height = 1080; + engine(demo, c).renderFrame(5); + return c.getContext('2d').getImageData(0, 0, 1920, 1080).data; + }; + const empty = pixels(() => {}); + const ink = (demo) => { + const d = pixels(demo); + const b = { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; + for (let i = 0; i < d.length; i += 4) { + if (Math.abs(d[i] - empty[i]) + Math.abs(d[i + 1] - empty[i + 1]) + Math.abs(d[i + 2] - empty[i + 2]) < 60) continue; + const x = (i / 4) % 1920; + const y = Math.floor(i / 4 / 1920); + b.x0 = Math.min(b.x0, x); b.y0 = Math.min(b.y0, y); b.x1 = Math.max(b.x1, x + 1); b.y1 = Math.max(b.y1, y + 1); + } + return b; + }; + const edges = (box) => box && { x0: box.x - box.w / 2, y0: box.y - box.h / 2, x1: box.x + box.w / 2, y1: box.y + box.h / 2 }; + const scenes = { + // Only the label shows: the line is drawn in a transparent color. + arrow: (s) => s.arrow([500, 600], [1400, 450], { color: 'rgba(0,0,0,0)', labelColor: '#d00', label: 'asks for the page', labelSize: 48, at: 0 }), + curved: (s) => s.arrow([600, 500], [1300, 500], { bend: 0.8, color: 'rgba(0,0,0,0)', labelColor: '#d00', label: 'ask the name server', at: 0 }), + line: (s) => s.lineChart([3, 5, 4, 8, 13], { labels: ['Monday', 'Tue', 'Wed', 'Thu', 'Friday'], labelSize: 40, at: 0, dur: 1 }), + bars: (s) => s.bars([{ label: 'A very long label', value: 19 }, { label: 'B', value: 31 }], { suffix: ' thousand', at: 0 }), + pie: (s) => s.pie([{ label: 'Images', value: 55 }, { label: 'Other', value: 45 }], { at: 0 }), + }; + const out = {}; + for (const [name, demo] of Object.entries(scenes)) { + const els = elements(demo); + out[name] = { kinds: els.map((e) => e.kind), box: edges((els.find((e) => e.kind === 'label') ?? els.find((e) => e.kind === 'chart'))?.box), ink: ink(demo) }; + } + // A label the frame never shows is left out. + out.offFrame = elements((s) => s.arrow([820, 540], [1120, 540], { at: 0, label: 'request', labelOffset: 640 })).map((e) => e.kind); + out.bare = elements((s) => { + s.icon('dot', { x: 300, y: 300, at: 0 }); + s.icon('dot', { x: 700, y: 300, label: 'A label', at: 0 }); + s.box('', { x: 300, y: 700, w: 200, h: 100, at: 0 }); + s.box('Words', { x: 700, y: 700, at: 0 }); + }).map((e) => e.bare); + return out; + }); + const { bare, offFrame } = r; + delete r.bare; + delete r.offFrame; + for (const [name, { box, ink }] of Object.entries(r)) { + assert.ok(box, `${name}: ${JSON.stringify(r[name].kinds)}`); + assert.ok(box.x0 <= ink.x0 + 1 && box.y0 <= ink.y0 + 1 && box.x1 >= ink.x1 - 1 && box.y1 >= ink.y1 - 1, `${name}: ${JSON.stringify({ box, ink })}`); + } + // An arrow's label is its own element; the line stays an arrow. + assert.deepEqual(r.arrow.kinds, ['arrow', 'label']); + // Icons and boxes without words are marked, so the camera does not move in on them. + assert.deepEqual(bare, [true, undefined, true, undefined]); + assert.deepEqual(offFrame, ['arrow']); + }); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/test/director.test.js b/test/director.test.js index fc502ee..15e4697 100644 --- a/test/director.test.js +++ b/test/director.test.js @@ -269,3 +269,48 @@ test('a continued scene also ends on the whole picture', () => { const end = cameraAt(plan, 4.75); for (const e of items) assert.ok(whole(end, e), `box at ${e.box.x}`); }); + +test("an arrow's label is kept whole, and a labelled arrow gets no close-up", () => { + const els = [ + el('shape', 300, 420, 300, 160, 0.3), + el('shape', 1100, 420, 300, 160, 0.3), + el('arrow', 600, 480, 500, 40, 4), + el('label', 700, 380, 300, 40, 4), + el('shape', 1300, 760, 300, 120, 9), + ]; + const plan = planCamera(els, { dur: 14, view, frame }); + assert.ok(!plan.moves.some((m) => m.start > 3 && m.start < 5.5), JSON.stringify(plan.moves.map((m) => m.start))); + for (let t = 0; t < 14; t += 0.05) { + if (cameraMoving(plan, t)) continue; + const shown = els.filter((e) => e.at <= t && e.out > t); + assert.deepEqual(cuts(cameraAt(plan, t), shown), [], `t=${t.toFixed(2)}`); + } + // A close-up next to an arrow leaves its label out or takes it in. + const label = el('label', 520, 300, 360, 50, 0); + const shot = compose([el('shape', 860, 360, 200, 120, 0)], [label, el('arrow', 400, 360, 460, 30, 0)], view, frame); + assert.ok(shot.z > 1.2, `z ${shot.z}`); + assert.deepEqual(cuts(shot, [label]), []); +}); + +test('an icon or shape without words gets no close-up of its own', () => { + // The picture fills the frame from the start, so the icon fits the shot. + const picture = [el('text', 560, 120, 800, 120, 0.2), el('shape', 150, 400, 300, 300, 0.2), el('shape', 1470, 400, 300, 300, 0.2)]; + const plan = (icon) => planCamera([...picture, icon], { dur: 12, view, frame, voiceEnd: 11 }); + const icon = el('icon', 900, 600, 120, 120, 3); + const bare = plan({ ...icon, bare: true }); + assert.ok(!bare.moves.some((m) => m.start > 2 && m.start < 4 && m.to.z > m.from.z * 1.2), JSON.stringify(bare.moves)); + const labelled = plan({ ...icon, box: { ...icon.box, h: 190 } }); + assert.ok(labelled.moves.some((m) => m.start > 2 && m.start < 4 && m.to.z > m.from.z * 1.2), JSON.stringify(labelled.moves)); +}); + +test("an arrow's label does not count on its own when a scene continues the one before", () => { + const card = (x) => el('shape', x, 450, 260, 140, 0.5); + const arrow = (x) => el('arrow', x, 500, 300, 40, 1); + const label = (x) => el('label', x + 60, 440, 180, 40, 1); + const a = [card(200), card(780), arrow(470), label(470), card(1360), arrow(1050), label(1050)]; + const b = [card(200), card(780), card(1360), arrow(470), label(470)].map((e) => ({ ...e, at: -1 })); + assert.equal(continues(a, b, { x0: 0, y0: 0, x1: 1920, y1: 1080 }), continues(a.filter((e) => e.kind !== 'label'), b.filter((e) => e.kind !== 'label'), { x0: 0, y0: 0, x1: 1920, y1: 1080 })); + const two = [card(200), card(780), arrow(470), label(470), arrow(1050), label(1050)]; + const one = [card(200), card(780), arrow(470)].map((e) => ({ ...e, at: -1 })); + assert.ok(continues(two, one, { x0: 0, y0: 0, x1: 1920, y1: 1080 })); +});