Install
That writes one file:compositions/oscilloscope-trace.html.
Add it to your video
It runs for 6 seconds at 1920×1080. Paste this into your composition:index.html
<div
data-composition-id="oscilloscope-trace"
data-composition-src="compositions/oscilloscope-trace.html"
data-start="0"
data-duration="6"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
data-start. Put it on a different timeline row with
data-track-index. See data attributes for the rest.
Variables
Every one of these has a default, so the piece works untouched. Set the ones you want to change on the element:| Variable | Default | Accepts | What it does |
|---|---|---|---|
waveform | sine | sine, square, triangle, data | |
frequency | 12 | 0.1Hz to 200Hz, step 0.1Hz | |
amplitude | 3 | 0.1div to 4div, step 0.1div | |
persistenceMs | 100 | 1ms to 1000ms, step 1ms | |
phosphorColor | #5dff8f | color | |
sweepRate | 5 | 0.25sweeps/s to 60sweeps/s, step 0.25sweeps/s | |
dataSeries | “ | string |
data-variable-values on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
<div
data-composition-id="oscilloscope-trace"
data-composition-src="compositions/oscilloscope-trace.html"
data-variable-values='{"waveform":"sine","frequency":12,"amplitude":3,"persistenceMs":100,"phosphorColor":"#5dff8f","sweepRate":5,"dataSeries":""}'
></div>
Source
oscilloscope-trace.html
oscilloscope-trace.html
<!doctype html>
<html
lang="en"
data-composition-variables='[
{"id":"waveform","type":"enum","label":"Waveform","default":"sine","options":[{"value":"sine","label":"Sine"},{"value":"square","label":"Square"},{"value":"triangle","label":"Triangle"},{"value":"data","label":"Data series"}]},
{"id":"frequency","type":"number","label":"Signal frequency","unit":"Hz","default":12,"min":0.1,"max":200,"step":0.1},
{"id":"amplitude","type":"number","label":"Amplitude","unit":"div","default":3,"min":0.1,"max":4,"step":0.1},
{"id":"persistenceMs","type":"number","label":"Phosphor persistence (tau)","unit":"ms","default":100,"min":1,"max":1000,"step":1},
{"id":"phosphorColor","type":"color","label":"Phosphor colour","default":"#5dff8f"},
{"id":"sweepRate","type":"number","label":"Sweep rate","unit":"sweeps/s","default":5,"min":0.25,"max":60,"step":0.25},
{"id":"dataSeries","type":"string","label":"Data series (waveform=data)","default":"","placeholder":"0,0.4,0.9,0.3,-0.6,-1,-0.2,0.5"}
]'
>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Oscilloscope Trace</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
background: #05070a;
}
#os-root {
position: relative;
width: 1920px;
height: 1080px;
font-family: "JetBrains Mono", ui-monospace, monospace;
}
#os-bg {
position: absolute;
inset: 0;
background: radial-gradient(120% 120% at 30% 25%, #0d141b 0%, #05070a 68%);
}
#os-screen {
position: absolute;
inset: 0;
width: 1920px;
height: 1080px;
display: block;
}
/* Purely decorative CRT falloff — no timing, no motion. */
#os-vignette {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(
78% 78% at 34% 50%,
rgba(0, 0, 0, 0) 55%,
rgba(0, 0, 0, 0.55) 100%
);
}
#os-readout {
position: absolute;
left: 1250px;
top: 150px;
width: 520px;
color: #6f8794;
font-size: 26px;
line-height: 1.15;
letter-spacing: 0.04em;
}
#os-readout .os-title {
color: #b9ccd6;
font-size: 34px;
letter-spacing: 0.22em;
padding-bottom: 26px;
border-bottom: 2px solid #1d2a33;
margin-bottom: 26px;
}
#os-readout .os-row {
display: flex;
justify-content: space-between;
padding: 13px 0;
}
#os-readout .os-val {
color: #d7e6ee;
}
#os-readout .os-note {
margin-top: 28px;
font-size: 20px;
color: #6a7b85;
line-height: 1.5;
}
</style>
</head>
<body>
<div
id="os-root"
data-composition-id="oscilloscope-trace"
data-start="0"
data-duration="6"
data-width="1920"
data-height="1080"
>
<div id="os-bg"></div>
<canvas
id="os-screen"
class="clip"
width="1920"
height="1080"
data-start="0"
data-duration="6"
data-track-index="0"
></canvas>
<div id="os-vignette"></div>
<div id="os-readout" class="clip" data-start="0" data-duration="6" data-track-index="1">
<div class="os-title">OSCILLOSCOPE</div>
<div class="os-row"><span>SOURCE</span><span class="os-val" id="os-r-wave">SINE</span></div>
<div class="os-row"><span>FREQ</span><span class="os-val" id="os-r-freq">-</span></div>
<div class="os-row"><span>TIMEBASE</span><span class="os-val" id="os-r-time">-</span></div>
<div class="os-row"><span>VERT</span><span class="os-val" id="os-r-amp">-</span></div>
<div class="os-row"><span>PHOSPHOR</span><span class="os-val" id="os-r-phos">-</span></div>
<div class="os-note" id="os-r-note"></div>
</div>
</div>
<script>
(function () {
"use strict";
// ---------------------------------------------------------------
// Oscilloscope trace with phosphor persistence.
//
// Two pieces of physical truth drive the look:
//
// 1. PERSISTENCE. The phosphor keeps emitting after the beam has
// passed, decaying as exp(-age/tau). Decay constants by EIA
// phosphor class (research entry F#10):
// P11/P31 - 0.01-1 ms (what a real bench scope uses: no
// visible afterglow at video rates)
// P1 - 80-150 ms (the "vintage glow" look; tau ~= 100 ms
// is the entry's recommended default)
// P33 - > 1 s
// P2/P7 - 30 s .. ~1 min (radar territory, out of range here)
// The tail window is 3*tau: after three time constants ~5% of the
// brightness remains, which the entry gives as the safe cutoff.
//
// 2. BEAM-VELOCITY BRIGHTNESS. A trace is brighter where the beam
// moves slower, because the same deposited energy is spread over
// a shorter path. Each sub-step deposits a fixed amount of energy
// (constant dt), so surface brightness goes as 1/segment-length.
// Flat parts of the waveform are bright; fast vertical edges are
// faint. Qualitative law only - the research entry states it with
// no proportionality constant, so the reference length below is
// the physical minimum (pure horizontal sweep motion), not a
// fudge factor.
//
// SWEEP RATE PROVENANCE. The phosphor decay constants above are
// measured; the sweep rate is NOT - the research entry gives no
// timebase figure. The default of 5 sweeps/s is authored: across the
// 10-division graticule it works out to 20 ms/div, a real value from
// the standard 1-2-5 timebase sequence, picked because it puts a few
// cycles of the default signal on screen. Treat it as a dial, not as
// a measurement.
//
// NO FEEDBACK BUFFER. The canvas is cleared every frame and the tail
// is recomputed by evaluating the beam curve backwards in time from
// the current frame: P(t - k*dt) for k = 0..K. Frame N depends only
// on N, so seeking anywhere is exact rather than approximate.
// ---------------------------------------------------------------
var COMP_ID = "oscilloscope-trace";
var DURATION = 6;
// Screen geometry: a 10x8 division graticule of SQUARE divisions,
// the standard CRT scope face.
var DIV = 100;
var DIVS_X = 10;
var DIVS_Y = 8;
var SW = DIV * DIVS_X;
var SH = DIV * DIVS_Y;
var SX = 140;
var SY = 140;
var CY = SY + SH / 2;
// Tail window in time constants (research entry F#10: 3*tau leaves
// ~5% weight, stated there as the safe cutoff).
var TAIL_TAUS = 3;
// Sub-step count. The entry suggests K ~= 24 steps across the window,
// which is enough to quantise the DECAY but far too coarse spatially:
// at 12 Hz the beam would advance 0.15 of a cycle per step and the
// trace would render as a polygon. So K is derived from how fast the
// beam actually moves - keep each sub-step under SEG_TARGET_PX of
// travel. That target and the clamps are MY numbers, not measured
// ones; the clamp bounds per-frame cost when persistence is long.
var SEG_TARGET_PX = 3;
var K_MIN = 120;
var K_MAX = 8000;
// Band-limit for the square wave. A real generator + a real scope
// front end both have finite bandwidth, so the edge is steep but not
// instantaneous; a mathematical step would make the vertical edge one
// sub-step long and the velocity law would erase it entirely.
var SQUARE_SHARPNESS = 8;
function readVariables() {
var api = window.__hyperframes && window.__hyperframes.getVariables;
if (typeof api === "function") return api() || {};
// Standalone fallback (raw file opened without the runtime): read
// the same declaration the runtime reads, so defaults have exactly
// one home.
var out = {};
try {
var raw = document.documentElement.getAttribute("data-composition-variables");
var decls = JSON.parse(raw || "[]");
for (var i = 0; i < decls.length; i++) out[decls[i].id] = decls[i].default;
} catch (err) {
/* declaration missing or malformed - fall through to hard defaults */
}
return out;
}
function num(value, fallback, min, max) {
var n = typeof value === "number" ? value : parseFloat(value);
if (!isFinite(n)) n = fallback;
return Math.min(max, Math.max(min, n));
}
var V = readVariables();
var waveform =
["sine", "square", "triangle", "data"].indexOf(String(V.waveform)) >= 0
? String(V.waveform)
: "sine";
var frequency = num(V.frequency, 12, 0.1, 200);
var amplitude = num(V.amplitude, 3, 0.1, 4);
var tau = num(V.persistenceMs, 100, 1, 1000) / 1000;
var sweepRate = num(V.sweepRate, 5, 0.25, 60);
var phosphor = parseColor(V.phosphorColor, [93, 255, 143]);
// waveform="data" replays a supplied series instead of a synthetic
// shape: comma/whitespace separated numbers, clamped to -1..1, linearly
// interpolated, one full pass of the series per `frequency` cycle.
// Empty or unparseable -> falls back to sine.
var series = String(V.dataSeries == null ? "" : V.dataSeries)
.split(/[\s,]+/)
.map(parseFloat)
.filter(function (n) {
return isFinite(n);
})
.map(function (n) {
return Math.min(1, Math.max(-1, n));
});
if (waveform === "data" && series.length < 2) waveform = "sine";
function parseColor(value, fallback) {
var m = /^#?([0-9a-f]{6})$/i.exec(String(value == null ? "" : value).trim());
if (!m) return fallback;
var v = parseInt(m[1], 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
function rgba(c, a) {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a.toFixed(4) + ")";
}
// --- the signal: pure function of time, no state ------------------
function wave(t) {
var p = frequency * t; // cycles elapsed
if (waveform === "square") {
return Math.tanh(SQUARE_SHARPNESS * Math.sin(2 * Math.PI * p));
}
if (waveform === "triangle") {
return (2 / Math.PI) * Math.asin(Math.sin(2 * Math.PI * p));
}
if (waveform === "data") {
var frac = p - Math.floor(p);
var pos = frac * (series.length - 1);
var i = Math.floor(pos);
var f = pos - i;
var a = series[i];
var b = series[Math.min(series.length - 1, i + 1)];
return a + (b - a) * f;
}
return Math.sin(2 * Math.PI * p);
}
/** Horizontal sweep position, 0..1 across the graticule. */
function sweepU(t) {
var s = t * sweepRate;
return s - Math.floor(s);
}
function beamX(t) {
return SX + sweepU(t) * SW;
}
function beamY(t) {
return CY - wave(t) * amplitude * DIV;
}
// Upper bound on beam speed, in px/s, used to pick the sub-step count.
// Horizontal is the constant sweep; vertical is the waveform's steepest
// slope, which differs per shape - a band-limited square is
// SQUARE_SHARPNESS times steeper at its edge than a sine of the same
// frequency, and under-sampling exactly there is what turns the edge
// into a polygon.
var slopeBound;
if (waveform === "square") {
slopeBound = SQUARE_SHARPNESS * 2 * Math.PI * frequency;
} else if (waveform === "triangle") {
slopeBound = 4 * frequency;
} else if (waveform === "data") {
var maxStep = 0;
for (var si = 1; si < series.length; si++) {
maxStep = Math.max(maxStep, Math.abs(series[si] - series[si - 1]));
}
slopeBound = maxStep * (series.length - 1) * frequency;
} else {
slopeBound = 2 * Math.PI * frequency;
}
var beamSpeedMax = Math.sqrt(
Math.pow(SW * sweepRate, 2) + Math.pow(slopeBound * amplitude * DIV, 2),
);
// --- graticule (drawn once to an offscreen canvas) ----------------
var grat = document.createElement("canvas");
grat.width = 1920;
grat.height = 1080;
(function drawGraticule() {
var g = grat.getContext("2d");
g.fillStyle = "#04080a";
g.fillRect(SX, SY, SW, SH);
g.strokeStyle = "rgba(120,180,160,0.16)";
g.lineWidth = 1;
for (var i = 1; i < DIVS_X; i++) {
g.beginPath();
g.moveTo(SX + i * DIV + 0.5, SY);
g.lineTo(SX + i * DIV + 0.5, SY + SH);
g.stroke();
}
for (var j = 1; j < DIVS_Y; j++) {
g.beginPath();
g.moveTo(SX, SY + j * DIV + 0.5);
g.lineTo(SX + SW, SY + j * DIV + 0.5);
g.stroke();
}
// Centre axes carry the fine 0.2-division ticks, as on a real face.
g.strokeStyle = "rgba(150,205,185,0.32)";
var cx = SX + SW / 2 + 0.5;
var cy = CY + 0.5;
g.beginPath();
g.moveTo(cx, SY);
g.lineTo(cx, SY + SH);
g.moveTo(SX, cy);
g.lineTo(SX + SW, cy);
g.stroke();
g.strokeStyle = "rgba(150,205,185,0.42)";
for (var k = 1; k < DIVS_X * 5; k++) {
var x = SX + (k * DIV) / 5 + 0.5;
g.beginPath();
g.moveTo(x, cy - 9);
g.lineTo(x, cy + 9);
g.stroke();
}
for (var m = 1; m < DIVS_Y * 5; m++) {
var y = SY + (m * DIV) / 5 + 0.5;
g.beginPath();
g.moveTo(cx - 9, y);
g.lineTo(cx + 9, y);
g.stroke();
}
g.strokeStyle = "rgba(160,215,195,0.55)";
g.lineWidth = 2;
g.strokeRect(SX + 1, SY + 1, SW - 2, SH - 2);
})();
// --- per-frame paint ----------------------------------------------
var canvas = document.getElementById("os-screen");
var ctx = canvas.getContext("2d");
function draw(t) {
ctx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, 1920, 1080);
ctx.drawImage(grat, 0, 0);
var windowS = TAIL_TAUS * tau;
var K = Math.max(
K_MIN,
Math.min(K_MAX, Math.round((windowS * beamSpeedMax) / SEG_TARGET_PX)),
);
var dt = windowS / K;
// Length the beam covers in one sub-step with zero vertical motion.
// That is the slowest the beam can ever move, so it is the maximum
// brightness reference and the velocity weight never exceeds 1.
var lRef = SW * sweepRate * dt;
ctx.globalCompositeOperation = "lighter";
ctx.lineCap = "round";
// Oldest -> newest, so the bright head lands on top.
var prevX = 0;
var prevY = 0;
var prevU = 0;
var havePrev = false;
for (var k = K; k >= 0; k--) {
var tk = t - k * dt;
if (tk < 0) {
// Before frame 0 the beam had not been switched on yet.
havePrev = false;
continue;
}
var u = sweepU(tk);
var x = SX + u * SW;
var y = beamY(tk);
if (havePrev && u >= prevU) {
var dx = x - prevX;
var dy = y - prevY;
var len = Math.sqrt(dx * dx + dy * dy);
// Beam-velocity law: brightness ~ 1 / path length per unit time.
var vel = len > 1e-6 ? Math.min(1, lRef / len) : 1;
var decay = Math.exp((-k * dt) / tau);
var a = decay * vel;
if (a > 0.002) {
ctx.beginPath();
ctx.moveTo(prevX, prevY);
ctx.lineTo(x, y);
ctx.strokeStyle = rgba(phosphor, a * 0.13);
ctx.lineWidth = 13;
ctx.stroke();
ctx.strokeStyle = rgba(phosphor, a * 0.9);
ctx.lineWidth = 3;
ctx.stroke();
if (a > 0.55) {
// Overdriven phosphor saturates towards white at the head.
ctx.strokeStyle =
"rgba(255,255,255," + (((a - 0.55) / 0.45) * 0.7).toFixed(4) + ")";
ctx.lineWidth = 1.4;
ctx.stroke();
}
}
}
// Retrace is blanked on a real scope: when u wraps, drop the
// connecting segment instead of drawing a line back across.
prevX = x;
prevY = y;
prevU = u;
havePrev = true;
}
// Beam head: the spot itself, brightest point on the screen.
var hx = beamX(t);
var hy = beamY(t);
var glow = ctx.createRadialGradient(hx, hy, 0, hx, hy, 22);
glow.addColorStop(0, "rgba(255,255,255,0.95)");
glow.addColorStop(0.22, rgba(phosphor, 0.85));
glow.addColorStop(1, rgba(phosphor, 0));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(hx, hy, 22, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = "source-over";
}
// --- readout --------------------------------------------------------
(function fillReadout() {
// Nearest EIA phosphor class for the chosen tau (bands from the
// research entry). Labelling, not simulation.
var tauMs = tau * 1000;
var cls = tauMs <= 1 ? "P31" : tauMs <= 150 ? "P1" : "P33";
var msPerDiv = 1000 / (sweepRate * DIVS_X);
function set(id, text) {
document.getElementById(id).textContent = text;
}
set("os-r-wave", waveform.toUpperCase());
set("os-r-freq", frequency.toFixed(frequency < 10 ? 2 : 1) + " Hz");
set(
"os-r-time",
(msPerDiv >= 10 ? msPerDiv.toFixed(0) : msPerDiv.toFixed(2)) + " ms/div",
);
set("os-r-amp", amplitude.toFixed(1) + " div pk");
set("os-r-phos", cls + " tau " + tauMs.toFixed(0) + " ms");
set(
"os-r-note",
"Trace brightness falls as exp(-age/tau) behind the beam, and rises where the beam slows.",
);
})();
// --- timeline -------------------------------------------------------
//
// tl.eventCallback("onUpdate", ...) is NOT usable here: the runtime
// seeks with suppressEvents, so the callback never fires on a seek and
// the canvas would keep whatever the last played frame drew. A tweened
// property with an accessor is applied by GSAP on every render,
// including suppressed ones, so the repaint is driven from the setter.
var beam = { t: 0 };
var driver = {};
Object.defineProperty(driver, "t", {
get: function () {
return beam.t;
},
set: function (value) {
beam.t = value;
draw(value);
},
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
tl.to(driver, { t: DURATION, duration: DURATION, ease: "none", lazy: false }, 0);
window.__timelines[COMP_ID] = tl;
draw(0);
})();
</script>
</body>
</html>
instrument data showcase retro.