From 292543a04e320aa1ac345782d0e9418747ca2c69 Mon Sep 17 00:00:00 2001 From: Time Date: Sun, 18 Oct 2026 18:30:24 +0000 Subject: [PATCH] card #147: update ventures/roll-credits/app.js --- ventures/roll-credits/app.js | 238 +++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 ventures/roll-credits/app.js diff --git a/ventures/roll-credits/app.js b/ventures/roll-credits/app.js new file mode 100644 index 0000000..dae3125 --- /dev/null +++ b/ventures/roll-credits/app.js @@ -0,0 +1,238 @@ +/* Roll Credits: type names, pick a style, watch the roll, download a 15-second WebM or GIF. All in the page. */ +(function () { + 'use strict'; + var W0 = 640, H0 = 360, INSET = 28, DUR = 15; + + var SAMPLE_TITLE = 'Roll Credits'; + var SAMPLE_NAMES = [ + '# For team leads and organizers', + 'Thank-yous, for a launch, a trip, a bake-off or a last day', + '# You do', + 'Type, a title and the names', + 'Pick, a style and a speed', + '# It does', + 'Your browser, draws the credits live', + 'WebM or GIF, 15 seconds, yours to keep', + '# It costs', + 'Nothing, and nothing is uploaded' + ].join('\n'); + + /* ---------- parsing: "Name, role" per line; "# text" is a heading ---------- */ + function parse(text) { + var out = []; + String(text).split('\n').forEach(function (raw) { + var l = raw.trim(), i; + if (!l) return; + if (l[0] === '#') { l = l.replace(/^#+\s*/, ''); if (l) out.push({ k: 'head', t: l }); return; } + i = l.indexOf(','); + out.push(i < 0 ? { k: 'e', name: l, role: '' } : { k: 'e', name: l.slice(0, i).trim(), role: l.slice(i + 1).trim() }); + }); + return out; + } + function hh(it) { return it.k === 'title' ? 70 : it.k === 'head' ? 46 : it.role ? 54 : 38; } + function total(items) { return items.reduce(function (a, it) { return a + hh(it); }, 0); } + function rollLength(items, speed) { return (H0 - 2 * INSET + total(items)) / speed; } + + /* ---------- styles ---------- */ + var MONO = 'ui-monospace, Menlo, Consolas, "Courier New", monospace'; + var SERIF = 'Georgia, "Times New Roman", serif'; + var SANS = '"Trebuchet MS", Verdana, "Segoe UI", sans-serif'; + + function perim(step, f) { // walks a rectangle 12px inside the canvas edge + var a = 12, b = W0 - 12, c = H0 - 12, i = 0, x, y; + for (x = a; x < b; x += step) f(x, a, i++); + for (y = a; y < c; y += step) f(b, y, i++); + for (x = b; x > a; x -= step) f(x, c, i++); + for (y = c; y > a; y -= step) f(a, y, i++); + } + function vgrad(c, a, b) { var g = c.createLinearGradient(0, 0, 0, H0); g.addColorStop(0, a); g.addColorStop(1, b); c.fillStyle = g; c.fillRect(0, 0, W0, H0); } + + var STYLES = { + arcade: { + fam: MONO, wt: 'bold ', rs: '', up: true, glow: true, + c: { title: '#ffe14a', name: '#39f5ff', role: '#ff4fd8' }, + bg: function (c) { c.fillStyle = '#07021a'; c.fillRect(0, 0, W0, H0); c.fillStyle = 'rgba(0,0,0,.28)'; for (var y = 0; y < H0; y += 4) c.fillRect(0, y, W0, 2); }, + frame: function (c, t) { + var k = Math.floor(t * 6); + perim(16, function (x, y, i) { c.fillStyle = (i + k) % 3 === 0 ? '#ff4fd8' : '#39f5ff'; c.fillRect(x - 4, y - 4, 8, 8); }); + } + }, + epic: { + fam: SERIF, wt: '', rs: 'italic ', up: false, glow: false, + c: { title: '#f2c75c', name: '#f5e6c4', role: '#c9a35a' }, + bg: function (c) { vgrad(c, '#2a1a0a', '#120a04'); }, + frame: function (c) { + c.strokeStyle = '#d4a93f'; c.lineWidth = 2; c.strokeRect(10, 10, W0 - 20, H0 - 20); + c.lineWidth = 1; c.strokeRect(17, 17, W0 - 34, H0 - 34); + c.fillStyle = '#f2c75c'; + [[10, 10], [W0 - 10, 10], [10, H0 - 10], [W0 - 10, H0 - 10]].forEach(function (p) { + c.beginPath(); c.moveTo(p[0], p[1] - 8); c.lineTo(p[0] + 8, p[1]); c.lineTo(p[0], p[1] + 8); c.lineTo(p[0] - 8, p[1]); c.closePath(); c.fill(); + }); + } + }, + sitcom: { + fam: SANS, wt: 'bold ', rs: '', up: false, glow: false, + c: { title: '#ffd23f', name: '#ffffff', role: '#ffd9a8' }, + bg: function (c) { vgrad(c, '#12818f', '#0b4f5c'); }, + frame: function (c, t) { + var k = Math.floor(t * 3); + c.strokeStyle = '#ff6b4a'; c.lineWidth = 5; c.strokeRect(12, 12, W0 - 24, H0 - 24); + perim(24, function (x, y, i) { c.fillStyle = (i + k) % 2 ? '#ffd23f' : '#9a6b12'; c.beginPath(); c.arc(x, y, 5, 0, 6.2832); c.fill(); }); + } + } + }; + + /* ---------- drawing: draw(ctx, pixelWidth, pixelHeight, seconds, model) is the single path for preview, WebM and GIF ---------- */ + function txt(c, s, x, y, px, pre, fam, col, maxW, S) { + var p = px; + if (S.up && pre !== S.rs) s = s.toUpperCase(); + for (; p > 8; p--) { c.font = pre + p + 'px ' + fam; if (c.measureText(s).width <= maxW) break; } + c.font = pre + p + 'px ' + fam; + c.fillStyle = col; c.shadowColor = col; c.shadowBlur = S.glow ? 6 : 0; + c.fillText(s, x, y); + c.shadowBlur = 0; + } + function draw(c, W, H, t, m) { + var S = STYLES[m.style] || STYLES.arcade, cx = W0 / 2, mw = W0 - 2 * INSET - 30; + var len = rollLength(m.items, m.speed), y = H0 - INSET - m.speed * (t % len); + c.setTransform(W / W0, 0, 0, W / W0, 0, 0); + c.textAlign = 'center'; c.textBaseline = 'middle'; + S.bg(c, t); + c.save(); c.beginPath(); c.rect(INSET, INSET, W0 - 2 * INSET, H0 - 2 * INSET); c.clip(); + m.items.forEach(function (it) { + var h = hh(it); + if (y + h > INSET && y < H0 - INSET) { + if (it.k === 'title') txt(c, it.t, cx, y + 35, 34, S.wt, S.fam, S.c.title, mw, S); + else if (it.k === 'head') txt(c, it.t, cx, y + 28, 17, S.wt, S.fam, S.c.role, mw, S); + else { + txt(c, it.name, cx, y + 20, 22, S.wt, S.fam, S.c.name, mw, S); + if (it.role) txt(c, it.role, cx, y + 42, 13, S.rs, S.fam, S.c.role, mw, S); + } + } + y += h; + }); + c.restore(); + S.frame(c, t); + } + + /* ---------- GIF89a encoder: fixed 3-3-2 colour palette, looping ---------- */ + // shortcut: fixed 256-colour palette bands gradients, upgrade to per-frame quantisation if it looks bad + function lzw(px) { + var out = [], cur = 0, bits = 0, size = 9, next = 258, dict = new Map(), p = px[0], i, k, c; + function put(code) { cur |= code << bits; bits += size; while (bits >= 8) { out.push(cur & 255); cur >>= 8; bits -= 8; } } + put(256); + for (i = 1; i < px.length; i++) { + k = p << 8 | px[i]; c = dict.get(k); + if (c !== undefined) { p = c; continue; } + put(p); dict.set(k, next++); + if (next > (1 << size) && size < 12) size++; + if (next === 4096) { put(256); dict = new Map(); next = 258; size = 9; } + p = px[i]; + } + put(p); + if (next >= (1 << size) && size < 12) size++; + put(257); + if (bits) out.push(cur & 255); + return out; + } + function gifEncoder(w, h, delayCs) { + var parts = [], i, pal = new Uint8Array(768); + for (i = 0; i < 256; i++) { pal[i * 3] = (i >> 5) * 255 / 7; pal[i * 3 + 1] = ((i >> 2) & 7) * 255 / 7; pal[i * 3 + 2] = (i & 3) * 85; } + parts.push(new Uint8Array([71, 73, 70, 56, 57, 97, w & 255, w >> 8, h & 255, h >> 8, 0xF7, 0, 0]), pal, + new Uint8Array([0x21, 0xFF, 11, 78, 69, 84, 83, 67, 65, 80, 69, 50, 46, 48, 3, 1, 0, 0, 0])); + return { + add: function (rgba) { + var idx = new Uint8Array(w * h), j, d = lzw(idx.length ? (function () { for (j = 0; j < idx.length; j++) idx[j] = (rgba[j * 4] & 0xE0) | ((rgba[j * 4 + 1] & 0xE0) >> 3) | (rgba[j * 4 + 2] >> 6); return idx; })() : idx), b = []; + b.push(0x21, 0xF9, 4, 0, delayCs & 255, delayCs >> 8, 0, 0, 0x2C, 0, 0, 0, 0, w & 255, w >> 8, h & 255, h >> 8, 0, 8); + for (j = 0; j < d.length; j += 255) { var n = Math.min(255, d.length - j); b.push(n); for (var q = 0; q < n; q++) b.push(d[j + q]); } + b.push(0); + parts.push(new Uint8Array(b)); + }, + done: function () { parts.push(new Uint8Array([0x3B])); return parts; } + }; + } + + if (typeof document === 'undefined') { module.exports = { parse: parse, draw: draw, lzw: lzw, gifEncoder: gifEncoder, STYLES: STYLES, rollLength: rollLength, SAMPLE_NAMES: SAMPLE_NAMES }; return; } + + /* ---------- the page ---------- */ + var root = document.getElementById('try'); + root.innerHTML = + '
' + + '
' + + '

' + + '
' + + '
Style' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '

Exports record 15 seconds at the chosen speed.

' + + '
'; + + var $ = function (id) { return document.getElementById(id); }; + var cv = $('rc-cv'), ctx = cv.getContext('2d'), start = performance.now(), busy = false; + $('rc-title').value = SAMPLE_TITLE; + $('rc-names').value = SAMPLE_NAMES; + + function model() { + var t = $('rc-title').value.trim(), items = parse($('rc-names').value); + if (t) items.unshift({ k: 'title', t: t }); + return { items: items, style: document.querySelector('input[name=st]:checked').value, speed: +$('rc-speed').value }; + } + function status(s) { $('rc-status').textContent = s; } + function update() { + var m = model(), len = rollLength(m.items, m.speed); + $('rc-len').textContent = 'Roll length ' + len.toFixed(1) + ' s' + (len > DUR ? ': an export stops at 15 s, so raise the speed to fit it all' : ': fits in the 15-second export'); + start = performance.now(); + } + function frame(now) { draw(ctx, W0, H0, (now - start) / 1000, model()); requestAnimationFrame(frame); } + document.querySelector('.panel').addEventListener('input', update); + update(); + requestAnimationFrame(frame); + + function lock(on) { busy = on; $('rc-webm').disabled = $('rc-gif').disabled = on; } + function save(blob, ext) { + var a = document.createElement('a'), kb = Math.round(blob.size / 1024); + a.href = URL.createObjectURL(blob); a.download = 'roll-credits.' + ext; + document.body.appendChild(a); a.click(); a.remove(); + status('Done: roll-credits.' + ext + ' (' + kb + ' KB) was downloaded. Nothing left this page.'); + lock(false); + } + + $('rc-webm').addEventListener('click', function () { + var mime, rec, ch = [], iv, t0, c, x, m; + if (busy) return; + try { + mime = window.MediaRecorder && ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'].filter(function (s) { return MediaRecorder.isTypeSupported(s); })[0]; + if (!mime) { status('This browser cannot record WebM. Try the GIF button.'); return; } + m = model(); c = document.createElement('canvas'); c.width = W0; c.height = H0; x = c.getContext('2d'); + rec = new MediaRecorder(c.captureStream(30), { mimeType: mime, videoBitsPerSecond: 4000000 }); + rec.ondataavailable = function (e) { if (e.data.size) ch.push(e.data); }; + rec.onstop = function () { clearInterval(iv); save(new Blob(ch, { type: 'video/webm' }), 'webm'); }; + lock(true); draw(x, W0, H0, 0, m); rec.start(); t0 = performance.now(); + iv = setInterval(function () { + var t = (performance.now() - t0) / 1000; + if (t >= DUR) { clearInterval(iv); rec.stop(); return; } + draw(x, W0, H0, t, m); status('Recording WebM in real time: ' + Math.ceil(DUR - t) + ' s left. Keep this tab visible.'); + }, 33); + } catch (e) { lock(false); status('WebM recording failed in this browser: ' + e.message + '. Try the GIF button.'); } + }); + + $('rc-gif').addEventListener('click', async function () { + var w = 320, h = 180, fps = 10, n = DUR * fps, c, x, m, enc, i; + if (busy) return; + try { + lock(true); m = model(); c = document.createElement('canvas'); c.width = w; c.height = h; + x = c.getContext('2d', { willReadFrequently: true }); enc = gifEncoder(w, h, 100 / fps); + for (i = 0; i < n; i++) { + draw(x, w, h, i / fps, m); enc.add(x.getImageData(0, 0, w, h).data); + if (i % 5 === 4) { status('Encoding GIF: ' + Math.round((i + 1) / n * 100) + '%'); await new Promise(function (r) { setTimeout(r); }); } + } + save(new Blob(enc.done(), { type: 'image/gif' }), 'gif'); + } catch (e) { lock(false); status('GIF export failed: ' + e.message); } + }); +})();