271 lines
14 KiB
HTML
271 lines
14 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Admin — PayToLaugh</title>
|
|
<meta name="robots" content="noindex, nofollow">
|
|
<link rel="stylesheet" href="/assets/app.css">
|
|
<style>
|
|
.tot{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:28px}
|
|
.tot div{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
|
|
.tot b{display:block;font-size:22px;letter-spacing:-.01em}
|
|
.tot span{font:11px/1.4 var(--mono);color:var(--muted);text-transform:uppercase;letter-spacing:.1em}
|
|
.proj{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px}
|
|
.proj h3{margin:0 0 4px;font-size:18px}
|
|
.proj .who{font:12px/1.5 var(--mono);color:var(--muted);margin-bottom:12px}
|
|
.grid2{display:grid;grid-template-columns:170px 1fr;gap:20px;align-items:start}
|
|
@media(max-width:700px){.grid2{grid-template-columns:1fr}}
|
|
.grid2 video{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);background:#000}
|
|
.kv{font:12px/1.7 var(--mono);color:var(--muted)}
|
|
.kv b{color:var(--ink);font-weight:600}
|
|
details{border-top:1px solid var(--line);margin-top:14px;padding-top:12px}
|
|
summary{cursor:pointer;font:12px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--accent)}
|
|
pre{background:#0f1117;border:1px solid var(--line);border-radius:8px;padding:12px 14px;
|
|
overflow-x:auto;font:12px/1.6 var(--mono);color:#cfd6e4;white-space:pre-wrap;word-break:break-word}
|
|
.shot{border-left:3px solid var(--line);padding-left:14px;margin:14px 0}
|
|
.shot .hd{font:11px/1 var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--accent2);margin-bottom:6px}
|
|
.warnw{color:#ffb9a7}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header class="page">
|
|
<h1>Admin</h1>
|
|
<p>Every project, what it cost, and the exact prompt that was sent for each shot.</p>
|
|
</header>
|
|
<section>
|
|
<div class="msg" id="msg"></div>
|
|
<div id="gate" class="card" style="max-width:440px">
|
|
<div class="field">
|
|
<label for="k">Admin key</label>
|
|
<span class="hint">Kept in this browser only. Never in the page source.</span>
|
|
<input type="password" id="k" autocomplete="off">
|
|
</div>
|
|
<button class="btn" id="go">Open</button>
|
|
</div>
|
|
<div id="out"></div>
|
|
</section>
|
|
<footer>PayToLaugh · operator view</footer>
|
|
</div>
|
|
|
|
<script src="/assets/app.js"></script>
|
|
<script>
|
|
// Access is the admin ROLE on the signed-in session. The key remains only as
|
|
// a bootstrap for granting the very first admin, and is not needed after that.
|
|
var KEY = null;
|
|
try { KEY = localStorage.getItem("ptl_admin"); } catch (e) {}
|
|
navBar("admin");
|
|
|
|
function money(v) { return v == null ? "—" : "$" + Number(v).toFixed(2); }
|
|
// when() comes from app.js and carries the time.
|
|
|
|
function shotHtml(s) {
|
|
var over = s.words > 18;
|
|
return '<div class="shot">'
|
|
+ '<div class="hd">Shot ' + s.index + (s.continues ? ' · continues' : '')
|
|
+ ' · seed: ' + esc(s.seed_used || "unknown") + '</div>'
|
|
+ '<p style="margin:0 0 6px">' + esc(s.line || "") + '</p>'
|
|
+ '<div class="kv"><span' + (over ? ' class="warnw"' : '') + '>' + s.words + ' words'
|
|
+ (over ? ' (over 18)' : '') + '</span> · camera: ' + esc(s.camera || "—") + '</div>'
|
|
// The writer's performance direction. It used to reach the previz and
|
|
// never Veo, so a line written as "building outrage" was filmed flat.
|
|
// Showing both halves is the point: what was asked for, and what went.
|
|
+ '<div class="kv">delivery: ' + (s.delivery_sent
|
|
? '<b>' + esc(s.delivery_sent) + '</b> — sent to Veo'
|
|
: (s.delivery
|
|
? '<span class="warnw">' + esc(s.delivery) + ' — written but NOT sent</span>'
|
|
: 'none written for this line')) + '</div>'
|
|
// A redub that failed falls back to Veo's own audio on purpose. That is
|
|
// the right behaviour, but it must not be silent. Deliberately skipping
|
|
// it is a third state and must not read as the failure.
|
|
+ '<div class="kv">voice: ' + (s.voice_used
|
|
? esc(s.voice_used) + ' (redubbed)'
|
|
: (s.redub_skipped
|
|
? 'Veo original — redub switched off for this run'
|
|
: '<span class="warnw">Veo original, not redubbed</span>')) + '</div>'
|
|
// The likeness chain, end to end: the owner's photograph, then the
|
|
// opening frame built from it, which is what Veo actually receives. If
|
|
// the frame is missing, Veo saw only the written description.
|
|
+ '<div style="display:flex;gap:14px;margin:10px 0;flex-wrap:wrap">'
|
|
+ (s.ref_photo_url
|
|
? '<figure style="margin:0"><a href="' + esc(s.ref_photo_url) + '" target="_blank" rel="noopener">'
|
|
+ '<img src="' + esc(s.ref_photo_url) + '" alt="reference photograph" '
|
|
+ 'style="height:120px;border-radius:8px;border:1px solid #2a2f3a;display:block"></a>'
|
|
+ '<figcaption class="kv">their photo' + (s.ref_photo_kind ? ' · ' + esc(s.ref_photo_kind) : '') + '</figcaption></figure>'
|
|
: '')
|
|
+ (s.plate_url
|
|
? '<figure style="margin:0"><a href="' + esc(s.plate_url) + '" target="_blank" rel="noopener">'
|
|
+ '<img src="' + esc(s.plate_url) + '" alt="opening frame" '
|
|
+ 'style="height:120px;border-radius:8px;border:1px solid #2a2f3a;display:block"></a>'
|
|
+ '<figcaption class="kv">opening frame → Veo</figcaption></figure>'
|
|
: (s.seed_used === 'previous last frame' || s.continues
|
|
? '<div class="kv" style="align-self:center">continues from the previous shot’s '
|
|
+ 'real last frame, so it needs no opening still of its own</div>'
|
|
: '<div class="warnw" style="align-self:center">no opening frame — Veo was given the '
|
|
+ 'written description only, so the likeness is invented</div>'))
|
|
// A frame can be drawn after the fact. Do not let it imply a seed that was
|
|
// never actually sent to Veo.
|
|
+ (s.plate_url && s.seed_used === 'none'
|
|
? '<div class="warnw" style="align-self:center;max-width:280px">this frame was drawn '
|
|
+ 'after filming — it was NOT used for the video above</div>'
|
|
: '')
|
|
+ '</div>'
|
|
// Copyable on purpose: the same text pasted into the Gemini web app is how
|
|
// we tell an artifact of OUR prompt from an artifact of the API path.
|
|
+ (s.prompt_sent
|
|
? '<details><summary>Exact prompt sent</summary>'
|
|
+ '<button type="button" class="btn copyp" '
|
|
+ 'style="padding:4px 10px;font-size:12px;margin:10px 0">Copy this prompt</button>'
|
|
+ '<pre>' + esc(s.prompt_sent) + '</pre></details>'
|
|
: '<div class="kv">no prompt recorded (filmed before prompts were stored)</div>')
|
|
// The prompt and the request are not the same claim. An earlier version of
|
|
// video_render validated its images and then posted only the prompt, which
|
|
// read as working code for weeks. This is the body as it went on the wire.
|
|
+ (s.request_sent
|
|
? '<details><summary>The request body actually POSTed</summary><pre>'
|
|
+ esc(s.request_sent) + '</pre></details>'
|
|
: '')
|
|
+ '</div>';
|
|
}
|
|
|
|
function projHtml(p) {
|
|
var media = p.video_url
|
|
? '<video controls playsinline preload="metadata" src="' + esc(p.video_url) + '"></video>'
|
|
: '<div class="kv">no video</div>';
|
|
return '<div class="proj">'
|
|
+ '<div class="grid2"><div>' + media + '</div><div>'
|
|
+ '<h3>' + esc(p.title || "(untitled)") + '</h3>'
|
|
// A re-film updates filmed_at and nothing else. Ordering and dating by
|
|
// created_at alone made today's work look like it never happened.
|
|
+ '<div class="who">' + esc(p.user_email || "") + ' · ' + esc(p.pet_name || "") + ' · '
|
|
+ esc(p.status || "") + ' · created ' + esc(when(p.created_at))
|
|
+ (p.filmed_at ? ' · <b>filmed ' + esc(when(p.filmed_at)) + '</b>' : '')
|
|
+ '</div>'
|
|
+ '<div class="kv">'
|
|
+ '<b>billed</b> ' + money(p.price_usd) + ' · <b>cost</b> ' + money(p.actual_cost_usd || p.cost_usd)
|
|
+ (p.audio_cost_usd
|
|
? ' <span style="opacity:.7">(video ' + money(p.video_cost_usd)
|
|
+ ' + voice ' + money(p.audio_cost_usd) + ', ' + (p.audio_chars || 0) + ' chars)</span>'
|
|
: '')
|
|
+ ' · <b>tokens</b> ' + (p.script_tokens || 0)
|
|
+ ' · <b>writes</b> ' + (p.script_writes || 0)
|
|
+ ' · <b>rewrites</b> ' + (p.revisions || 0) + '<br>'
|
|
+ '<b>voice</b> ' + esc(p.voice_name ? p.voice_name + " — " : "")
|
|
+ esc(p.pet_voice_formula || "(none set)") + '<br>'
|
|
+ '<b>scene</b> ' + esc(p.scene || "(none)") + '<br>'
|
|
+ '<b>source</b> <a href="' + esc(p.source_url || "#") + '" target="_blank" rel="noopener nofollow">' + esc((p.source_url || "").slice(0, 70)) + '</a>'
|
|
+ (p.film_note ? '<br><span class="warnw"><b>note</b> ' + esc(p.film_note) + '</span>' : '')
|
|
+ '</div>'
|
|
// Operator switch, for hearing the same script both ways. Not a customer
|
|
// control: the server refuses it to anyone without the admin role.
|
|
// Veo's own read ships. The conversion is opt in, kept for the one thing
|
|
// it still solves: a voice that holds across cuts.
|
|
+ '<div class="kv" style="margin-top:10px">redub on the next film: <b>'
|
|
+ (p.redub_on ? 'ON — converted to the pet voice' : 'off — Veo keeps its own voice') + '</b> '
|
|
+ '<button type="button" class="btn" style="padding:4px 10px;font-size:12px;margin-left:8px" '
|
|
+ 'data-redub="' + esc(p.id) + '" data-want="' + (p.redub_on ? '0' : '1') + '">'
|
|
+ (p.redub_on ? 'Keep Veo’s own voice' : 'Redub the next one')
|
|
+ '</button></div>'
|
|
+ (p.owner_notes ? '<p class="kv" style="margin-top:10px"><b>notes</b> ' + esc(p.owner_notes) + '</p>' : '')
|
|
+ '</div></div>'
|
|
+ (p.full_text ? '<details><summary>Full piece as written</summary><pre>' + esc(p.full_text) + '</pre></details>' : '')
|
|
// The master is what Veo produced. Everything else is derived from it and
|
|
// is disposable; the master is not regenerated and not overwritten.
|
|
+ (p.renditions && p.renditions.length
|
|
? '<details open><summary>' + p.renditions.length + ' renditions</summary>'
|
|
+ p.renditions.map(function (r) {
|
|
return '<div class="kv" style="margin:10px 0;padding-left:12px;'
|
|
+ 'border-left:3px solid ' + (r.kind === "master" ? 'var(--accent2)' : 'var(--line)') + '">'
|
|
+ '<b>' + esc(r.label || r.kind || "") + '</b>'
|
|
+ (r.url ? ' · <a href="' + esc(r.url) + '" target="_blank" rel="noopener">open</a>' : '')
|
|
+ (r.made_at ? ' · ' + esc(when(r.made_at)) : '')
|
|
+ (r.note ? '<br><span style="opacity:.72">' + esc(r.note) + '</span>' : '')
|
|
+ '</div>';
|
|
}).join("")
|
|
+ '</details>'
|
|
: '')
|
|
+ (p.shots && p.shots.length
|
|
? '<details open><summary>' + p.shots.length + ' shots</summary>' + p.shots.map(shotHtml).join("") + '</details>'
|
|
: '')
|
|
+ '</div>';
|
|
}
|
|
|
|
async function load() {
|
|
hideMsg("msg");
|
|
var r = await api("admin_overview", KEY ? { admin_key: KEY } : {});
|
|
if (!r.ok) {
|
|
showMsg("msg", r.error || "Could not load.");
|
|
document.getElementById("gate").style.display = "";
|
|
return;
|
|
}
|
|
document.getElementById("gate").style.display = "none";
|
|
var t = r.totals || {};
|
|
var pct = t.projects ? Math.round((t.accepted_first_draft / t.projects) * 100) : 0;
|
|
document.getElementById("out").innerHTML =
|
|
'<div class="tot">'
|
|
+ '<div><b>' + (t.projects || 0) + '</b><span>projects</span></div>'
|
|
+ '<div><b>' + (t.with_video || 0) + '</b><span>filmed</span></div>'
|
|
+ '<div><b>' + (t.users || 0) + '</b><span>users</span></div>'
|
|
+ '<div><b>' + money(t.billed_usd) + '</b><span>billed</span></div>'
|
|
+ '<div><b>' + money(t.spent_usd) + '</b><span>our cost</span></div>'
|
|
+ '<div><b>' + (t.tokens || 0).toLocaleString() + '</b><span>script tokens</span></div>'
|
|
+ '<div><b>' + (t.rewrites || 0) + '</b><span>rewrites asked</span></div>'
|
|
+ '<div><b>' + pct + '%</b><span>took first draft</span></div>'
|
|
+ '</div>'
|
|
+ (r.projects || []).map(projHtml).join("");
|
|
|
|
// Delegated, because the list is re-rendered wholesale on every load.
|
|
document.getElementById("out").onclick = async function (ev) {
|
|
var cp = ev.target.closest ? ev.target.closest(".copyp") : null;
|
|
if (cp) {
|
|
var pre = cp.parentElement.querySelector("pre");
|
|
if (pre) {
|
|
try {
|
|
await navigator.clipboard.writeText(pre.textContent);
|
|
cp.textContent = "Copied";
|
|
} catch (e) {
|
|
// Clipboard needs a secure context and permission; selecting the
|
|
// text is a working fallback rather than a dead button.
|
|
var rg = document.createRange();
|
|
rg.selectNodeContents(pre);
|
|
var sel = window.getSelection();
|
|
sel.removeAllRanges();
|
|
sel.addRange(rg);
|
|
cp.textContent = "Selected — press Ctrl+C";
|
|
}
|
|
setTimeout(function () { cp.textContent = "Copy this prompt"; }, 2500);
|
|
}
|
|
return;
|
|
}
|
|
var b = ev.target.closest ? ev.target.closest("[data-redub]") : null;
|
|
if (!b) return;
|
|
b.disabled = true;
|
|
var was = b.textContent;
|
|
b.textContent = "Saving...";
|
|
var res = await api("set_film_options", {
|
|
project_id: b.getAttribute("data-redub"),
|
|
redub_on: b.getAttribute("data-want") === "1"
|
|
});
|
|
if (!res.ok) {
|
|
showMsg("msg", res.error || "Could not change that.");
|
|
b.disabled = false;
|
|
b.textContent = was;
|
|
return;
|
|
}
|
|
load();
|
|
};
|
|
}
|
|
|
|
document.getElementById("go").onclick = function () {
|
|
KEY = document.getElementById("k").value.trim();
|
|
if (!KEY) { showMsg("msg", "Enter the admin key."); return; }
|
|
try { localStorage.setItem("ptl_admin", KEY); } catch (e) {}
|
|
load();
|
|
};
|
|
|
|
// A signed-in admin never sees the key prompt.
|
|
if (Session.token() || KEY) load();
|
|
</script>
|
|
</body>
|
|
</html>
|