Compare commits

..
13 changed files with 9 additions and 334 deletions

No files matched your search

-91
View File
@@ -2291,7 +2291,6 @@ function runWorkflow(id, { door = "chat", idea = "", name = "" } = {}) {
if (id === "new") return writeFromIdea(idea, { door, name });
if (id === "photoshoot") return photoshoot(idea, { door });
if (id === "recall") return recall(idea);
if (id === "whisper") return whisper(idea);
}
/** the chat a workflow's result lands in: the one of theirs on screen, else their latest, else a new one */
function threadFor(c) {
@@ -2405,96 +2404,6 @@ async function photoshoot(arg, { door = "chat" } = {}) {
balanceSoon();
}
}
/**
* /whisper: a short ASMR voice note. One call writes the script in their voice from the chat (and what it's about,
* if given); a whisper preset speaks it (W.WHISPER_PRESETS: luna on Inworld, eve on Grok, picked from the
* asmr.gandora.lol bake-off); it's mixed over soft rain made here, and lands in their chat as a voice note.
*/
let whispering = false;
async function whisper(arg) {
const c = card();
if (!c || c.plain) { err("/whisper needs a character: pick one in the harbor first."); return; }
if (whispering) { toast("A whisper is already on its way."); return; }
const { preset: named, brief } = W.parseWhisper(arg);
const key = named || (c.whisper && W.WHISPER_PRESETS[c.whisper] ? c.whisper : W.WHISPER_DEFAULT), p = W.WHISPER_PRESETS[key];
await loadList("tts");
const vm = (S.lists.tts || []).find((m) => m.id === p.model);
const guess = vm ? A.voicePrice(vm, 600) : null;
if (!confirm(`A whispered voice note from ${c.name} (${p.voice})${brief ? " about " + brief : ""}? About ${guess != null ? A.usd(guess) : "a cent"} on your Venice key, plus the words.`)) return;
const t = threadFor(c);
whispering = true;
S.inflight++;
doing(t, "is writing something to whisper…");
try {
const lines = t.msgs.filter((m) => !m.hidden && (m.role === "user" || m.role === "assistant")).slice(-10)
.map((m) => `${m.role === "user" ? "Them" : c.name}: ${String(m.content || "").replace(/\s+/g, " ").trim().slice(0, 300)}`);
const o = await askJson(await jsonModel(c), { messages: W.whisperMessages(c, { brief, lines }), schema: W.WHISPER_SCHEMA, maxTokens: 400, temperature: 0.9, what: "the whisper" });
const script = W.cleanWhisper(o);
if (!script) throw new Error("The model sent back nothing to whisper. Try again, or pick another model on the card.");
doing(t, "is whispering…");
const input = W.whisperInput(script, p);
const voice = await new Venice(S.key).speak({ model: p.model, voice: p.voice, input, speed: p.speed, format: p.format });
let blob = voice;
try { blob = await overRain(voice); } catch { /* the dry take is still a whisper */ }
const price = vm ? A.voicePrice(vm, input.length) : null;
done(t);
deliver(t, { role: "assistant", content: script, voice: { id: await M.put(blob), model: vm ? vm.name : p.model, voice: p.voice, price, whisper: true } });
if (named) { const now = S.cards.find((x) => x.id === c.id); if (now && now.whisper !== named) replaceCard({ ...now, whisper: named }); }
} catch (e) {
done(t);
err(e.message || String(e));
} finally {
whispering = false;
S.inflight--;
balanceSoon();
}
}
/** the voice, close-mic'd (a little low end, gentle compression), over soft rain made here; a wav back */
async function overRain(blob) {
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
const probe = new (window.AudioContext || window.webkitAudioContext)();
const v = await probe.decodeAudioData(await blob.arrayBuffer());
probe.close && probe.close();
const rate = 44100, pad = 1.5, len = Math.ceil((v.duration + pad * 2) * rate);
const ctx = new AC(2, len, rate);
// rain: pink-ish hiss with a slow flutter, over a low brown rumble
const noise = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const d = noise.getChannelData(ch); let b0 = 0, b1 = 0, b2 = 0, br = 0;
for (let i = 0; i < len; i++) {
const w = Math.random() * 2 - 1;
b0 = 0.997 * b0 + 0.029 * w; b1 = 0.985 * b1 + 0.032 * w; b2 = 0.95 * b2 + 0.048 * w;
br = (br + 0.02 * w) / 1.02;
d[i] = (b0 + b1 + b2 + w * 0.05) * 0.6 + br * 2.5;
}
}
const rain = ctx.createBufferSource(); rain.buffer = noise;
const hp = ctx.createBiquadFilter(); hp.type = "highpass"; hp.frequency.value = 120;
const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 6500;
const rg = ctx.createGain(); const end = len / rate;
rg.gain.setValueAtTime(0, 0); rg.gain.linearRampToValueAtTime(0.09, 1.2); rg.gain.setValueAtTime(0.09, end - 1.2); rg.gain.linearRampToValueAtTime(0, end);
rain.connect(hp); hp.connect(lp); lp.connect(rg); rg.connect(ctx.destination);
const src = ctx.createBufferSource(); src.buffer = v;
const lo = ctx.createBiquadFilter(); lo.type = "lowshelf"; lo.frequency.value = 180; lo.gain.value = 4;
const cmp = ctx.createDynamicsCompressor(); cmp.threshold.value = -30; cmp.ratio.value = 4; cmp.attack.value = 0.01; cmp.release.value = 0.25;
const vg = ctx.createGain(); vg.gain.value = 1.6;
src.connect(lo); lo.connect(cmp); cmp.connect(vg); vg.connect(ctx.destination);
rain.start(0); src.start(pad);
return wavBlob(await ctx.startRendering());
}
/** an AudioBuffer as a 16-bit wav */
function wavBlob(buf) {
const n = buf.numberOfChannels, len = buf.length, out = new DataView(new ArrayBuffer(44 + len * n * 2));
const s = (o, t) => { for (let i = 0; i < t.length; i++) out.setUint8(o + i, t.charCodeAt(i)); };
s(0, "RIFF"); out.setUint32(4, 36 + len * n * 2, true); s(8, "WAVE"); s(12, "fmt "); out.setUint32(16, 16, true);
out.setUint16(20, 1, true); out.setUint16(22, n, true); out.setUint32(24, buf.sampleRate, true);
out.setUint32(28, buf.sampleRate * n * 2, true); out.setUint16(32, n * 2, true); out.setUint16(34, 16, true);
s(36, "data"); out.setUint32(40, len * n * 2, true);
const chans = []; for (let c = 0; c < n; c++) chans.push(buf.getChannelData(c));
let o = 44;
for (let i = 0; i < len; i++) for (let c = 0; c < n; c++) { const x = Math.max(-1, Math.min(1, chans[c][i])); out.setInt16(o, x < 0 ? x * 0x8000 : x * 0x7fff, true); o += 2; }
return new Blob([out], { type: "audio/wav" });
}
/**
* A photo of them from the chat, kept on their card: as their portrait (as = "portrait"; the look it was drawn from goes
* on the card too if it had none), or as one of the pictures their photos and clips are made from (as = "").
+1 -2
View File
@@ -23,7 +23,6 @@
<link rel="alternate" type="text/markdown" href="skill.md" title="Skill">
<link rel="stylesheet" href="style.css">
<script type="module" src="app.js"></script>
<script src="update.js" defer></script>
</head>
<body>
<a class="skip" href="#chat">skip to the chat</a>
@@ -31,7 +30,7 @@
<header class="mast" id="top">
<h1>HAVRE</h1>
<p class="motto">a harbor for characters. <br>talk to venice characters on your own key. <br>no account. no server. nothing kept but what you keep.</p>
<nav class="nav" aria-label="Havre"><a href="open/">studio</a> / <button type="button" class="linkish" id="theme">dark mode</button> / <button type="button" class="linkish" id="reload" title="the latest Havre (installed as an app, there's no other way to reload)">reload</button></nav>
<nav class="nav" aria-label="On this page"><a href="#how">how it works</a> / <a href="#agents">for agents</a> / <a href="#costs">costs</a> / <a href="#plans">early access</a> / <a href="open/">open studio (beta)</a> / <button type="button" class="linkish" id="theme">dark mode</button> / <button type="button" class="linkish" id="reload" title="the latest Havre (installed as an app, there's no other way to reload)">reload</button></nav>
</header>
<form class="keybar" id="keyform" autocomplete="off" aria-label="Your Venice API key">
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 76 KiB

-7
View File
@@ -551,13 +551,6 @@ p.note { padding: 8px 10px; border: 1px solid var(--ink); background: var(--hi);
.lefthead [role=tab] { font-size: 19px; }
.lefthead .sep::before { font-size: 19px; }
}
/* a new version is out: a bar at the top to reload (update.js) */
#upd {
position: fixed; top: calc(10px + env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); z-index: 30;
padding: 9px 16px; border: 1px solid var(--paper); border-radius: 0; background: var(--ink); color: var(--paper);
font: 700 13px/1 var(--mono); letter-spacing: .12em; white-space: nowrap; cursor: pointer;
}
#upd:hover, #upd:focus-visible { background: var(--hi); color: var(--ink); border-color: var(--ink); }
@media (prefers-reduced-motion: reduce) {
* { scroll-behavior: auto !important; }
}
-37
View File
@@ -1,37 +0,0 @@
// update.js: an installed Havre has no address bar to refresh from, so the page notices when a new version has been
// published and offers a reload, like gandora's pill. It fingerprints the files the app is made of when it opens,
// checks again whenever it comes back to the foreground (and every ten minutes), and shows the pill when they differ.
// Checking fetches with cache: "reload", which also refreshes the browser's copies, so the reload gets the new files.
(() => {
const FILES = ["./", "app.js", "style.css", "workflows.js", "abilities.js", "card.js", "venice.js", "memory.js",
"threads.js", "media.js", "backup.js", "theme.js"];
let base = null, shown = false, busy = false;
async function print() {
const parts = await Promise.all(FILES.map((f) => fetch(f, { cache: "reload" }).then((r) => (r.ok ? r.text() : "")).catch(() => null)));
if (parts.some((p) => p === null)) return null; // offline: no verdict
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("\u0000")));
return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, "0")).join("");
}
function show() {
if (shown) return;
shown = true;
const b = document.createElement("button");
b.id = "upd"; b.type = "button"; b.textContent = "NEW VERSION · RELOAD";
b.addEventListener("click", () => location.reload());
document.body.append(b);
}
async function check() {
if (busy || shown || !crypto.subtle) return;
busy = true;
try {
const now = await print();
if (!now) return;
if (!base) base = now;
else if (now !== base) show();
} finally { busy = false; }
}
if (location.protocol !== "https:") return;
setTimeout(check, 3000);
setInterval(check, 10 * 60 * 1000);
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
})();
+1 -2
View File
@@ -141,9 +141,8 @@ export class Venice {
}
/** a voice note: words read out by a voice model. mood goes to models that take a style prompt; the rest ignore it. */
async speak({ model, voice, input, mood = "", speed = 1, format = "", signal } = {}) {
async speak({ model, voice, input, mood = "", speed = 1, signal } = {}) {
const body = { input: String(input || "").slice(0, 4096), model, speed };
if (format) body.response_format = format;
if (voice) body.voice = voice;
if (mood) body.prompt = String(mood).slice(0, 500);
const res = await this.raw("/audio/speech", { body, signal });
-50
View File
@@ -33,11 +33,6 @@ export const WORKFLOWS = [
about: "a few photos of them from the pictures on their card, one shoot, each a different moment (3 unless you say)",
usage: "/photoshoot [how many] [what the shoot is about]",
},
{
id: "whisper", command: "whisper", title: "a whisper", takes: "card", gives: "voice note", doors: ["chat"],
about: "a short ASMR voice note from them, whispered over soft rain (luna or eve; add what it's about)",
usage: "/whisper [luna|eve] [what it's about]",
},
{
id: "recall", command: "recall", title: "recall", takes: "words", gives: "memory", doors: ["chat"],
about: "what they remember about something (their notes, then the chats themselves), brought to mind for a while",
@@ -133,51 +128,6 @@ export function cardFromIdea(o, { model } = {}) {
return normalizeCard({ ...base, look: { ...base.look, description: look } });
}
// ---------- whisper: a short ASMR voice note, over soft rain ----------
// The voices that whisper well and cheaply (picked in the asmr.gandora.lol bake-off): Venice model, voice, how the
// words are wrapped so the model whispers, and the pace (0.9 of their usual reading speed sounded best).
export const WHISPER_PRESETS = {
luna: { model: "tts-inworld-1-5-max", voice: "Luna", lead: "[whispering] ", tail: "", speed: 0.77, format: "wav" },
eve: { model: "tts-xai-v1", voice: "eve", lead: "<whisper>", tail: "</whisper>", speed: 0.77, format: "mp3" },
};
export const WHISPER_DEFAULT = "luna";
/** the words after /whisper: a preset name first if there is one, the rest what it's about */
export function parseWhisper(arg = "") {
const m = /^\s*(\w+)(?:\s+|$)([\s\S]*)$/.exec(str(arg, 2000));
const first = m ? m[1].toLowerCase() : "";
if (WHISPER_PRESETS[first]) return { preset: first, brief: m[2].replace(/\s+/g, " ").trim() };
return { preset: "", brief: str(arg, 2000).replace(/\s+/g, " ").trim() };
}
export const WHISPER_SCHEMA = {
type: "object", additionalProperties: false, required: ["script"],
properties: { script: { type: "string" } },
};
export function whisperMessages(card, { brief = "", lines = [] } = {}) {
const system = `You are ${card.name}. Write a short ASMR voice note you whisper to the person you're talking with: ` +
"60 to 110 words, in your own voice and manner, intimate and calming, present tense, short phrases with pauses " +
"written as \"...\". Gentle sensory detail (breath, rain, a soft sound nearby) is welcome. No stage directions, no " +
"emoji, no markup, nothing in brackets: only the words to be whispered. Return one JSON object: script.";
const persona = card.persona ? clip(card.persona.replace(/\s+/g, " "), 900) : "";
const user = [
persona ? `Who you are: ${persona}` : "",
lines.length ? "The conversation so far, latest last:\n" + lines.join("\n") : "You haven't talked yet.",
"What it's about: " + (str(brief, 500).trim() || "winding down together; help them relax."),
].filter(Boolean).join("\n\n");
return [{ role: "system", content: system }, { role: "user", content: user }];
}
/** the script, cleaned of anything the model shouldn't have added (brackets, asterisks, emoji-ish markup) */
export function cleanWhisper(o) {
return str(o && o.script, 1500).replace(/\[[^\]]*\]|\([^)]*\)|<[^>]*>|\*[^*]*\*/g, "").replace(/\s+/g, " ").trim();
}
/** what the voice model is sent: the script inside the preset's whisper markup */
export const whisperInput = (script, preset) => (preset.lead || "") + script + (preset.tail || "");
// ---------- photoshoot: a few photos of them, from the pictures on their card ----------
export const SHOOT_MIN = 1, SHOOT_MAX = 6, SHOOT_DEFAULT = 3;
+1 -1
View File
@@ -10,7 +10,7 @@ People who make a quick throwaway deck every week — sprint demos, status updat
## How to use it
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately. Already have a `.md` file? Drop it anywhere on the page, or press **Open .md** above the editor; the file is read in the tab and replaces the text.
1. Open the page and type markdown-ish text in the editor on the left. It comes preloaded with a sample deck so you can see real output immediately.
2. Watch the preview on the right re-typeset live. Navigate with the arrow keys (or swipe on touch screens); a progress bar tracks your place.
3. A slide with too much text shrinks to fit, down to 70% of the normal size. Past that it's cut into two or more slides, between lines and never right after a heading, and each new slide repeats its title with "(cont.)". A note under the preview says which slides were cut; put in a `---` yourself to choose the break. Cuts are worked out for a 1280x720 screen, so the copied deck has the same slides on any screen, and on a smaller one they shrink a little more.
4. Press **Copy deck as HTML** to copy the whole deck as one self-contained HTML file. Open it in any browser and present with the arrow keys — offline, no dependencies.
-58
View File
@@ -576,64 +576,6 @@
if (copied) done(); else failed();
}
// Files: a .md (or any text file) dropped anywhere on the page, or picked with Open .md, becomes the
// deck. It is read in the tab with FileReader; nothing is uploaded.
var fileInput = document.getElementById("file");
var openBtn = document.getElementById("open");
var dragDepth = 0;
function hasFile(e) {
var t = e.dataTransfer && e.dataTransfer.types;
return !!t && Array.prototype.indexOf.call(t, "Files") >= 0;
}
function openFile(file) {
if (!file) return;
if (file.size > 2000000) {
say(file.name + " is over 2 MB. A deck is plain text, so something else is in that file.", true);
return;
}
var reader = new FileReader();
reader.onload = function () {
load(String(reader.result).replace(/^/, ""));
current = 0;
history.replaceState(null, "", location.href.split("#")[0]);
parse();
say("Opened " + file.name + ". Edits stay in this tab; copy a link or the HTML to keep them.");
};
reader.onerror = function () { say("Couldn't read " + file.name + ".", true); };
reader.readAsText(file);
}
openBtn.addEventListener("click", function () { fileInput.click(); });
fileInput.addEventListener("change", function () {
openFile(fileInput.files[0]);
fileInput.value = "";
});
document.addEventListener("dragenter", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
dragDepth++;
document.body.classList.add("dropping");
});
document.addEventListener("dragover", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
});
document.addEventListener("dragleave", function (e) {
if (!hasFile(e)) return;
if (--dragDepth <= 0) { dragDepth = 0; document.body.classList.remove("dropping"); }
});
document.addEventListener("drop", function (e) {
if (!hasFile(e)) return;
e.preventDefault();
dragDepth = 0;
document.body.classList.remove("dropping");
openFile(e.dataTransfer.files[0]);
});
// A deck in the address opens in place of the sample, and so does one pasted into it later.
var linked = /(^#|&)(d|md)=/.test(location.hash);
load(linked ? "" : SAMPLE);
+5 -31
View File
@@ -87,34 +87,12 @@
flex-direction: column;
min-height: 0;
}
.editor-pane .label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.editor-pane label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-dim);
}
.editor-pane .open { padding: 4px 10px; font-size: 0.78rem; }
#file { display: none; }
body.dropping textarea { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(110,168,254,0.25); }
body.dropping::after {
content: "Drop your .md file to open it";
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15,17,21,0.75);
color: var(--ink);
font-size: 1.3rem;
pointer-events: none;
z-index: 10;
margin-bottom: 6px;
}
textarea {
flex: 1;
@@ -424,16 +402,12 @@
</header>
<main id="try">
<section class="editor-pane">
<div class="label-row">
<label for="src">Slides</label>
<button id="open" class="open" type="button">Open .md</button>
<input id="file" type="file" accept=".md,.markdown,.txt,text/markdown,text/plain">
</div>
<label for="src">Slides</label>
<textarea id="src" spellcheck="false"></textarea>
<p class="hint">
<code># Title</code> heading · <code>## Subtitle</code> · <code>- bullet</code> point ·
<code>---</code> on its own line starts a new slide · inline <code>**bold**</code>, <code>*italic*</code>, <code>`code`</code>.
Plain lines become paragraphs. Or drop a <code>.md</code> file anywhere on the page.
Plain lines become paragraphs.
</p>
</section>
<section class="stage">
@@ -455,7 +429,7 @@
<div class="steps">
<div class="step">
<h3>Type your slides</h3>
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type. Already have a <code>.md</code> file? Drop it on the page.</p>
<p>Markdown-ish text in the editor: headings, bullets, paragraphs, <code>---</code> between slides. The syntax is the stuff you already type.</p>
</div>
<div class="step">
<h3>Watch it set live</h3>
@@ -501,7 +475,7 @@
<div class="price">$0</div>
<p>For anyone who needs a quick deck right now.</p>
<ul>
<li>Full plain-text editor; drop in a <code>.md</code> file to start from one</li>
<li>Full plain-text editor</li>
<li>Live navigable preview with arrow keys and progress bar</li>
<li>One-click copy of the deck as self-contained HTML</li>
<li>Links with the deck inside them, nothing stored</li>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

-39
View File
@@ -1,39 +0,0 @@
#!/bin/sh
# tools/build.sh: shots/desktop.png, the first screen at 1440x900, rendered from this folder by headless Chrome.
# Time's refresh workflow runs this (Actions -> refresh, slug roll-credits). Needs python3 (for a local server) and
# Chrome or Chromium: set CHROME=/path/to/chrome if it isn't found, and CHROME_FLAGS=--no-sandbox when running as root.
set -eu
cd "$(dirname "$0")/.."
CHROME=${CHROME:-}
for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" "$(command -v chromium || true)" \
"$(command -v chromium-browser || true)" "$(command -v google-chrome || true)" "$(command -v google-chrome-stable || true)"; do
if [ -z "$CHROME" ] && [ -n "$c" ] && [ -x "$c" ]; then CHROME=$c; fi
done
[ -n "$CHROME" ] || { echo "no Chrome or Chromium found; set CHROME" >&2; exit 1; }
PORT=${PORT:-8795}
python3 -m http.server "$PORT" --bind 127.0.0.1 >/dev/null 2>&1 </dev/null &
SRV=$!
trap 'kill $SRV 2>/dev/null || true' EXIT INT TERM
n=0
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/"; do
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
done
mkdir -p shots
out=$PWD/shots/desktop.png
prof=$(mktemp -d)
rm -f "$out"
# shellcheck disable=SC2086
"$CHROME" --headless=new --hide-scrollbars --no-first-run --no-default-browser-check --disable-background-networking \
--disable-component-update --user-data-dir="$prof" --virtual-time-budget=6000 --window-size=1440,900 \
--screenshot="$out" ${CHROME_FLAGS:-} "http://127.0.0.1:$PORT/" >"$prof.log" 2>&1 &
pid=$!
n=0
while kill -0 "$pid" 2>/dev/null && [ ! -s "$out" ]; do n=$((n + 1)); [ "$n" -lt 120 ] || break; sleep 0.5; done
sleep 1
kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true
[ -s "$out" ] || { echo "Chrome made no screenshot:" >&2; tail -5 "$prof.log" >&2; exit 1; }
rm -rf "$prof" "$prof.log"
echo "built: shots/desktop.png"
+1 -16
View File
@@ -1,19 +1,5 @@
{
"ventures": [
{
"slug": "gandora",
"name": "gandora",
"one_liner": "Type a seed, get a station: every song is written and rendered for you as it plays, every cover is drawn from the song's own settings, and your reactions steer what comes next.",
"url": "https://gandora.lol/",
"pages": "https://gandora.yolopreneur.lol/",
"launched": "2026-10-20",
"status": "live",
"signups": 0,
"source": "yolopreneur",
"repo": "https://teahub.xyz/gandora/site",
"hosting": "pages",
"shot": "https://gandora.yolopreneur.lol/shots/desktop.png"
},
{
"slug": "slyng",
"name": "SLYNG",
@@ -501,7 +487,6 @@
},
{
"slug": "roll-credits",
"shot": "roll-credits/shots/desktop.png",
"name": "Roll Credits",
"one_liner": "Type the names of the people who made something, pick a credits style, and download a scrolling end-credits video, entirely in your browser.",
"for_whom": "A team lead or friend-group organizer who wants to thank the people behind a launch, a trip, a bake-off, or a last day at a job, and wants it to feel like a film ending.",
@@ -520,5 +505,5 @@
"hosting": "pages"
}
],
"refreshed": "2026-10-21"
"refreshed": "2026-10-20"
}