// Terminal: a terminal window that types, for code beats (LEJ-726, for LEJ-550). // It fills the frame over a moving ground. Commands are typed letter by letter, output arrives line by line, a cursor // blinks, and the window scrolls when it is full: the picture changes many times a second while it is up. // Needs only remotion and react, and Wash.tsx beside it: the ground, the colours and the fonts come from there. // Written from scratch. The idea (a timed list of prompt, output and tool-call events drawn in a terminal frame) is // the one in the MIT-licensed remotion-scene-kit by UrNas; no code from that package is used and none is needed. import React from 'react'; import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from 'remotion'; import { Wash, type WashTone, LEJ as C, FONT, clamp } from './Wash'; export type TermLine = { kind?: 'cmd' | 'out' | 'ok' | 'err' | 'note'; // cmd is typed after a prompt; the rest appear whole (default out) text: string; at?: number; // seconds from the start; default: right after the line before it hold?: number; // seconds to wait after this line before the next (default 0.25) }; export type TerminalProps = { lines: TermLine[]; title?: string; // the window's title bar (default ~/project) prompt?: string; // default $ cps?: number; // letters typed per second (default 26) size?: number; // type size in px (default 46 on a tall video: 12 px at phone width) tone?: WashTone; // the ground behind the window (default terra) ground?: string | null; // a picture for the ground; none: a moving two-tone gradient caption?: string; // one line of words above the window }; const INK = { cmd: '#F3EFEA', out: '#B9B4AE', ok: '#8FD6A0', err: '#FF9C8A', note: '#FFB08F' } as const; export const Terminal: React.FC = ({ lines, title = '~/project', prompt = '$', cps = 26, size, tone = 'terra', ground = null, caption }) => { const f = useCurrentFrame(); const { fps, width, height } = useVideoConfig(); const t = f / fps; const tall = height > width; const fs = size ?? (tall ? 46 : 40); const lh = fs * 1.36; const win = tall ? { x: 60, y: caption ? 520 : 330, w: width - 120, h: caption ? 940 : 1130 } : { x: width * 0.08, y: height * (caption ? 0.24 : 0.10), w: width * 0.84, h: height * (caption ? 0.66 : 0.80) }; const pad = 34; const cols = Math.max(8, Math.floor((win.w - pad * 2) / (fs * 0.6))); // when each line starts and ends let clock = 0.15; const plan = (lines || []).map((l) => { const kind = l.kind ?? 'out'; const start = l.at ?? clock; const dur = kind === 'cmd' ? l.text.length / cps : 0.06; clock = start + dur + (l.hold ?? 0.25); return { kind, text: l.text, start, end: start + dur }; }); // wrap to the window's width, keeping which line and how many letters came before each row const rows: { kind: keyof typeof INK; text: string; start: number; end: number; first: boolean; offset: number; total: number }[] = []; for (const p of plan) { const lead = p.kind === 'cmd' ? prompt.length + 1 : 0; const room = cols - lead; const words = p.text.split(' '); let cur = ''; const parts: string[] = []; for (const w of words) { if (cur && (cur + ' ' + w).length > room) { parts.push(cur); cur = w; } else cur = cur ? cur + ' ' + w : w; } parts.push(cur); let off = 0; parts.forEach((txt, i) => { rows.push({ kind: p.kind as keyof typeof INK, text: txt, start: p.start, end: p.end, first: i === 0, offset: off, total: p.text.length }); off += txt.length + 1; }); } const shown = rows.filter((r) => t >= r.start + (r.kind === 'cmd' ? r.offset / cps : 0)); const maxRows = Math.floor((win.h - 78 - pad * 2) / lh); const scroll = Math.max(0, shown.length - maxRows); const typing = plan.find((p) => p.kind === 'cmd' && t >= p.start && t < p.end); const blink = typing ? 1 : (Math.floor(t * 2.2) % 2 === 0 ? 1 : 0); const rise = interpolate(f, [0, 5], [0, 1], clamp); return ( {caption ? (
{caption}
) : null}
{['#FF6B5E', '#F5BF4F', '#5BCB6B'].map((c) =>
)}
{title}
{shown.map((r, i) => { const typed = r.kind === 'cmd' ? Math.max(0, Math.min(r.text.length, Math.floor((t - r.start) * cps) - r.offset)) : r.text.length; const last = i === shown.length - 1; return (
{r.kind === 'cmd' ? {r.first ? prompt + ' ' : ' '.repeat(prompt.length + 1)} : null} {r.kind === 'ok' ? {'✓ '} : null}{r.kind === 'err' ? {'✗ '} : null} {r.text.slice(0, typed)} {last ? : null}
); })}
); };