The catalogue was 21 polished professional voices — podcast hosts and narrators. None of them sounds like ninety pounds of jowly bulldog, which is the actual complaint. Seven deep, gravelly, weathered voices are added, and they go first because a talking animal usually wants weight rather than polish. Without a group of their own they would not have appeared at all: the dropdown only built male, female and neutral.
334 lines
16 KiB
HTML
334 lines
16 KiB
HTML
<!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.
|
|
// Big and rough first: a talking animal usually wants weight, not polish.
|
|
var groups = [["big", "Big and rough — good for large or heavy animals"],
|
|
["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) + '" '
|
|
+ 'data-full="' + esc(s.url) + '" data-cap="' + esc(p.name + " · " + s.kind) + '" '
|
|
+ 'title="Click to see it full size" '
|
|
+ 'style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block;cursor:zoom-in">'
|
|
+ '<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);
|
|
|
|
// A 110px square crop is too small to check the collar, the eye colour or the
|
|
// markings — which is exactly what these photographs exist to record.
|
|
function showFull(src, caption) {
|
|
var back = document.createElement("div");
|
|
back.setAttribute("role", "dialog");
|
|
back.setAttribute("aria-label", caption || "Photo");
|
|
back.style.cssText = "position:fixed;inset:0;background:rgba(6,8,12,.9);z-index:9999;"
|
|
+ "display:flex;flex-direction:column;align-items:center;justify-content:center;"
|
|
+ "gap:14px;padding:26px;cursor:zoom-out";
|
|
back.innerHTML = '<img src="' + esc(src) + '" alt="' + esc(caption || "") + '" '
|
|
+ 'style="max-width:min(100%,900px);max-height:82vh;border-radius:12px;'
|
|
+ 'border:1px solid var(--line);display:block;background:#000">'
|
|
+ '<p style="margin:0;color:#c9cedb;font:13px/1.5 var(--mono);text-align:center">'
|
|
+ esc(caption || "") + '<br><span style="opacity:.65">click anywhere, or press Escape, to close</span></p>';
|
|
|
|
function close() {
|
|
document.removeEventListener("keydown", onKey);
|
|
if (back.parentNode) back.parentNode.removeChild(back);
|
|
}
|
|
function onKey(e) { if (e.key === "Escape") close(); }
|
|
back.addEventListener("click", close);
|
|
document.addEventListener("keydown", onKey);
|
|
document.body.appendChild(back);
|
|
}
|
|
|
|
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 full = e.target.closest("[data-full]");
|
|
if (full) {
|
|
showFull(full.getAttribute("data-full"), full.getAttribute("data-cap"));
|
|
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>
|