Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a0f40ce99 | ||
|
|
44a91eb69b | ||
|
|
db39c10681 | ||
|
|
8166fff7f9 |
No files matched your search
@@ -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 = "").
|
||||
|
||||
@@ -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 |
@@ -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; }
|
||||
}
|
||||
@@ -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(); });
|
||||
})();
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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"
|
||||
}
|
||||
Reference in new issue
Block a user