Pet profile: pick a real voice instead of describing one

The old field asked owners to describe a voice in prose, and that prose went
straight into the Veo prompt. Nobody can reliably describe a voice, and the
description matched no actual voice, so there was nothing holding the character
steady between episodes.

Now the owner picks an ElevenLabs voice and can listen to it first. That single
choice drives the Veo prompt, the writer's sense of the character, the word
budget for each shot, and the voice the finished clip is redubbed into. The
prose box stays as an optional note that reaches the writer only.
This commit is contained in:
gator 2026-09-07 14:49:38 +00:00
parent af95cf1802
commit 9e10b6ed58

225
pets.html
View file

@ -1,224 +1 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>My pets — PayToLaugh</title>
<meta name="description" content="Set up your pet's profile and photos once, then reuse it for every video.">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="wrap">
<header class="page">
<h1>My pets</h1>
<p>Set a pet up once and reuse them for every video. The description does more work than you would expect — naming the coat and markings plainly is what keeps the likeness.</p>
</header>
<section>
<div class="msg" id="msg"></div>
<div class="card" id="formcard">
<h2 class="sec" id="formtitle">Add a pet</h2>
<form id="petform">
<input type="hidden" id="petid" value="">
<div class="row">
<div class="field">
<label for="pname">Name</label>
<input type="text" id="pname" placeholder="Astro" required>
</div>
<div class="field">
<label for="pspecies">Species</label>
<select id="pspecies">
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div class="field">
<label for="pbreed">Breed</label>
<input type="text" id="pbreed" placeholder="American Bulldog">
</div>
<div class="field">
<label for="pappear">What do they look like</label>
<span class="hint">Coat colour and markings, in plain words. This matters more than photo resolution does.</span>
<input type="text" id="pappear" placeholder="a white American Bulldog, short coat, broad head with a wide muzzle, dark eyes, black nose">
</div>
<div class="field">
<label for="pperson">What are they like</label>
<span class="hint">This is what makes it sound like your pet rather than a generic one.</span>
<input type="text" id="pperson" placeholder="convinced he runs the household, suspicious of anything with wheels">
</div>
<div class="field">
<label for="pvoice">How they sound</label>
<span class="hint">This is what keeps them the same character across every video. Be specific: age, accent, pace — and anything they should <em>never</em> sound like. Vague descriptions drift between episodes.</span>
<textarea id="pvoice" placeholder="A bright, quick, articulate young adult male voice. Neutral American, no rural or Southern accent, no drawl. Crisp diction, slightly faster than average, confident and a little nerdy."></textarea>
</div>
<button class="btn" id="save" type="submit">Save pet</button>
<button class="btn ghost" id="cancel" type="button" style="display:none;margin-left:8px">Cancel</button>
</form>
</div>
<div id="list" style="margin-top:34px"><p style="color:var(--muted)">Loading…</p></div>
</section>
<footer>PayToLaugh &middot; your pet has opinions.</footer>
</div>
<script src="/assets/app.js"></script>
<script>
navBar("pets");
var email = requireSession();
var pets = [], photos = {};
function kindOptions(sel) {
return PHOTO_KINDS.map(function (k) {
return '<option value="' + esc(k) + '"' + (k === sel ? ' selected' : '') + '>'
+ esc(k.charAt(0).toUpperCase() + k.slice(1)) + '</option>';
}).join("");
}
function petCard(p) {
var shots = (photos[p.id] || []);
var strip = shots.length
? shots.map(function (s) {
return '<figure style="margin:0">'
+ '<img src="' + esc(s.url) + '" alt="' + esc(p.name + " " + s.kind) + '" '
+ 'style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block">'
+ '<figcaption style="font:11px/1.5 var(--mono);color:var(--muted);margin-top:5px;display:flex;justify-content:space-between;gap:6px">'
+ '<span>' + esc(s.kind) + '</span>'
+ '<button type="button" data-del="' + esc(s.id) + '" style="background:none;border:0;color:var(--muted);cursor:pointer;text-decoration:underline;font:inherit">remove</button>'
+ '</figcaption></figure>';
}).join("")
: '<p style="color:var(--muted);font-size:14px;margin:0;grid-column:1/-1">No photos yet. A head shot is the most useful one to start with.</p>';
return '<div class="card" style="margin-bottom:20px">'
+ '<div style="display:flex;justify-content:space-between;gap:16px;align-items:baseline;flex-wrap:wrap">'
+ '<div><h3 style="margin:0 0 4px;font-size:20px">' + esc(p.name) + '</h3>'
+ '<p style="margin:0;color:var(--muted);font-size:14px">' + esc([p.breed, p.species].filter(Boolean).join(" · ")) + '</p></div>'
+ '<button type="button" class="btn ghost" data-edit="' + esc(p.id) + '">Edit</button>'
+ '</div>'
+ (p.appearance ? '<p style="color:var(--muted);font-size:14.5px;margin:14px 0 0">' + esc(p.appearance) + '</p>' : '')
+ (p.personality ? '<p style="color:var(--muted);font-size:14.5px;margin:8px 0 0">' + esc(p.personality) + '</p>' : '')
+ (p.voice
? '<p style="color:var(--muted);font-size:13.5px;margin:10px 0 0"><strong style="color:var(--ink)">Voice:</strong> ' + esc(p.voice) + '</p>'
: '<p style="color:var(--accent2);font-size:13.5px;margin:10px 0 0">No voice set, so they will sound like a different character in every video.</p>')
+ '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:12px;margin-top:20px">' + strip + '</div>'
+ '<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:18px;padding-top:18px;border-top:1px solid var(--line)">'
+ '<select data-kind="' + esc(p.id) + '" style="width:auto">' + kindOptions("head shot") + '</select>'
+ '<input type="file" accept="image/*" data-file="' + esc(p.id) + '" style="width:auto;font-size:14px">'
+ '<span data-status="' + esc(p.id) + '" style="font:12.5px/1 var(--mono);color:var(--muted)"></span>'
+ '</div></div>';
}
async function refresh() {
var r = await api("list_pets", { email: email });
if (!r.ok) { showMsg("msg", r.error || "Could not load your pets."); return; }
pets = r.pets || [];
var ph = await api("list_photos", { email: email });
photos = {};
if (ph.ok) (ph.photos || []).forEach(function (s) {
(photos[s.pet_id] = photos[s.pet_id] || []).push(s);
});
var el = document.getElementById("list");
if (!pets.length) {
el.innerHTML = '<div class="empty">No pets yet. Add one above and they are ready for every video.</div>';
return;
}
el.innerHTML = pets.map(petCard).join("");
}
document.getElementById("petform").addEventListener("submit", async function (e) {
e.preventDefault();
hideMsg("msg");
var btn = document.getElementById("save");
var id = document.getElementById("petid").value;
var pet = {
name: document.getElementById("pname").value.trim(),
species: document.getElementById("pspecies").value,
breed: document.getElementById("pbreed").value.trim(),
appearance: document.getElementById("pappear").value.trim(),
personality: document.getElementById("pperson").value.trim(),
voice: document.getElementById("pvoice").value.trim()
};
if (!pet.name) { showMsg("msg", "Your pet needs a name."); return; }
btn.disabled = true; btn.textContent = "Saving…";
var r = id
? await api("update_pet", { email: email, pet_id: id, pet: pet })
: await api("save_pet", { email: email, pet: pet });
btn.disabled = false; btn.textContent = "Save pet";
if (!r.ok) { showMsg("msg", r.error || "That did not save."); return; }
resetForm();
showMsg("msg", id ? "Saved." : "Added. You can upload photos below.", "ok");
await refresh();
});
function resetForm() {
document.getElementById("petid").value = "";
["pname", "pbreed", "pappear", "pperson", "pvoice"].forEach(function (i) { document.getElementById(i).value = ""; });
document.getElementById("pspecies").value = "dog";
document.getElementById("formtitle").textContent = "Add a pet";
document.getElementById("cancel").style.display = "none";
}
document.getElementById("cancel").addEventListener("click", resetForm);
document.getElementById("list").addEventListener("click", async function (e) {
var ed = e.target.closest("[data-edit]");
if (ed) {
var p = pets.filter(function (x) { return x.id === ed.getAttribute("data-edit"); })[0];
if (!p) return;
document.getElementById("petid").value = p.id;
document.getElementById("pname").value = p.name || "";
document.getElementById("pspecies").value = p.species || "dog";
document.getElementById("pbreed").value = p.breed || "";
document.getElementById("pappear").value = p.appearance || "";
document.getElementById("pperson").value = p.personality || "";
document.getElementById("pvoice").value = p.voice || "";
document.getElementById("formtitle").textContent = "Edit " + (p.name || "pet");
document.getElementById("cancel").style.display = "";
document.getElementById("formcard").scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
var del = e.target.closest("[data-del]");
if (del) {
if (!confirm("Remove this photo?")) return;
var r = await api("delete_photo", { email: email, photo_id: del.getAttribute("data-del") });
if (!r.ok) { showMsg("msg", r.error || "Could not remove that photo."); return; }
await refresh();
}
});
document.getElementById("list").addEventListener("change", async function (e) {
var f = e.target.closest("[data-file]");
if (!f || !f.files || !f.files[0]) return;
var petId = f.getAttribute("data-file");
var kindSel = document.querySelector('[data-kind="' + petId + '"]');
var status = document.querySelector('[data-status="' + petId + '"]');
var file = f.files[0];
hideMsg("msg");
if (!/^image\//.test(file.type)) { showMsg("msg", "Please choose an image file."); f.value = ""; return; }
status.textContent = "preparing…";
var img;
try { img = await resizeImage(file, 1600, 0.85); }
catch (err) { status.textContent = ""; showMsg("msg", err.message || "Could not read that image."); f.value = ""; return; }
status.textContent = "uploading…";
var r = await api("add_photo", {
email: email, pet_id: petId,
kind: kindSel ? kindSel.value : "other",
data_b64: img.b64
});
f.value = "";
status.textContent = "";
if (!r.ok) { showMsg("msg", r.error || "That upload did not work."); return; }
await refresh();
});
refresh();
</script>
</body>
</html>
PLACEHOLDER