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 nothing held 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 per 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:
parent
9e10b6ed58
commit
a3af200b14
1 changed files with 300 additions and 1 deletions
301
pets.html
301
pets.html
|
|
@ -1 +1,300 @@
|
|||
PLACEHOLDER
|
||||
<!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="pvoiceid">How they sound</label>
|
||||
<span class="hint">Pick the voice and listen to it. Every video is recorded in this exact voice, so your pet sounds like the same character each time instead of a different one in every episode.</span>
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||
<select id="pvoiceid" style="flex:1 1 260px"><option value="">Loading voices…</option></select>
|
||||
<button type="button" class="btn ghost" id="hear" disabled>▶ Hear it</button>
|
||||
</div>
|
||||
<p id="vdesc" style="color:var(--muted);font-size:13.5px;margin:10px 0 0"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="pvoice">Anything else about how they talk <span style="color:var(--muted);font-weight:400">(optional)</span></label>
|
||||
<span class="hint">This goes to the writer, not the recording — it shapes the words your pet uses, not the voice itself.</span>
|
||||
<textarea id="pvoice" placeholder="Talks like he is explaining something obvious to a slow audience. Never swears, but gets close."></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 · your pet has opinions.</footer>
|
||||
</div>
|
||||
|
||||
<script src="/assets/app.js"></script>
|
||||
<script>
|
||||
navBar("pets");
|
||||
var email = requireSession();
|
||||
var pets = [], photos = {}, voices = [], sample = null;
|
||||
|
||||
function voiceById(id) {
|
||||
for (var i = 0; i < voices.length; i++) if (voices[i].id === id) return voices[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
// What to show on a card. Falls back to the stored formula for pets saved
|
||||
// before voices existed, so an old profile still reads as something.
|
||||
function voiceName(p) {
|
||||
var v = voiceById(p.eleven_voice_id || "");
|
||||
return v ? v.name + " — " + v.formula : (p.voice_formula || "");
|
||||
}
|
||||
|
||||
function describeVoice() {
|
||||
var v = voiceById(document.getElementById("pvoiceid").value);
|
||||
var d = document.getElementById("vdesc");
|
||||
document.getElementById("hear").disabled = !(v && v.preview);
|
||||
if (!v) { d.textContent = ""; return; }
|
||||
d.innerHTML = esc(v.description)
|
||||
+ '<br><span style="font:12px/1.7 var(--mono)">'
|
||||
+ esc(v.accent + " · " + v.age + " · about " + v.words + " words per 8 second shot")
|
||||
+ '</span>';
|
||||
}
|
||||
|
||||
async function loadVoices() {
|
||||
var r = await api("camera_presets", {});
|
||||
var sel = document.getElementById("pvoiceid");
|
||||
if (!r.ok || !(r.voices || []).length) {
|
||||
sel.innerHTML = '<option value="">Voices unavailable</option>';
|
||||
return;
|
||||
}
|
||||
voices = r.voices;
|
||||
// Grouped, because twenty-one flat names is a list nobody reads.
|
||||
var groups = [["male", "Male"], ["female", "Female"], ["neutral", "Neutral"]];
|
||||
var html = '<option value="">No voice yet — pick one</option>';
|
||||
groups.forEach(function (g) {
|
||||
var inG = voices.filter(function (v) { return v.gender === g[0]; });
|
||||
if (!inG.length) return;
|
||||
html += '<optgroup label="' + esc(g[1]) + '">'
|
||||
+ inG.map(function (v) {
|
||||
return '<option value="' + esc(v.id) + '">' + esc(v.name + " — " + v.formula) + '</option>';
|
||||
}).join("")
|
||||
+ '</optgroup>';
|
||||
});
|
||||
sel.innerHTML = html;
|
||||
describeVoice();
|
||||
}
|
||||
|
||||
document.getElementById("pvoiceid").addEventListener("change", describeVoice);
|
||||
|
||||
document.getElementById("hear").addEventListener("click", function () {
|
||||
var v = voiceById(document.getElementById("pvoiceid").value);
|
||||
if (!v || !v.preview) return;
|
||||
if (sample) { sample.pause(); sample = null; }
|
||||
sample = new Audio(v.preview);
|
||||
var btn = this;
|
||||
btn.textContent = "■ Playing…";
|
||||
sample.addEventListener("ended", function () { btn.innerHTML = "▶ Hear it"; });
|
||||
sample.addEventListener("error", function () { btn.innerHTML = "▶ Hear it"; });
|
||||
sample.play().catch(function () { btn.innerHTML = "▶ Hear it"; });
|
||||
});
|
||||
|
||||
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>' : '')
|
||||
+ (voiceName(p)
|
||||
? '<p style="color:var(--muted);font-size:13.5px;margin:10px 0 0"><strong style="color:var(--ink)">Voice:</strong> ' + esc(voiceName(p)) + '</p>'
|
||||
: '<p style="color:var(--accent2);font-size:13.5px;margin:10px 0 0">No voice picked, so they will sound like a different character in every video.</p>')
|
||||
+ (p.voice ? '<p style="color:var(--muted);font-size:13.5px;margin:6px 0 0">' + esc(p.voice) + '</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(),
|
||||
eleven_voice_id: document.getElementById("pvoiceid").value
|
||||
};
|
||||
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("pvoiceid").value = "";
|
||||
describeVoice();
|
||||
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("pvoiceid").value = p.eleven_voice_id || "";
|
||||
describeVoice();
|
||||
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();
|
||||
});
|
||||
|
||||
loadVoices().then(refresh);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Reference in a new issue