CRT terminal
A retro terminal with curved glass and glowing text.
Customize
import {Scene, Video} from "odori";
import {CrtTerminal} from "../components/crt-terminal/crt-terminal";
<Video>
<Scene id="crt-terminal" duration="9s">
<CrtTerminal
command="render launch --format mp4"
prompt=">"
phosphor="green"
intensity={0.6}
charactersPerSecond={16}
curvature={0.75}
/>
</Scene>
</Video>Installation
pnpm odori add component crt-terminalnpx odori add component crt-terminalbunx odori add component crt-terminalEdit the source in videos/components/crt-terminal/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/crt-terminal/crt-terminal.tsx
import {Fill, typedAt, useCanvas, useVideo} from "odori";
export type CrtTerminalProps = {
/** Lines already on the screen when the shot opens. */
history?: string[];
/** The line typed at the prompt. */
command?: string;
/** Lines printed after the command is entered. */
response?: string[];
/** What the shell prints before the command. */
prompt?: string;
/** The phosphor. Green is a P1 tube, amber a P3. */
phosphor?: "green" | "amber" | "white";
/** How hard the tube is driven, 0 to 1. Raises glow, bloom and flicker. */
intensity?: number;
/** Characters revealed per second. */
charactersPerSecond?: number;
/**
* How much glass there is, 0 to 1.
*
* Bowing the text alone leaves a flat rectangle with bent writing on it,
* which is not what a tube looks like: the giveaway is the face itself,
* corners pulled in and edges pushed out. This drives both, so one number
* takes the screen from a flat panel to an obvious bulb.
*/
curvature?: number;
};
const PHOSPHOR = {
green: {ink: [126, 255, 148], glow: "rgba(80,255,120,"},
amber: {ink: [255, 176, 60], glow: "rgba(255,176,60,"},
white: {ink: [222, 236, 255], glow: "rgba(200,225,255,"},
};
/**
* A terminal on a cathode ray tube, drawn as pixels rather than as DOM.
*
* Everything that makes a CRT look like a CRT happens after the text exists,
* which is exactly what a canvas is for and what live DOM cannot do: the glass
* bulges, so lines bow away from the centre; the beam is not perfectly
* converged, so red and blue sit a hair either side of green; the phosphor
* blooms, so bright text carries a halo; the shadow mask leaves scanlines; and
* the supply is imperfect, so the whole image breathes.
*
* Every one of those is a function of the frame rather than of wall time,
* including the flicker and the roll, so the same frame draws the same tube
* twice and two export workers meeting at a chunk boundary agree.
*/
export const CrtTerminal = ({
history = ["ODORI SYSTEM v0.0.3", "READY."],
command = "render launch --format mp4",
response = ["CAPTURING 360 FRAMES", "ENCODING H.264", "DONE. OUT/LAUNCH.MP4"],
prompt = ">",
phosphor = "green",
intensity = 0.6,
charactersPerSecond = 16,
curvature = 0.75,
}: CrtTerminalProps) => {
const {width, height} = useVideo();
const tube = PHOSPHOR[phosphor];
const canvas = useCanvas(
(context, {frame, width: w, height: h}) => {
const drive = Math.min(1, Math.max(0, intensity));
const curve = Math.min(1, Math.max(0, curvature));
const [r, g, b] = tube.ink;
const size = Math.round(h / 26);
const lineHeight = size * 1.55;
const left = w * 0.11;
const top = h * 0.16;
// The supply breathes. Two slow sines rather than one, so the wobble
// never settles into an obvious beat.
const flicker = 1 - drive * (0.03 + 0.022 * Math.sin(frame * 0.7) + 0.012 * Math.sin(frame * 0.31));
context.fillStyle = "#050705";
context.fillRect(0, 0, w, h);
// The tube's own glow, before anything is written on it.
const halo = context.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.62);
halo.addColorStop(0, `${tube.glow}${0.055 * drive})`);
halo.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = halo;
context.fillRect(0, 0, w, h);
const typed = typedAt(command, frame, {from: 20, charactersPerSecond});
const enteredAt = 20 + Math.ceil((command.length / charactersPerSecond) * 30) + 10;
const lines = [
...history.map((line) => ({text: line, dim: 0.72})),
{text: `${prompt} ${typed.text}${typed.caret && frame < enteredAt ? "█" : ""}`, dim: 1},
...response
.filter((_, index) => frame >= enteredAt + 12 + index * 16)
.map((line) => ({text: line, dim: 0.86})),
];
context.textBaseline = "top";
context.font = `${size}px ui-monospace, "SF Mono", Menlo, monospace`;
lines.forEach((line, index) => {
const y = top + index * lineHeight;
// The glass bulges: a line further from the centre bows outward and
// its ends sit a touch lower than its middle.
const fromCentre = (y + size / 2 - h / 2) / (h / 2);
const bow = fromCentre * fromCentre * h * (0.012 + 0.05 * curve);
const x = left + fromCentre * fromCentre * w * (0.006 + 0.022 * curve);
const alpha = line.dim * flicker;
// Bloom first, so the text sits inside its own halo.
context.globalCompositeOperation = "lighter";
context.shadowColor = `${tube.glow}${0.55 * drive})`;
context.shadowBlur = size * (0.5 + drive * 0.9);
// The beam is not perfectly converged.
const split = 1 + drive * 1.6;
context.fillStyle = `rgba(${r},60,60,${alpha * 0.5})`;
context.fillText(line.text, x - split, y + bow);
context.fillStyle = `rgba(60,60,${b},${alpha * 0.5})`;
context.fillText(line.text, x + split, y + bow);
context.fillStyle = `rgba(${r},${g},${b},${alpha})`;
context.fillText(line.text, x, y + bow);
context.shadowBlur = 0;
context.globalCompositeOperation = "source-over";
});
// The shadow mask. Every third line is dark, and the whole grille rolls
// slowly, which is the artefact a phone camera sees on a real tube.
const roll = (frame * 0.6) % 3;
context.fillStyle = `rgba(0,0,0,${0.16 + drive * 0.2})`;
for (let y = -3 + roll; y < h; y += 3) context.fillRect(0, y, w, 1);
// A brighter band drifting down the screen, once every few seconds.
const band = ((frame * 2.2) % (h + 400)) - 200;
const sweep = context.createLinearGradient(0, band - 160, 0, band + 160);
sweep.addColorStop(0, "rgba(255,255,255,0)");
sweep.addColorStop(0.5, `rgba(255,255,255,${0.016 * drive})`);
sweep.addColorStop(1, "rgba(255,255,255,0)");
context.fillStyle = sweep;
context.fillRect(0, 0, w, h);
// The corners of the glass.
const vignette = context.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.32, w / 2, h / 2, Math.max(w, h) * 0.72);
vignette.addColorStop(0, "rgba(0,0,0,0)");
vignette.addColorStop(1, "rgba(0,0,0,0.82)");
context.fillStyle = vignette;
context.fillRect(0, 0, w, h);
/*
* The face of the tube, cut last so everything above it is inside the
* glass rather than beside it.
*
* Four quadratics, one per edge, each bulging away from the centre: the
* corners come in and the sides go out, which is the shape a shadow
* mask actually is. Cutting it with destination-in means the scanlines,
* the sweep, and the vignette all stop at the same curve instead of
* running square off the edge and giving the panel away.
*/
const inset = Math.min(w, h) * (0.012 + 0.03 * curve);
const bulgeX = w * 0.055 * curve;
const bulgeY = h * 0.075 * curve;
const face = (): void => {
const x0 = inset;
const y0 = inset;
const x1 = w - inset;
const y1 = h - inset;
context.beginPath();
context.moveTo(x0, y0);
context.quadraticCurveTo(w / 2, y0 - bulgeY, x1, y0);
context.quadraticCurveTo(x1 + bulgeX, h / 2, x1, y1);
context.quadraticCurveTo(w / 2, y1 + bulgeY, x0, y1);
context.quadraticCurveTo(x0 - bulgeX, h / 2, x0, y0);
context.closePath();
};
context.globalCompositeOperation = "destination-in";
context.fillStyle = "#000";
face();
context.fill();
context.globalCompositeOperation = "source-over";
// The rim catches the room it is sitting in.
face();
context.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * curve})`;
context.lineWidth = Math.max(1, Math.min(w, h) * 0.004);
context.stroke();
},
[history, command, response, prompt, phosphor, intensity, charactersPerSecond, curvature],
);
return (
<Fill style={{alignItems: "center", background: "#000000", justifyContent: "center"}}>
<canvas ref={canvas} width={width} height={height} style={{height: "100%", width: "100%"}} />
</Fill>
);
};
videos/components/crt-terminal/crt-terminal.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {CrtTerminal} from "./crt-terminal";
export default defineComponentPreview({
title: "CRT terminal",
category: "Media/Treatments",
description: "A retro terminal with curved glass and glowing text.",
component: CrtTerminal,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
command: {type: "text", defaultValue: "render launch --format mp4", maxLength: 60},
prompt: {type: "text", defaultValue: ">", maxLength: 8},
phosphor: {type: "select", defaultValue: "green", options: ["green", "amber", "white"]},
intensity: {type: "number", defaultValue: 0.6, min: 0, max: 1, step: 0.05},
charactersPerSecond: {type: "number", defaultValue: 16, min: 4, max: 40, step: 1},
curvature: {type: "number", defaultValue: 0.75, min: 0, max: 1, step: 0.05},
},
examples: [
{name: "P1 green", props: {}},
{name: "Amber", props: {phosphor: "amber", intensity: 0.75}},
{name: "Flat panel", props: {curvature: 0.1, intensity: 0.4}},
{
name: "Cold boot",
props: {
phosphor: "white",
intensity: 0.35,
history: ["MEMORY OK", "LOADING ODORI…"],
command: "boot",
response: ["RUNTIME READY", "STUDIO ON 127.0.0.1:4300"],
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {CrtTerminal} from "../components/crt-terminal/crt-terminal";
<Video>
<Scene id="crt-terminal" duration="9s">
<CrtTerminal
command="render launch --format mp4"
prompt=">"
phosphor="green"
intensity={0.6}
charactersPerSecond={16}
curvature={0.75}
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Media
- Recommended duration
- 270 frames · 9s
- Minimum duration
- 120 frames · 4s
- Entrance
- 12 frames
- Exit
- 10 frames
- Reduced motion
- no flicker, roll or sweep; text held
- Requires
- sans font, mono font
- Content limits
- command ≤ 60, prompt ≤ 8
Props
| Prop | Type | Default | Required |
|---|---|---|---|
historyLines already on the screen when the shot opens. | string[] | ["ODORI SYSTEM v0.0.3" | — |
commandThe line typed at the prompt. | string | "render launch --format mp4" | — |
responseLines printed after the command is entered. | string[] | ["CAPTURING 360 FRAMES" | — |
promptWhat the shell prints before the command. | string | ">" | — |
phosphorThe phosphor. Green is a P1 tube, amber a P3. | "green" | "amber" | "white" | "green" | — |
intensityHow hard the tube is driven, 0 to 1. Raises glow, bloom and flicker. | number | 0.6 | — |
charactersPerSecondCharacters revealed per second. | number | 16 | — |
curvatureHow much glass there is, 0 to 1. Bowing the text alone leaves a flat rectangle with bent writing on it, which is not what a tube looks like: the giveaway is the face itself, corners pulled in and edges pushed out. This drives both, so one number takes the screen from a flat panel to an obvious bulb. | number | 0.75 | — |
Related components
All componentsB-roll windowSupporting footage framed beside the main narrative.Canvas magnifierA magnifying lens over your content.Canvas stageFrame-driven canvas drawing with export parity.CarouselA paced media sequence with stable aspect handling.Glare sweepA light reflection that sweeps across your content.Halftone printText revealed as a pattern of printed dots.