paytolaugh/script.html
gator 4292624eda Wire the Remove cutaway button
Clears the cutaway from the shot so it is not cut into the film. The subject
stays, so Make cutaway can try again with a different draw.
2026-09-15 02:05:20 +00:00

1647 lines
79 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Direct the shots — PayToLaugh</title>
<meta name="description" content="Read the script, set the scene and camera for each shot, then approve it.">
<link rel="stylesheet" href="/assets/app.css">
<style>
.shot{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px;margin-bottom:18px;
display:grid;grid-template-columns:190px 1fr;gap:22px;align-items:start}
@media(max-width:720px){.shot{grid-template-columns:1fr}}
.shot .still{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);
object-fit:cover;display:block;background:#000}
.shot .noimg{width:100%;aspect-ratio:9/16;border-radius:10px;border:1px dashed var(--line);
background:#0f1117;display:flex;align-items:center;justify-content:center;text-align:center;
color:var(--muted);font-size:12.5px;padding:14px}
.shot .n{font:600 11px/1 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--accent)}
.shot .cont{font:11px/1 var(--mono);color:var(--accent2);margin-left:8px}
.shot .line{font-size:17px;line-height:1.5;margin:10px 0 16px}
.shot .foot{font:11.5px/1 var(--mono);color:var(--muted)}
.mini{font-size:13px;font-weight:600;margin:0 0 5px;display:block}
.mini span{color:var(--muted);font-weight:400}
.shot textarea{min-height:62px}
.warn{background:#2a2112;border:1px solid #5b4520;color:#f0d9a7;border-radius:10px;padding:13px 16px;margin-bottom:16px;font-size:14px}
.drop{border:1px dashed var(--line);border-radius:12px;padding:26px 20px;text-align:center;color:var(--muted);
background:#0f1117;cursor:pointer;font-size:14.5px}
.drop.hot{border-color:var(--accent);color:var(--ink)}
.pics{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:16px}
.pic img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block}
.pic input{margin-top:6px;font-size:13px;padding:7px 9px}
.pic .rm{background:none;border:0;color:var(--muted);cursor:pointer;font:11px/1.6 var(--mono);text-decoration:underline;padding:0}
.place{border-top:1px solid var(--line);padding:8px 0;font:12.5px/1.6 var(--mono);color:var(--muted)}
.full{background:#0f1117;border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:24px;
color:var(--muted);font-size:15.5px;line-height:1.7}
.tl{position:relative;height:56px;background:#0f1117;border:1px solid var(--line);border-radius:8px;
overflow:hidden;margin-top:12px;user-select:none;touch-action:none}
.tlcut{position:absolute;top:6px;bottom:6px;background:#1f6feb;border-radius:5px;cursor:grab;
font:11px/1.5 var(--mono);color:#fff;overflow:hidden;box-sizing:border-box;
display:flex;align-items:center;gap:6px;padding-right:11px}
.tlcut.moved{background:#8957e5}
.tlcut img,.tlcut video{height:100%;width:34px;object-fit:cover;flex:0 0 auto;display:block}
.tlcut b{font-weight:600;white-space:nowrap;overflow:hidden;padding-left:2px}
.tlshots{position:relative;height:46px;margin-top:14px}
.tlshot{position:absolute;top:0;bottom:0;border-left:1px solid var(--line);padding-left:4px;
font:10px/1.3 var(--mono);color:var(--muted);overflow:hidden;box-sizing:border-box}
.tlshot img{height:28px;width:auto;border-radius:3px;display:block;margin-top:2px;border:1px solid var(--line)}
.tlcut .grip{position:absolute;top:0;right:0;bottom:0;width:9px;cursor:ew-resize;background:rgba(255,255,255,.28)}
.tlhead{position:absolute;top:0;bottom:0;width:2px;background:var(--accent2);pointer-events:none}
.tlruler{position:absolute;left:0;right:0;bottom:0;height:14px;font:9px/14px var(--mono);color:var(--muted)}
.tlruler span{position:absolute;transform:translateX(-50%)}
.steps{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}
.steps button{background:#0f1117;border:1px solid var(--line);color:var(--muted);
border-radius:999px;padding:8px 15px;font:12px/1 var(--mono);cursor:pointer}
.steps button.done{color:var(--ink)}
.steps button.on{background:var(--accent);border-color:var(--accent);color:#fff;cursor:default}
.steps button[disabled]{opacity:.35;cursor:not-allowed}
.stage{position:relative;width:100%;max-width:300px}
.stage>video{width:100%;aspect-ratio:9/16;border-radius:10px;background:#000;display:block}
.ovl{position:absolute;inset:0;border-radius:10px;overflow:hidden;background:#000;display:none}
.ovl>*{width:100%;height:100%;object-fit:cover;display:block}
</style>
</head>
<body>
<div class="wrap">
<header class="page">
<h1 id="title">The script</h1>
<p id="sub">Read it through, then direct it. Every shot can have its own setting and camera move.</p>
</header>
<section>
<div class="msg" id="msg"></div>
<div id="out"><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("videos");
var email = requireSession();
var pid = new URLSearchParams(location.search).get("id");
var poll = null, PRESETS = [], PHOTOS = [], DATA = null;
// Shot length is a property of the engine the project will be filmed on, not a
// constant. Veo shoots 8s, Omni 1.1 shoots 10s, so every duration and price
// derived from "segments" has to ask which engine first.
var MODELS = [], WRITERS = [];
function modelFor(p) {
var id = (p && p.render_model) || "";
for (var i = 0; i < MODELS.length; i++) if (MODELS[i].id === id) return MODELS[i];
return null;
}
function shotSecondsOf(p) { var m = modelFor(p); return (m && m.shot_seconds) || 8; }
function videoSecondsOf(p) { return ((p && p.segments) || 4) * shotSecondsOf(p); }
if (!pid) {
document.getElementById("out").innerHTML =
'<div class="empty">No script chosen. <a href="/videos.html">Back to my videos</a>.</div>';
}
function photoOptions(sel) {
if (!PHOTOS.length) return '<option value="">No photos uploaded</option>';
return PHOTOS.map(function (p) {
var label = p.kind.charAt(0).toUpperCase() + p.kind.slice(1);
return '<option value="' + esc(p.id) + '"' + (p.id === sel ? ' selected' : '') + '>'
+ esc(label) + '</option>';
}).join("");
}
function shotHtml(s, locked) {
var img = s.plate_url
? '<img class="still" src="' + esc(s.plate_url) + '" alt="Shot ' + s.index + ' opening frame">'
: '<div class="noimg">No opening frame yet.<br>Set the scene, then make one.</div>';
var cont = s.source === "previous" && s.index > 1;
var toggle = s.index > 1
? '<div class="chips" data-srcgrp="' + s.index + '" style="margin-bottom:14px">'
+ '<span class="chip' + (cont ? '' : ' on') + '" data-src="photo">Start from a photo</span>'
+ '<span class="chip' + (cont ? ' on' : '') + '" data-src="previous">Continue from shot ' + (s.index - 1) + '</span>'
+ '</div>'
: '';
var settingLabel = cont
? '<label class="mini">The setting <span>— stays as it was; the camera is what moves</span></label>'
: '<label class="mini">The setting <span>— anywhere, including places a real pet could not be</span></label>';
var picker = cont
? '<p class="foot" style="margin:0">Continues the previous shot. Filming uses that shot&rsquo;s '
+ 'real last frame; the still here is a preview of the camera move.</p>'
: '<label class="mini">Which photo of them</label>'
+ '<select data-photo="' + s.index + '">' + photoOptions(s.photo_id) + '</select>';
// A cutaway belonging to this shot straddles the cut into the next one, so
// the registration jump happens off screen.
var br = s.broll || {};
var brollBlock = '';
if (br.subject) {
brollBlock = '<div style="margin-top:16px;padding-top:14px;border-top:1px solid var(--line)">'
+ '<label class="mini">Cutaway <span>— shown while this line is spoken, covering the cut</span></label>'
+ '<p class="foot" style="margin:0 0 8px">' + esc(br.subject) + ' · ' + esc(br.kind || 'image') + '</p>'
+ (s.broll_url
? (s.broll_kind === 'video'
? '<video src="' + esc(s.broll_url) + '" controls playsinline style="width:120px;border-radius:8px;border:1px solid var(--line)"></video>'
: '<img src="' + esc(s.broll_url) + '" alt="cutaway" style="width:120px;border-radius:8px;border:1px solid var(--line);display:block">')
: '')
+ (locked ? '' : '<button type="button" class="btn ghost" data-broll="' + s.index + '" style="margin-top:10px">'
+ (s.broll_url ? 'Remake cutaway' : 'Make cutaway') + '</button>')
// The subject is the writer's guess at what the line is about, so the
// picture it draws is hit and miss. Making and remaking were possible;
// throwing one away was not, which left a wrong image in the film with
// no way to take it out.
+ ((locked || !s.broll_url) ? ''
: '<button type="button" class="btn ghost" data-brollrm="' + s.index + '" '
+ 'style="margin-top:10px;margin-left:8px">Remove cutaway</button>')
+ '</div>';
}
var controls = locked ? '' :
toggle
+ settingLabel
+ '<textarea data-scene="' + s.index + '" placeholder="a field of tall grass at golden hour">' + esc(s.scene || "") + '</textarea>'
+ '<label class="mini" style="margin-top:12px">Camera</label>'
+ '<input type="text" list="cams" data-cam="' + s.index + '" value="' + esc(s.camera || "") + '" placeholder="tight head shot, camera static">'
+ '<div class="row" style="margin-top:12px">'
+ '<div>' + picker + '</div>'
+ '<div style="display:flex;align-items:flex-end">'
+ '<button type="button" class="btn ghost" data-make="' + s.index + '" style="width:100%">'
+ (s.plate_url ? "Redraw the opening still" : "Draw the opening still") + '</button>'
+ '<span class="foot" style="display:block;margin-top:6px">One picture, not the video. '
+ 'It is drawn from their photo and is what carries their likeness.</span></div>'
+ '</div>';
return '<div class="shot">'
+ '<div>' + img + '</div>'
+ '<div>'
+ '<span class="n">Shot ' + (s.index || "") + '</span>'
+ (s.continues ? '<span class="cont">continues the last shot</span>' : '')
+ '<p class="line">' + esc(s.line || "") + '</p>'
+ '<p class="foot">' + (s.word_count || 0) + ' words · about ' + (s.est_seconds || 0) + 's</p>'
+ '<div style="margin-top:16px">' + controls + '</div>'
+ brollBlock
+ '</div></div>';
}
// Four jobs, one at a time. Which one you are on follows from the project's
// status; VIEW lets you step back to look at an earlier one without pretending
// the work has been undone.
var STAGES = ["The words", "The layout", "Filming", "The cut"];
var VIEW = null;
function stageOf(st) {
if (st === "ready" || st === "film_failed") return 4;
if (st === "queued_to_film" || st === "filming") return 3;
if (st === "approved") return 2;
return 1;
}
// How far the stepper may be CLICKED, as opposed to how far the project has
// got. Approved means the words are settled and filming is the next thing to
// do, so the Filming step has to be reachable - it is where the button lives.
// Until this existed the only way to start a film was a button at the bottom
// of the Layout page, below the shot cards and the photo uploader.
function reachOf(st) {
if (st === "approved") return 3;
return stageOf(st);
}
function stepper(stage, view, reach) {
if (reach === undefined || reach === null) reach = stage;
return '<div class="steps">' + STAGES.map(function (n, k) {
var i = k + 1;
var cls = i === view ? "on" : (i <= stage ? "done" : "");
return '<button type="button" class="' + cls + '" data-stage="' + i + '"'
+ (i <= reach ? '' : ' disabled') + '>' + i + ' &middot; ' + esc(n) + '</button>';
}).join("") + '</div>';
}
// Stage one shows the writing and nothing else: no frames, no camera, no
// settings. Just what the pet actually says.
function linesHtml(s) {
return '<div class="shot" style="grid-template-columns:1fr;padding:16px 20px"><div>'
+ '<span class="n">Shot ' + (s.index || "") + '</span>'
+ '<p class="line" style="margin:8px 0 6px">' + esc(s.line || "") + '</p>'
+ '<p class="foot">' + (s.word_count || 0) + ' words &middot; about '
+ (s.est_seconds || 0) + 's</p></div></div>';
}
function render(r) {
DATA = r;
var p = r.project, s = r.script, out = document.getElementById("out");
var used = parseInt(p.revisions || 0, 10);
var left = (r.max_revisions || 3) - used;
if (p.status === "scripting") {
out.innerHTML = '<div class="card"><p style="margin:0;color:var(--muted)">'
+ 'Writing now. This usually takes under a minute — the page updates itself.</p></div>';
if (!poll) poll = setInterval(load, 5000);
return;
}
if (poll) { clearInterval(poll); poll = null; }
if (p.status === "script_failed") {
// The reason was always stored; it just was not being shown. A dead or
// paywalled link is the usual cause, and only the reader can fix it.
var why = (s && s.error) ? s.error : "";
out.innerHTML = '<div class="card"><p style="margin:0 0 10px">The writer could not finish this one.</p>'
+ (why ? '<div class="warn" style="margin:0 0 16px">' + esc(why) + '</div>'
: '<p class="foot" style="margin:0 0 16px">No reason was recorded.</p>')
+ '<a class="btn" style="display:inline-block;text-decoration:none" href="/create.html">Start again</a></div>';
return;
}
if (!s || !s.segments) { out.innerHTML = '<div class="empty">No script yet.</div>'; return; }
document.getElementById("title").textContent = s.title || "The script";
document.getElementById("sub").textContent = s.premise || "";
var stage = stageOf(p.status);
if (VIEW === null || VIEW > stage) VIEW = stage;
// Shot controls belong to the layout stage. Elsewhere the shots are shown
// but not touchable, which is what "locked" means to shotHtml.
var locked = VIEW !== 2;
var canEditScript = (stage === 1);
var warn = (s._warnings && s._warnings.length)
? '<div class="warn"><strong>Worth a look:</strong> ' + s._warnings.map(esc).join(" · ") + '</div>' : "";
var head = '<div class="quote" style="margin-bottom:22px">'
+ '<div><div class="big">$' + Number(p.price_usd || 0).toFixed(2) + '</div>'
+ '<div class="sub">' + videoSecondsOf(p) + ' seconds · ' + (p.segments || 4) + ' shots'
+ (modelFor(p) ? ' · ' + esc(modelFor(p).label) : '') + '</div></div>'
+ '<div class="sub">' + (canEditScript
? (used === 1 ? "1 rewrite" : used + " rewrites") + " so far"
: "Words approved") + '</div></div>';
// The whole piece, which is what was actually written - the shots below are
// cut from it. Older scripts predate the field, so fall back to the shot
// lines rather than showing an empty box.
var narrative = s.full_text
|| (s.segments || []).map(function (x) { return (x.line || "").trim(); }).join(" ").trim();
var stale = !!s.shots_stale;
var full = !narrative ? "" :
'<h2 class="sec">The whole piece</h2>'
+ (canEditScript
? '<div class="card"><div class="field">'
+ '<span class="hint">This is the script. The shots further down are cut from it, '
+ 'so change the words here and press Setup shots to divide them again.</span>'
+ '<textarea id="narr" style="min-height:260px">' + esc(narrative) + '</textarea></div>'
+ '<button type="button" class="btn ghost" id="savenarr">Save the words</button>'
+ '<span class="foot" style="margin-left:12px" id="narrnote"></span></div>'
: '<div class="full">' + esc(narrative) + '</div>');
// Cutting the narrative into shots. Explicit, because it speaks the whole
// piece through ElevenLabs to find real word endings - about a cent, fifteen
// seconds, and it rewrites where every shot begins and ends. This used to
// happen unasked on page load.
// Two jobs that used to be one button, separated because they are not the
// same thing and do not cost the same thing.
//
// Setup shots divides the words. Local Python at the engine's own
// measured speaking rate. Free, instant, no network call.
// Script audio reads it aloud so you can hear it. ElevenLabs, about 16c
// for an 80s piece. Measures; moves nothing.
var vp = r.voice || {};
var voiceLine = vp.formula
? '<p class="foot" style="margin:0 0 14px"><strong>The voice:</strong> '
+ esc(vp.formula) + '.<br>In the finished film: ' + esc(vp.film_voice || '') + '.</p>'
: '';
var setup = !canEditScript ? '' :
'<h2 class="sec" style="margin-top:34px">Setup shots</h2><div class="card">'
+ voiceLine
+ '<p class="foot" style="margin:0 0 14px">Divides the piece into shots at '
+ esc(vp.engine || 'the engine') + '&rsquo;s own measured speaking rate. Runs here in '
+ 'code &mdash; nothing is sent to a language model and nothing is spoken, so it is '
+ 'instant and free. Press it whenever the words change.</p>'
+ '<button type="button" class="btn' + (stale ? '' : ' ghost') + '" id="setupshots">'
+ 'Setup shots</button>'
+ '<span class="foot" style="margin-left:12px" id="setupnote"></span>'
+ '<div id="setupout" style="margin-top:12px"></div>'
+ '<h2 class="sec" style="margin-top:28px">Script audio</h2>'
+ '<p class="foot" style="margin:0 0 14px">'
+ (vp.readthrough_is_standin
? 'Reads the whole piece aloud so you can hear the timing and the jokes. '
+ '<strong>This is ElevenLabs standing in.</strong> ' + esc(vp.engine || 'The engine')
+ ' performs the real thing itself from the written description above and will '
+ 'phrase it differently &mdash; there is no way to hear its real voice without '
+ 'paying to film it.'
: 'Reads the whole piece aloud in the same voice that will be dubbed onto the film.')
// The ElevenLabs rate is 22 dollars per 131,000 characters. A measured
// run came back at 15.89 cents for 946 characters, so quote it from what
// is actually on screen. Deliberately spelled out: a dollar sign before a
// quote is a substitution token to patch_git_file and once shipped a broken
// page - BROWSER_TESTS.md:265 - so none appear in this file's patch text.
+ ' Costs about ' + Math.max(1, Math.round(narrative.length * 22 / 1310))
+ ' cents at this length, and changes none of the words.</p>'
+ '<button type="button" class="btn ghost" id="readaloud">Read it aloud</button>'
+ '<span class="foot" style="margin-left:12px" id="readnote"></span>'
+ '<div id="readout" style="margin-top:14px"></div>'
+ '</div>';
var staleNote = (stale && canEditScript)
? '<div class="warn">The words above have changed since these shots were cut, so this is '
+ 'the previous division. Press Setup shots to cut the new words.</div>' : '';
var cams = '<datalist id="cams">' + PRESETS.map(function (c) {
return '<option value="' + esc(c) + '">'; }).join("") + '</datalist>';
// Post production only exists once there is footage to cut.
// The owner's own photographs, scoped to this video only. Described once,
// then matched to shots by reading the script rather than hand-assigned.
var own = locked ? '' :
'<h2 class="sec" style="margin-top:34px">Your own pictures</h2>'
+ '<div class="card">'
+ '<p class="foot" style="margin:0 0 14px">Paste or drop photographs here. Describe each one, '
+ 'and they can be cut in where the script talks about them, or laid across a join to cover it.</p>'
+ '<div class="drop" id="drop" tabindex="0">Paste an image, drop one here, or click to choose</div>'
+ '<input type="file" id="picker" accept="image/*" multiple style="display:none">'
+ '<div class="pics" id="pics"></div>'
+ '<div style="margin-top:16px">'
+ '<button type="button" class="btn ghost" id="suggest">Suggest where they fit</button>'
+ '<span id="sugnote" class="foot" style="margin-left:12px"></span></div>'
+ '<div id="places"></div>'
+ '</div>';
// Filming, from the browser. This was the last step that needed an operator.
var film = '';
if (p.status === 'approved' || p.status === 'queued_to_film'
|| p.status === 'filming' || p.status === 'film_failed'
|| p.status === 'ready') {
var busy = (p.status === 'queued_to_film' || p.status === 'filming');
// The opening frame is what carries the animal's likeness into the video.
// Without one the model works from the written description and invents a
// plausible stranger, so say so before any money is spent.
var noFrame = (s.segments || []).filter(function (x) {
return x.source !== 'previous' && !x.plate_url;
}).map(function (x) { return x.index; });
film = '<h2 class="sec" style="margin-top:34px">Film it</h2><div class="card">'
+ (noFrame.length && !busy
? '<div class="warn">Shot' + (noFrame.length > 1 ? 's ' : ' ') + noFrame.join(', ')
+ ' still ' + (noFrame.length > 1 ? 'have' : 'has') + ' no opening frame. '
+ 'That frame is what carries your pet&rsquo;s actual likeness — their collar, '
+ 'their markings, their eyes — into the video. '
+ 'One will be drawn automatically before filming, which adds about half a minute '
+ 'per shot. Make them yourself above if you want to see them first.</div>'
: '')
+ '<div class="quote" style="margin-bottom:16px">'
+ '<div><div class="big">' + videoSecondsOf(p) + 's</div>'
+ '<div class="sub">of your time credit</div></div>'
+ '<div class="sub" id="balsec">checking your balance…</div></div>'
+ (p.status === 'film_failed'
? '<div class="warn">That did not finish: ' + esc(p.film_note || 'unknown reason')
+ '. Your seconds were returned.</div>'
: '')
+ (busy
? '<p class="foot" id="filmnote">' + esc(p.film_note || 'Queued…') + '</p>'
: '<button type="button" class="btn" id="film">'
+ (p.status === 'film_failed' ? 'Try filming again'
: (p.status === 'ready' ? 'Film it again' : 'Film it'))
+ '</button>'
+ '<span class="foot" style="margin-left:12px">About a minute a shot. '
+ 'Anything still missing — an opening still, a cutaway — is made '
+ 'automatically first, so nothing above is required.</span>'
+ (p.status === 'ready'
? '<p class="foot" style="margin:10px 0 0">This has already been filmed. '
+ 'Filming again spends the seconds again and replaces the video.</p>'
: ''))
+ '</div>';
}
var post = '';
if (p.status === 'ready' || p.cut_url) {
post = '<h2 class="sec" style="margin-top:34px">The cut</h2><div class="card">'
+ (p.cut_url
? '<video src="' + esc(p.cut_url) + '" controls playsinline style="width:190px;aspect-ratio:9/16;border-radius:10px;border:1px solid var(--line);background:#000;display:block;margin-bottom:14px"></video>'
: '')
+ '<p class="foot" style="margin:0 0 12px">Cutaways are laid across the joins between shots, '
+ 'so the picture changes while the voice keeps going and the join is never on screen.</p>'
+ '<button type="button" class="btn ghost" id="edlopen" style="margin-bottom:16px">Open the video editor</button>'
+ '<div id="edlwrap" style="margin:6px 0 18px;display:none">'
+ '<div class="stage" style="margin-bottom:12px">'
+ '<video id="pvid" playsinline preload="metadata"></video>'
+ '<div class="ovl" id="povl"><img id="povlimg" alt="cutaway"><video id="povlvid" muted playsinline></video></div>'
+ '</div>'
+ '<button type="button" class="btn ghost" id="pplay">&#9654; Preview the cut</button>'
+ '<span class="foot" style="margin-left:12px" id="ptime">0.0 / 0.0s</span>'
+ '<span class="foot" style="margin-left:12px">Nothing is rendered &mdash; this switches the picture as it plays.</span>'
+ '<div class="tlshots" id="tlshots"></div>'
+ '<div class="tl" id="tl"></div>'
+ '<p class="foot" id="tlinfo" style="margin:8px 0 0"></p>'
+ '<div style="margin-top:12px">'
+ '<button type="button" class="btn ghost" id="edlsave" disabled>Save these positions</button>'
+ '<button type="button" class="btn ghost" id="edlreset" style="margin-left:8px">Back to the suggested cut</button>'
+ '</div></div>'
+ '<button type="button" class="btn ghost" id="seams">Measure the joins</button>'
+ '<button type="button" class="btn" id="cut" style="margin-left:10px">'
+ (p.cut_url ? 'Cut it again' : 'Cut the B-roll in') + '</button>'
+ '<div id="seamout" style="margin-top:14px"></div>'
+ '<div id="takes" style="margin-top:20px"></div>'
+ '</div>'
+ '<h2 class="sec" style="margin-top:34px">Send it to someone</h2>'
+ '<div class="card">'
+ '<p class="foot" style="margin:0 0 14px">Texting the video itself puts it through '
+ 'carrier transcoding and it arrives looking worse than it left. Send the picture as '
+ 'the hook and let the link carry the video at full quality.</p>'
+ '<button type="button" class="btn ghost" id="sharebtn">Make the picture</button>'
+ '<span class="foot" style="margin-left:12px" id="sharenote"></span>'
+ '<div id="shareout" style="margin-top:16px"></div>'
+ '</div>';
}
var actions = !canEditScript
? '<div class="card"><p style="margin:0"><strong>These words are settled.</strong> '
+ 'Everything after this arranges how they are shot, so the writing is left alone.</p></div>'
: '<div class="card">'
+ '<h2 class="sec">Change the words</h2>'
+ '<div class="field"><label for="fb">What would you change</label>'
+ '<span class="hint">Be specific. “Too polite, he would be smugger” beats “make it funnier”. '
+ 'To change a word yourself, edit the piece above and press Setup shots.</span>'
+ '<textarea id="fb" placeholder="Shot three is too wordy, and he would never admit the robot is good at anything."></textarea></div>'
+ (WRITERS.length
? '<div class="field"><label for="writer">Who rewrites it</label>'
+ '<span class="hint">Different models are different comedians. The same note sent '
+ 'to each one comes back with different jokes.</span>'
+ '<select id="writer">'
+ WRITERS.map(function (w) {
return '<option value="' + esc(w.id) + '"'
+ (w.id === (p.writer || "grok") ? ' selected' : '') + '>'
+ esc(w.label) + '</option>';
}).join("")
+ '</select></div>'
: '')
+ '<button class="btn ghost" id="revise">Rewrite the words</button>'
+ '<button class="btn" id="approve" style="margin-left:10px">Approve and lock it</button>'
// What the writing has cost so far. Rewrites are no longer capped at
// three - they are billed onto the video at cost, with no markup - so
// the number that matters is the running total, shown where the decision
// to spend another one is actually made.
+ (Number(r.script_usd || 0) > 0
? '<span class="foot" style="margin-left:14px">Writing so far: '
+ Math.round(Number(r.script_usd) * 100) + 'c'
+ (Number(r.last_write_usd || 0) > 0
? ' (last rewrite ' + Math.max(1, Math.round(Number(r.last_write_usd) * 100)) + 'c)'
: '')
+ ' &mdash; added to the bill at cost, not marked up.</span>'
: '<span class="foot" style="margin-left:14px">Rewrites are added to the '
+ 'bill at what they cost, not marked up.</span>')
// "What happens if I approve?" was a fair question with no answer on the
// page. Two lines, next to the button that does it.
+ '<p class="foot" style="margin:14px 0 0"><strong>Approve and lock it</strong> settles '
+ 'the writing: the words, the shot division and the running order stop being editable, '
+ 'and the project moves on to setting up each shot &mdash; the scene, the camera and '
+ 'the opening frame &mdash; and then to filming. Nothing is filmed and nothing is '
+ 'charged for filming at that point. It is the point after which rewriting means '
+ 'starting a new video.</p>'
+ '</div>';
var body;
if (VIEW === 1) {
// The narrative reads first, then what changes it, then what it is cut
// into. The rewrite box used to sit at the very bottom, under eight shot
// cards, which is a long way from the words it rewrites.
body = head + warn + full
+ actions
+ setup
+ '<h2 class="sec" style="margin-top:34px">The words, shot by shot</h2>'
+ staleNote
+ s.segments.map(linesHtml).join("");
} else if (VIEW === 2) {
body = head
+ '<p class="note" style="margin:0 0 22px"><strong>How it will be laid out.</strong> '
+ 'Each shot has a setting, a camera move and an opening frame that carries your '
+ 'pet&rsquo;s likeness. Cutaways sit across the joins so the cut is never on screen. '
+ 'All of it has been chosen for you &mdash; change anything you disagree with, or '
+ 'go straight to filming.</p>'
+ '<h2 class="sec">Hear it first</h2>'
+ '<div class="card" id="pzcard">'
// "A hundredth of a cent" was wrong by three orders of magnitude. A
// measured run of an 80s script came back at 15.89 cents - the
// ElevenLabs rate is 22 dollars per 131,000 characters.
+ '<p class="foot" style="margin:0 0 14px">Filming costs real money; speaking the '
+ 'words costs a few cents. Run it through in your pet&rsquo;s voice '
+ 'over the opening stills first, and only film once the timing and the jokes land.</p>'
+ '<button type="button" class="btn ghost" id="pzmake">Speak the script</button>'
+ '<button type="button" class="btn" id="alignbtn" style="margin-left:8px">Cut it to the clock</button>'
+ '<span class="foot" style="margin-left:12px" id="pznote"></span>'
// It no longer moves anything. Setup shots does the division, locally
// and for nothing, at the performing engine's own measured rate. This
// reads the result back so you can hear it and check the timing.
+ '<p class="foot" style="margin:10px 0 0">This speaks the whole piece once and measures '
+ 'where every word actually falls, so you can check the timing against the shots. '
+ 'It does not move the shot boundaries &mdash; Setup shots, on the words page, does '
+ 'that in code at the engine&rsquo;s own measured rate.</p>'
+ '<div id="alignout" style="margin-top:14px"></div>'
+ '<div id="pzout" style="margin-top:16px"></div>'
+ '</div>'
+ '<h2 class="sec" style="margin-top:34px">The shots</h2>'
+ s.segments.map(function (x) { return shotHtml(x, false); }).join("")
+ own
+ film;
} else if (VIEW === 3) {
body = head + film;
} else {
body = head + post + film;
}
out.innerHTML = cams + stepper(stage, VIEW, reachOf(p.status)) + body;
wire(locked);
}
var PICS = [], SUGGESTED = [];
function renderPics() {
var el = document.getElementById("pics");
if (!el) return;
el.innerHTML = PICS.map(function (p) {
return '<div class="pic">'
+ '<img src="' + esc(p.url) + '" alt="' + esc(p.description || "") + '">'
+ '<input type="text" value="' + esc(p.description || "") + '" readonly>'
+ '<button type="button" class="rm" data-rmpic="' + esc(p.id) + '">remove</button>'
+ '</div>';
}).join("");
[].forEach.call(el.querySelectorAll("[data-rmpic]"), function (b) {
b.onclick = async function () {
var r = await api("delete_project_image", { email: email, image_id: b.getAttribute("data-rmpic") });
if (!r.ok) { showMsg("msg", r.error || "Could not remove that."); return; }
await loadPics();
};
});
}
async function loadPics() {
var r = await api("list_project_images", { email: email, project_id: pid });
PICS = (r.ok && r.images) || [];
renderPics();
}
async function addPic(file) {
hideMsg("msg");
if (!file || !/^image\//.test(file.type)) { showMsg("msg", "That is not an image."); return; }
// Asked for up front: a picture with no description cannot be matched to
// anything, so it would just sit there unused.
var desc = prompt("What is in this picture? A short description is what lets it be matched to the script.");
if (!desc || desc.trim().length < 3) { showMsg("msg", "A description is needed to place the picture."); return; }
var drop = document.getElementById("drop");
if (drop) drop.textContent = "Uploading…";
var img;
try { img = await resizeImage(file, 1600, 0.85); }
catch (e) { showMsg("msg", e.message || "Could not read that image."); return; }
var r = await api("add_project_image", { email: email, project_id: pid,
description: desc.trim(), data_b64: img.b64 });
if (drop) drop.textContent = "Paste an image, drop one here, or click to choose";
if (!r.ok) { showMsg("msg", r.error || "That upload did not work."); return; }
await loadPics();
}
function bindPics() {
var drop = document.getElementById("drop");
if (!drop) return;
var picker = document.getElementById("picker");
drop.onclick = function () { picker.click(); };
picker.onchange = function () {
[].forEach.call(picker.files || [], addPic);
picker.value = "";
};
drop.addEventListener("dragover", function (e) { e.preventDefault(); drop.classList.add("hot"); });
drop.addEventListener("dragleave", function () { drop.classList.remove("hot"); });
drop.addEventListener("drop", function (e) {
e.preventDefault(); drop.classList.remove("hot");
[].forEach.call((e.dataTransfer && e.dataTransfer.files) || [], addPic);
});
// Paste anywhere on the page, which is how people actually do this.
document.addEventListener("paste", function (e) {
var items = (e.clipboardData && e.clipboardData.items) || [];
for (var i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image") === 0) {
var f = items[i].getAsFile();
if (f) { e.preventDefault(); addPic(f); }
return;
}
}
});
var sg = document.getElementById("suggest");
if (sg) sg.onclick = async function () {
hideMsg("msg");
if (!PICS.length) { showMsg("msg", "Add a picture first."); return; }
sg.disabled = true; sg.textContent = "Reading the script…";
var r = await api("suggest_image_placements", { email: email, project_id: pid });
if (!r.ok) { sg.disabled = false; sg.textContent = "Suggest where they fit"; showMsg("msg", r.error || "Could not suggest."); return; }
// Reading the script is a model call, far too slow to answer inside one
// request, so it runs in the background and we wait for it here.
if (r.pending) {
document.getElementById("sugnote").textContent = r.status_text || "Reading the script…";
var waited = 0;
r = await new Promise(function (done) {
var t = setInterval(async function () {
waited += 4;
var g = await api("suggest_image_placements", { email: email, project_id: pid });
if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
if (waited > 150) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
}, 4000);
});
}
sg.disabled = false; sg.textContent = "Suggest where they fit";
document.getElementById("sugnote").textContent = r.status_text || "";
if (!r.ok) { showMsg("msg", r.error || "Could not suggest."); return; }
SUGGESTED = r.placements || [];
var byId = {};
PICS.forEach(function (p) { byId[p.id] = p; });
document.getElementById("places").innerHTML = SUGGESTED.length
? SUGGESTED.map(function (x) {
var p = byId[x.image_id] || {};
return '<div class="place">shot ' + x.index + ' · '
+ esc((p.description || "").slice(0, 60)) + ' — ' + esc(x.why || "") + '</div>';
}).join("")
+ '<button type="button" class="btn" id="applyp" style="margin-top:14px">Use these</button>'
: '<p class="foot">No picture fitted any shot well enough to use.</p>';
var ap = document.getElementById("applyp");
if (ap) ap.onclick = async function () {
ap.disabled = true; ap.textContent = "Applying…";
var res = await api("apply_image_placements", { email: email, project_id: pid,
placements: SUGGESTED });
if (!res.ok) { ap.disabled = false; ap.textContent = "Use these"; showMsg("msg", res.error || "Could not apply."); return; }
showMsg("msg", res.status_text || "Placed.", "ok");
load();
};
};
loadPics();
}
function wire(locked) {
[].forEach.call(document.querySelectorAll("[data-stage]"), function (b) {
b.onclick = function () {
var want = parseInt(b.getAttribute("data-stage"), 10);
if (want === VIEW) return;
VIEW = want;
hideMsg("msg");
render(DATA);
window.scrollTo({ top: 0, behavior: "smooth" });
};
});
bindPics();
bindPreviz();
bindAlign();
bindFilm((DATA && DATA.project) || {});
bindPost();
if (locked) { bindBottom(); return; }
// Save edits when the field loses focus, so typing is never interrupted.
[].forEach.call(document.querySelectorAll("[data-scene],[data-cam],[data-photo]"), function (el) {
var evt = el.tagName === "SELECT" ? "change" : "blur";
el.addEventListener(evt, async function () {
var idx = el.getAttribute("data-scene") || el.getAttribute("data-cam") || el.getAttribute("data-photo");
var body = { email: email, project_id: pid, index: parseInt(idx, 10) };
if (el.hasAttribute("data-scene")) body.scene = el.value;
if (el.hasAttribute("data-cam")) body.camera = el.value;
if (el.hasAttribute("data-photo")) body.photo_id = el.value;
var r = await api("update_shot", body);
if (!r.ok) showMsg("msg", r.error || "That change did not save.");
else hideMsg("msg");
});
});
// Switching source changes which fields apply, so redraw the card after.
[].forEach.call(document.querySelectorAll("[data-srcgrp]"), function (g) {
g.addEventListener("click", async function (e) {
var c = e.target.closest("[data-src]");
if (!c) return;
var idx = parseInt(g.getAttribute("data-srcgrp"), 10);
var r = await api("update_shot", { email: email, project_id: pid, index: idx,
source: c.getAttribute("data-src") });
if (!r.ok) { showMsg("msg", r.error || "Could not change that."); return; }
hideMsg("msg");
load();
});
});
// Throwing a cutaway away. The picture is drawn from the writer's guess at
// what the line is about, so a wrong one is common and there was no way to
// get rid of it - only to redraw and hope.
[].forEach.call(document.querySelectorAll("[data-brollrm]"), function (b) {
b.addEventListener("click", async function () {
hideMsg("msg");
var idx = parseInt(b.getAttribute("data-brollrm"), 10);
b.disabled = true; b.textContent = "Removing…";
var r = await api("update_shot", { email: email, project_id: pid,
index: idx, broll_clear: true });
if (!r.ok) {
b.disabled = false; b.textContent = "Remove cutaway";
showMsg("msg", r.error || "Could not remove that.");
return;
}
showMsg("msg", "Cutaway removed from shot " + idx + ".", "ok");
load();
});
});
[].forEach.call(document.querySelectorAll("[data-broll]"), function (b) {
b.addEventListener("click", async function () {
hideMsg("msg");
var idx = parseInt(b.getAttribute("data-broll"), 10);
b.disabled = true; b.textContent = "Making…";
var r = await api("make_broll", { email: email, project_id: pid, index: idx });
if (!r.ok) { b.disabled = false; b.textContent = "Make cutaway"; showMsg("msg", r.error || "Could not make that."); return; }
var waited = 0;
var t = setInterval(async function () {
waited += 5;
var g = await api("get_project", { email: email, project_id: pid });
var seg = (g.ok && g.script && g.script.segments || []).filter(function (x) {
return parseInt(x.index, 10) === idx; })[0];
if (seg && seg.broll_url) { clearInterval(t); load(); return; }
if (waited > 180) {
clearInterval(t);
b.disabled = false; b.textContent = "Make cutaway";
showMsg("msg", "That cutaway is taking unusually long.");
}
}, 5000);
});
});
[].forEach.call(document.querySelectorAll("[data-make]"), function (b) {
b.addEventListener("click", async function () {
hideMsg("msg");
var idx = parseInt(b.getAttribute("data-make"), 10);
// Flush any pending edits in this card before drawing from them.
var card = b.closest(".shot");
var sc = card.querySelector("[data-scene]"), cm = card.querySelector("[data-cam]"),
ph = card.querySelector("[data-photo]");
await api("update_shot", { email: email, project_id: pid, index: idx,
scene: sc ? sc.value : undefined, camera: cm ? cm.value : undefined,
photo_id: ph ? ph.value : undefined });
b.disabled = true; b.textContent = "Drawing…";
var r = await api("make_shot_image", { email: email, project_id: pid, index: idx });
if (!r.ok) {
b.disabled = false; b.textContent = "Try again";
showMsg("msg", r.error || "Could not make that frame.");
return;
}
// Drawing takes about half a minute and runs on the server, so the call
// above returns straight away. Watch the shot until the frame appears.
var waited = 0;
var tick = setInterval(async function () {
waited += 4;
var g = await api("get_project", { email: email, project_id: pid });
var seg = (g.ok && g.script && g.script.segments || []).filter(function (x) {
return parseInt(x.index, 10) === idx; })[0];
if (seg && seg.draw_error) {
clearInterval(tick);
b.disabled = false; b.textContent = "Try again";
showMsg("msg", "That frame did not draw: " + seg.draw_error);
return;
}
if (seg && seg.plate_url) { clearInterval(tick); load(); return; }
if (waited > 120) {
clearInterval(tick);
b.disabled = false; b.textContent = "Try again";
showMsg("msg", "That frame is taking unusually long. Try again in a moment.");
}
}, 4000);
});
});
bindBottom();
}
async function bindFilm(p) {
var bal = document.getElementById("balsec");
if (bal) {
var b = await api("get_balance", { email: email });
var have = (b.ok && b.balance_seconds) || 0;
var need = videoSecondsOf(p);
bal.textContent = have + "s left" + (have < need ? " — not enough" : "");
if (have < need) bal.style.color = "var(--bad)";
}
// While it films, keep the page honest about where it has got to.
if (p.status === "queued_to_film" || p.status === "filming") {
var t = setInterval(async function () {
var g = await api("get_project", { email: email, project_id: pid });
if (!g.ok) return;
var st = g.project.status;
var note = document.getElementById("filmnote");
if (note) note.textContent = g.project.film_note || st;
if (st === "ready" || st === "film_failed") { clearInterval(t); load(); }
}, 8000);
}
var fb = document.getElementById("film");
if (fb) fb.onclick = async function () {
hideMsg("msg");
fb.disabled = true; fb.textContent = "Starting…";
var r = await api("request_render", { email: email, project_id: pid });
if (!r.ok) {
fb.disabled = false; fb.textContent = "Film it";
showMsg("msg", r.error || "Could not start filming.");
return;
}
showMsg("msg", r.status_text || "Filming.", "ok");
load();
};
}
// ---------------------------------------------------------------- the cut
// EDL is {duration, cuts:[{shot,start,seconds,kind,src,in,moved}], segments}.
// Only the cut positions are editable; the server composes the rest, so the
// page never has to be trusted about source urls or trim points.
var EDL = null, DIRTY = false;
function tlDraw() {
var tl = document.getElementById("tl");
if (!tl || !EDL) return;
var dur = EDL.duration || 1;
// Above the bar: where each shot begins, with the frame it opens on. The end
// of a shot is always the start of the next one, so only starts are marked.
var strip = document.getElementById("tlshots");
if (strip) {
var segs = ((DATA && DATA.script && DATA.script.segments) || []);
strip.innerHTML = segs.map(function (s, k) {
var at = k * 8, wide = (8 / dur) * 100;
return '<div class="tlshot" style="left:' + ((at / dur) * 100).toFixed(3) + '%;width:'
+ wide.toFixed(3) + '%" data-seek="' + at + '" title="shot ' + s.index + ' starts at ' + at + 's">'
+ 'shot ' + s.index
+ (s.plate_url ? '<img src="' + esc(s.plate_url) + '" alt="shot ' + s.index + ' opening frame">' : '')
+ '</div>';
}).join("");
}
var html = "";
(EDL.cuts || []).forEach(function (c, i) {
var left = (c.start / dur) * 100, wide = (c.seconds / dur) * 100;
// Show the actual picture. A plain rectangle says nothing about what is
// going to be on screen, which is the only thing worth judging here.
var thumb = c.kind === "video"
? '<video src="' + esc(c.src) + '#t=' + (c["in"] || 0) + '" preload="metadata" muted></video>'
: '<img src="' + esc(c.src) + '" alt="">';
html += '<div class="tlcut' + (c.moved ? ' moved' : '') + '" data-i="' + i + '" '
+ 'style="left:' + left.toFixed(3) + '%;width:' + wide.toFixed(3) + '%" '
+ 'title="cutaway over the end of shot ' + c.shot + '">'
+ thumb + '<b>' + c.seconds + 's</b>'
+ '<div class="grip" data-grip="' + i + '"></div></div>';
});
html += '<div class="tlhead" id="tlhead" style="left:0"></div>';
html += '<div class="tlruler">';
for (var t = 0; t <= dur; t += 8) {
// The end labels are centred on the track edges, so half of each falls
// outside it. Pin the first left and the last right instead.
var pos = (t / dur) * 100;
var style = t === 0 ? 'left:2px;transform:none'
: (t >= dur ? 'right:2px;left:auto;transform:none'
: 'left:' + pos.toFixed(2) + '%');
html += '<span style="' + style + '">' + t + 's</span>';
}
html += '</div>';
tl.innerHTML = html;
var n = (EDL.cuts || []).length;
document.getElementById("tlinfo").textContent = n
? n + " cutaway" + (n === 1 ? "" : "s") + " over " + dur + " seconds. Drag to move one, or pull its right edge to change how long it runs. The voice underneath never stops."
: "No cutaways on this one, so the talking head runs the whole way.";
document.getElementById("edlsave").disabled = !DIRTY;
}
// Move the picture to a moment without disturbing the edit.
function seekTo(t) {
var v = document.getElementById("pvid");
if (!v || !EDL) return;
v.currentTime = Math.max(0, Math.min(t, EDL.duration || 0));
v.dispatchEvent(new Event("timeupdate"));
}
function tlBind() {
var tl = document.getElementById("tl");
if (!tl) return;
var drag = null;
var strip = document.getElementById("tlshots");
if (strip) strip.addEventListener("click", function (e) {
var s = e.target.closest("[data-seek]");
if (s) seekTo(parseFloat(s.getAttribute("data-seek")));
});
function timeAt(clientX) {
var r = tl.getBoundingClientRect();
return ((clientX - r.left) / (r.width || 1)) * (EDL.duration || 0);
}
tl.addEventListener("pointerdown", function (e) {
var grip = e.target.closest("[data-grip]");
var block = e.target.closest("[data-i]");
if (!block) {
// Empty track: scrub. This is how you get to a particular moment.
drag = { mode: "seek" };
seekTo(timeAt(e.clientX));
tl.setPointerCapture(e.pointerId);
e.preventDefault();
return;
}
var i = parseInt((grip || block).getAttribute(grip ? "data-grip" : "data-i"), 10);
drag = { i: i, mode: grip ? "len" : "move", x0: e.clientX,
start0: EDL.cuts[i].start, secs0: EDL.cuts[i].seconds };
tl.setPointerCapture(e.pointerId);
e.preventDefault();
});
tl.addEventListener("pointermove", function (e) {
if (!drag) return;
if (drag.mode === "seek") { seekTo(timeAt(e.clientX)); return; }
var dur = EDL.duration || 1, w = tl.clientWidth || 1;
var d = ((e.clientX - drag.x0) / w) * dur;
var c = EDL.cuts[drag.i];
if (drag.mode === "move") {
c.start = Math.max(0, Math.min(drag.start0 + d, dur - c.seconds));
} else {
c.seconds = Math.max(0.8, Math.min(drag.secs0 + d, Math.min(20, dur - c.start)));
}
c.start = Math.round(c.start * 20) / 20; // quarter-frame is plenty
c.seconds = Math.round(c.seconds * 20) / 20;
c.moved = true; DIRTY = true;
tlDraw();
});
function end(e) {
if (!drag) return;
drag = null;
try { tl.releasePointerCapture(e.pointerId); } catch (x) {}
}
tl.addEventListener("pointerup", end);
tl.addEventListener("pointercancel", end);
}
// The preview never renders anything. The talking head plays with its audio
// throughout and the picture is covered while a cutaway is due, which is
// exactly what the finished cut does.
function previewBind(p) {
var v = document.getElementById("pvid");
if (!v) return;
if (p.video_url) v.src = p.video_url;
var ovl = document.getElementById("povl"),
oi = document.getElementById("povlimg"),
ov = document.getElementById("povlvid");
function activeCut(t) {
var cs = (EDL && EDL.cuts) || [];
for (var i = 0; i < cs.length; i++) {
if (t >= cs[i].start && t < cs[i].start + cs[i].seconds) return cs[i];
}
return null;
}
function resetPlayButton() {
var b = document.getElementById("pplay");
if (b) b.innerHTML = "&#9654; Preview the cut";
}
// Playing to the end used to leave the button reading "Pause" for good.
v.addEventListener("ended", resetPlayButton);
v.addEventListener("pause", resetPlayButton);
v.addEventListener("play", function () {
var b = document.getElementById("pplay");
if (b) b.innerHTML = "&#9646;&#9646; Pause";
});
var showing = null;
v.addEventListener("timeupdate", function () {
var head = document.getElementById("tlhead");
if (head && EDL) head.style.left = ((v.currentTime / (EDL.duration || 1)) * 100) + "%";
var tm = document.getElementById("ptime");
if (tm && EDL) tm.textContent = v.currentTime.toFixed(1) + " / " + (EDL.duration || 0).toFixed(1) + "s";
var c = activeCut(v.currentTime);
if (c === showing) return;
showing = c;
if (!c) { ovl.style.display = "none"; ov.pause(); return; }
ovl.style.display = "block";
if (c.kind === "video") {
oi.style.display = "none"; ov.style.display = "block";
if (ov.src !== c.src) ov.src = c.src;
try { ov.currentTime = c["in"] || 0; } catch (x) {}
ov.play().catch(function () {});
} else {
ov.style.display = "none"; ov.pause();
oi.style.display = "block"; oi.src = c.src;
}
});
document.getElementById("pplay").onclick = function () {
// Starting again from the end should start again, not sit there.
if (v.ended || v.currentTime >= (EDL.duration || 0) - 0.05) v.currentTime = 0;
if (v.paused) v.play(); else v.pause();
};
}
async function loadEdl(p) {
var r = await api("preview_edl", { email: email, project_id: pid });
if (!r.ok) return;
EDL = r.edl; DIRTY = false;
tlDraw(); tlBind(); previewBind(p);
document.getElementById("edlsave").onclick = async function () {
var b = this; b.disabled = true; b.textContent = "Saving…";
var s = await api("save_edl_cuts", { email: email, project_id: pid,
cuts: (EDL.cuts || []).map(function (c) {
return { shot: c.shot, start: c.start, seconds: c.seconds }; }) });
b.textContent = "Save these positions";
if (!s.ok) { b.disabled = false; showMsg("msg", s.error || "Those positions were refused."); return; }
hideMsg("msg");
EDL = s.edl; DIRTY = false; tlDraw();
showMsg("msg", s.status_text || "Saved.", "ok");
};
document.getElementById("edlreset").onclick = async function () {
var s = await api("reset_edl_cuts", { email: email, project_id: pid });
if (!s.ok) { showMsg("msg", s.error || "Could not reset."); return; }
EDL = s.edl; DIRTY = false; tlDraw();
showMsg("msg", s.status_text || "Back to the suggested cut.", "ok");
};
}
// Every film keeps the take it replaced. Without this, a re-film to fix one
// thing could quietly make another worse with no way back.
async function loadTakes() {
var el = document.getElementById("takes");
if (!el) return;
var r = await api("list_versions", { email: email, project_id: pid });
if (!r.ok || !(r.versions || []).length) { el.innerHTML = ""; return; }
el.innerHTML = '<h3 class="sec" style="font-size:14px;margin:0 0 10px">Earlier takes</h3>'
+ '<p class="foot" style="margin:0 0 12px">' + esc(r.status_text || "") + ' '
+ 'Bringing one back keeps the one you are leaving.</p>'
+ '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px">'
+ r.versions.map(function (v, i) {
return '<div>'
+ '<video src="' + esc(v.cut_url || v.video_url) + '" controls playsinline preload="metadata" '
+ 'style="width:100%;aspect-ratio:9/16;border-radius:9px;border:1px solid var(--line);background:#000;display:block"></video>'
+ '<div class="foot" style="margin-top:6px">' + esc(when(v.at) || "earlier")
+ (v.voice_name ? ' &middot; ' + esc(v.voice_name) : '')
+ (v.cost_usd ? ' &middot; &#36;' + Number(v.cost_usd).toFixed(2) : '') + '</div>'
+ (v.note ? '<div class="foot" style="opacity:.7">' + esc(v.note) + '</div>' : '')
+ '<button type="button" class="btn ghost" data-restore="' + i + '" '
+ 'style="margin-top:8px;width:100%">Use this one</button>'
+ '</div>';
}).join("")
+ '</div>';
[].forEach.call(el.querySelectorAll("[data-restore]"), function (b) {
b.onclick = async function () {
if (!confirm("Bring that take back as the current video? The one you are leaving is kept.")) return;
b.disabled = true; b.textContent = "Restoring…";
var s = await api("restore_version", { email: email, project_id: pid,
index: parseInt(b.getAttribute("data-restore"), 10) });
if (!s.ok) { b.disabled = false; b.textContent = "Use this one"; showMsg("msg", s.error || "Could not restore that."); return; }
showMsg("msg", s.status_text || "Restored.", "ok");
load();
};
});
}
// The run-through: each opening still held for exactly as long as its line
// takes to say. No video is generated, so this costs nothing to watch again.
var PZ = null, PZTIMER = null;
function pzPaint(lines) {
var el = document.getElementById("pzout");
if (!el) return;
var segs = ((DATA && DATA.script && DATA.script.segments) || []);
var byIdx = {};
(lines || []).forEach(function (l) { byIdx[l.index] = l; });
PZ = segs.map(function (s) {
var l = byIdx[s.index] || {};
return { index: s.index, url: l.url || "", seconds: l.seconds || 0,
over: !!l.over, plate: s.plate_url || "", line: s.line || "" };
}).filter(function (x) { return x.url; });
if (!PZ.length) { el.innerHTML = ""; return; }
var total = PZ.reduce(function (a, b) { return a + (b.seconds || 0); }, 0);
var bought = segs.length * 8;
// Spoken here, but filmed by Veo, which was asked to over-enunciate so the
// mouth is readable and therefore runs slower. Measured on a real clip:
// 17 words took 7.02s filmed against about 5.7s spoken, so ~1.25x.
var FILMED = 1.25;
var filmed = total * FILMED;
var over = PZ.filter(function (x) { return (x.seconds || 0) * FILMED > 8.0; });
el.innerHTML = '<div class="stage" style="margin-bottom:12px">'
+ '<img id="pzimg" alt="opening still" style="width:100%;aspect-ratio:9/16;'
+ 'border-radius:10px;background:#000;object-fit:cover;display:block">'
+ '</div>'
+ '<audio id="pzaud"></audio>'
+ '<button type="button" class="btn" id="pzplay">&#9654; Play the run-through</button>'
+ '<span class="foot" style="margin-left:12px" id="pzwhere">'
+ PZ.length + ' lines &middot; ' + total.toFixed(1) + 's spoken, about '
+ filmed.toFixed(1) + 's once filmed, against ' + bought + 's of shots</span>'
+ (filmed > bought
? '<div class="warn" style="margin-top:12px">Filmed, this would run about '
+ filmed.toFixed(1) + ' seconds but only ' + bought + ' seconds are being '
+ 'shot. Something will be cut off. Shorten the words, or buy more length.</div>'
: (filmed < bought * 0.88
? '<div class="warn" style="margin-top:12px">Filmed, this would run about '
+ filmed.toFixed(1) + ' seconds across ' + bought + ' seconds of video, '
+ 'leaving roughly ' + ((bought - filmed) / PZ.length).toFixed(1) + ' seconds '
+ 'of silence at the end of every shot. That is where trailing sound effects '
+ 'and echo come from: the model is handed a gap and fills it. More words per '
+ 'shot is the fix.</div>'
: ''))
+ (over.length
? '<div class="warn" style="margin-top:12px">Shot' + (over.length > 1 ? 's ' : ' ')
+ over.map(function (x) { return x.index; }).join(", ")
+ ' will not fit in eight seconds once filmed: '
+ over.map(function (x) { return x.index + " would run " + (x.seconds * FILMED).toFixed(1) + "s"; }).join(", ")
+ '. Trim those lines before filming.</div>'
: '')
+ '<div style="margin-top:14px">'
+ PZ.map(function (x) {
var f = (x.seconds || 0) * FILMED;
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + x.index + ' &middot; ' + x.seconds.toFixed(1) + 's spoken, ~'
+ f.toFixed(1) + 's filmed'
+ (f > 8 ? ' <span style="color:var(--accent2)">over</span>' : '')
+ ' &middot; ' + esc(x.line.slice(0, 60)) + '</div>';
}).join("")
+ '</div>';
var img = document.getElementById("pzimg"),
aud = document.getElementById("pzaud"),
btn = document.getElementById("pzplay"),
where = document.getElementById("pzwhere");
function stop() {
aud.pause();
if (PZTIMER) { clearTimeout(PZTIMER); PZTIMER = null; }
btn.innerHTML = "&#9654; Play the run-through";
}
function playFrom(i) {
if (i >= PZ.length) { stop(); return; }
var x = PZ[i];
img.src = x.plate || "";
where.textContent = "shot " + x.index + " of " + PZ.length + " · " + x.seconds.toFixed(1) + "s";
aud.src = x.url;
aud.onended = function () { playFrom(i + 1); };
aud.play().catch(function () { stop(); });
}
btn.onclick = function () {
if (!aud.paused) { stop(); return; }
btn.innerHTML = "&#9646;&#9646; Stop";
playFrom(0);
};
}
function bindAlign() {
var b = document.getElementById("alignbtn");
if (!b) return;
var out = document.getElementById("alignout");
function paint(r) {
// The shot length belongs to the engine. Hardcoding 8 reported every Omni
// shot 2s short and flagged well-fitting lines as overruns.
var each = shotSecondsOf((DATA && DATA.project) || {});
var bought = ((DATA && DATA.script && DATA.script.segments) || []).length * each;
var filmed = r.filmed || 0;
out.innerHTML = '<p class="foot" style="margin:0 0 8px">Measured: '
+ (r.spoken || 0).toFixed(1) + 's spoken, about ' + filmed.toFixed(1)
+ 's filmed, against ' + bought + 's of shots.</p>'
+ (r.audio ? '<audio controls src="' + esc(r.audio) + '" style="width:100%;max-width:420px"></audio>' : '')
+ (r.shots || []).map(function (s) {
// Under the clock is FINE. The engine finishes the line and waits -
// measured, Omni "barely stretches, it finishes early". Only an
// OVERRUN is a problem, because that is cut off mid-word.
//
// This used to flag anything more than 1.2s from the clip length in
// either direction, so a 7.0s line in a 10s shot was marked exactly
// like a genuine 11.4s overrun, and sent people rewriting lines that
// fitted perfectly well.
var over = (s.filmed || 0) > each + 0.3;
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + s.index + ' &middot; ' + (s.start || 0).toFixed(1) + '-'
+ (s.end || 0).toFixed(1) + 's spoken &middot; ~' + (s.filmed || 0).toFixed(1) + 's filmed'
+ (over ? ' <span style="color:var(--accent2)">runs long</span>' : '')
+ '<br>' + esc(s.line || "") + '</div>';
}).join("")
+ (filmed < bought - 1.5
? '<div class="warn" style="margin-top:12px">The whole piece only runs about '
+ filmed.toFixed(1) + ' seconds filmed, but ' + bought + ' seconds are being shot. '
+ 'No arrangement of these words fills the video &mdash; it needs more written, or a '
+ 'shorter length.</div>'
: '')
+ (filmed > bought + 1.5
? '<div class="warn" style="margin-top:12px">The whole piece runs about '
+ filmed.toFixed(1) + ' seconds filmed against ' + bought + ' seconds of video, so the '
+ 'end would be cut off. Trim it, or buy more length.</div>'
: '');
}
b.onclick = async function () {
hideMsg("msg");
b.disabled = true; b.textContent = "Measuring…";
var r = await api("align_script", { email: email, project_id: pid, refresh: true });
if (r.ok && r.pending) {
var waited = 0;
r = await new Promise(function (done) {
var t = setInterval(async function () {
waited += 4;
var g = await api("align_script", { email: email, project_id: pid });
if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
if (waited > 240) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
}, 4000);
});
}
b.disabled = false; b.textContent = "Cut it to the clock";
if (!r.ok) { showMsg("msg", r.error || "Could not measure that."); return; }
if (r.note) { showMsg("msg", r.note); return; }
paint(r);
showMsg("msg", "Shots re-cut at real word endings.", "ok");
load();
};
// Read the cached measurement only. This used to call without `refresh`,
// which still STARTS a run when nothing is cached - so merely opening the
// page spoke the script and moved every shot boundary, unasked and charged.
if (DATA && DATA.project && DATA.project.align_url) {
api("align_script", { email: email, project_id: pid }).then(function (r) {
if (r && r.ok && !r.pending && r.audio) paint(r);
});
}
}
function bindPreviz() {
var b = document.getElementById("pzmake");
if (!b) return;
var note = document.getElementById("pznote");
async function poll() {
var waited = 0;
return await new Promise(function (done) {
var t = setInterval(async function () {
waited += 4;
var g = await api("make_previz", { email: email, project_id: pid });
if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
if (g.ok && g.pending && note) note.textContent = g.status_text || "";
if (waited > 240) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
}, 4000);
});
}
b.onclick = async function () {
hideMsg("msg");
b.disabled = true; b.textContent = "Speaking…";
var r = await api("make_previz", { email: email, project_id: pid });
if (r.ok && r.pending) { note.textContent = r.status_text || ""; r = await poll(); }
b.disabled = false; b.textContent = "Speak it again";
if (!r.ok) { note.textContent = ""; showMsg("msg", r.error || "Could not speak that."); return; }
note.textContent = r.status_text || "";
pzPaint(r.lines || []);
};
// Already spoken? Show it without asking again.
api("make_previz", { email: email, project_id: pid }).then(function (r) {
if (r && r.ok && !r.pending && (r.lines || []).length) {
b.textContent = "Speak it again";
note.textContent = r.status_text || "";
pzPaint(r.lines);
}
});
}
// The picture is the hook; the link carries the video. Never the video itself
// over MMS - carriers transcode it into mush.
function bindShare() {
var b = document.getElementById("sharebtn");
if (!b) return;
var note = document.getElementById("sharenote"),
out = document.getElementById("shareout");
// The file URL ends in /public with no filename. The headers are already
// correct (video/mp4, filename=final.mp4) so anything that reads them is
// fine, but clients that sniff the extension from the URL string are not.
// A filename query is accepted and ignored by the file service.
// Filled in by share_link, which owns the address. A link straight to the
// file would be invisible to us; this one is counted server side.
var SHARE_LINK = "";
function named(url) { return SHARE_LINK || ""; }
function paint(r) {
var kb = Math.round((r.bytes || 0) / 1024);
var link = named(r.video || "");
var msg = ((DATA && DATA.script && DATA.script.title)
? DATA.script.title + " — " : "")
+ ((DATA && DATA.project && DATA.project.pet_name) || "your pet")
+ " has something to say. " + link;
out.innerHTML = '<div style="display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start">'
+ '<img src="' + esc(r.image) + '" alt="share picture" '
+ 'style="width:190px;border-radius:10px;border:1px solid var(--line);display:block">'
+ '<div style="flex:1 1 260px">'
+ '<p class="foot" style="margin:0 0 10px">' + esc(r.size || "") + ' &middot; ' + kb + ' KB'
+ ' &middot; well inside the 600KB most carriers allow, so it will not be '
+ 'downsampled.</p>'
+ '<label class="mini">The message</label>'
+ '<textarea id="sharemsg" style="min-height:70px">' + esc(msg) + '</textarea>'
+ '<button type="button" class="btn" id="sharelink" style="margin-top:8px">Share the video</button>'
+ '<button type="button" class="btn ghost" id="sharecopy" style="margin-left:8px;margin-top:8px">Copy the message</button>'
+ '<a class="btn ghost" style="margin-left:8px;display:inline-block;text-decoration:none" '
+ 'href="' + esc(r.image) + '" download>Download the picture</a>'
+ '<p class="foot" style="margin:12px 0 0;word-break:break-all">' + esc(link)
+ '<span id="viewcount"></span></p>'
+ '</div></div>';
api("share_link", { email: email, project_id: pid }).then(function (s) {
if (!(s && s.ok)) return;
SHARE_LINK = s.url || "";
var el = document.getElementById("viewcount");
if (el) {
el.innerHTML = '<br>' + esc(SHARE_LINK) + '<br>Opened ' + (s.views || 0)
+ ' time' + ((s.views === 1) ? '' : 's') + '.';
}
var t = document.getElementById("sharemsg");
if (t && SHARE_LINK) t.value = t.value.replace(/https?:\/\/\S+/, SHARE_LINK);
});
var sl = document.getElementById("sharelink");
if (sl) sl.onclick = async function () {
var title = (DATA && DATA.script && DATA.script.title) || "A video";
// The native sheet is the shortest path into a text message on a phone.
if (navigator.share) {
try { await navigator.share({ title: title, text: msg, url: link }); return; }
catch (e) { if (e && e.name === "AbortError") return; }
}
try {
await navigator.clipboard.writeText(link);
sl.textContent = "Link copied";
setTimeout(function () { sl.textContent = "Share the video"; }, 1600);
} catch (e) { showMsg("msg", "Copy this: " + link); }
};
var c = document.getElementById("sharecopy");
if (c) c.onclick = function () {
var t = document.getElementById("sharemsg");
t.select();
navigator.clipboard.writeText(t.value).then(function () {
c.textContent = "Copied";
setTimeout(function () { c.textContent = "Copy the message"; }, 1600);
}).catch(function () { c.textContent = "Select it and copy"; });
};
}
b.onclick = async function () {
hideMsg("msg");
b.disabled = true; b.textContent = "Choosing a frame…";
var r = await api("share_image", { email: email, project_id: pid, refresh: true });
if (r.ok && r.pending) {
var waited = 0;
r = await new Promise(function (done) {
var t = setInterval(async function () {
waited += 4;
var g = await api("share_image", { email: email, project_id: pid });
if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
if (waited > 180) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
}, 4000);
});
}
b.disabled = false; b.textContent = "Pick another frame";
if (!r.ok) { showMsg("msg", r.error || "Could not make that picture."); return; }
if (r.note) { showMsg("msg", r.note); return; }
note.textContent = r.status_text || "";
paint(r);
};
api("share_image", { email: email, project_id: pid }).then(function (r) {
if (r && r.ok && !r.pending && r.image) {
b.textContent = "Pick another frame";
note.textContent = r.status_text || "";
paint(r);
}
});
}
function bindPost() {
loadTakes();
bindShare();
// The editing bay is opt-in. Most people want to watch the thing and leave.
var eo = document.getElementById("edlopen");
if (eo) eo.onclick = function () {
var w = document.getElementById("edlwrap");
var open = w.style.display === "none";
w.style.display = open ? "" : "none";
this.textContent = open ? "Close the video editor" : "Open the video editor";
if (open) {
if (!EDL) loadEdl((DATA && DATA.project) || {});
else tlDraw();
w.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
var sb = document.getElementById("seams");
function paintSeams(r) {
var el = document.getElementById("seamout");
if (!el) return;
el.innerHTML = '<p class="foot" style="margin:0 0 8px">' + esc(r.status_text || "") + '</p>'
+ (r.seams || []).map(function (x) {
var col = x.verdict === "smooth" ? "var(--good)"
: (x.verdict === "visible" ? "var(--accent2)" : "var(--bad)");
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + x.after_shot + ' → ' + x.before_shot + ' at ' + x.at_seconds + 's · '
+ 'shift ' + x.shift_x + ',' + x.shift_y + ' · drift ' + x.drift + ' · '
+ '<span style="color:' + col + '">' + esc(x.verdict) + '</span>'
+ (x.recommend_broll ? ' · worth a cutaway' : '')
+ '</div>';
}).join("");
}
if (sb) sb.onclick = async function () {
sb.disabled = true; sb.textContent = "Measuring…";
var el = document.getElementById("seamout");
var r = await api("seam_report", { email: email, project_id: pid });
if (!r.ok) {
sb.disabled = false; sb.textContent = "Measure the joins";
showMsg("msg", r.error || "Could not measure.");
return;
}
// Measuring downloads every shot and searches each join for the best
// alignment, which is far too slow to answer inside one request.
if (r.pending) {
el.innerHTML = '<p class="foot">' + esc(r.status_text || "Measuring…") + '</p>';
var waited = 0;
var t = setInterval(async function () {
waited += 4;
var g = await api("seam_report", { email: email, project_id: pid });
if (g.ok && !g.pending) {
clearInterval(t);
sb.disabled = false; sb.textContent = "Measure the joins";
paintSeams(g);
return;
}
if (waited > 180) {
clearInterval(t);
sb.disabled = false; sb.textContent = "Measure the joins";
showMsg("msg", "Measuring is taking unusually long.");
}
}, 4000);
return;
}
sb.disabled = false; sb.textContent = "Measure the joins";
if (!r.seams || !r.seams.length) {
el.innerHTML = '<p class="foot">' + esc(r.status_text || "Nothing to measure.") + '</p>';
return;
}
el.innerHTML = '<p class="foot" style="margin:0 0 8px">' + esc(r.status_text || "") + '</p>'
+ r.seams.map(function (x) {
var col = x.verdict === "smooth" ? "var(--good)"
: (x.verdict === "visible" ? "var(--accent2)" : "var(--bad)");
return '<div class="foot" style="padding:6px 0;border-top:1px solid var(--line)">'
+ 'shot ' + x.after_shot + ' → ' + x.before_shot + ' at ' + x.at_seconds + 's · '
+ 'shift ' + x.shift_x + ',' + x.shift_y + ' · drift ' + x.drift + ' · '
+ '<span style="color:' + col + '">' + esc(x.verdict) + '</span>'
+ (x.recommend_broll ? ' · worth a cutaway' : '')
+ '</div>';
}).join("");
};
var cb = document.getElementById("cut");
if (cb) cb.onclick = async function () {
hideMsg("msg");
cb.disabled = true; cb.textContent = "Cutting…";
var r = await api("render_cut", { email: email, project_id: pid });
if (!r.ok) { cb.disabled = false; cb.textContent = "Cut the B-roll in"; showMsg("msg", r.error || "Could not cut."); return; }
var waited = 0;
var t = setInterval(async function () {
waited += 5;
var g = await api("get_project", { email: email, project_id: pid });
if (g.ok && g.project && g.project.cut_url) { clearInterval(t); load(); return; }
if (waited > 300) {
clearInterval(t);
cb.disabled = false; cb.textContent = "Cut the B-roll in";
showMsg("msg", "That is taking unusually long. Try again shortly.");
}
}, 5000);
};
}
function bindBottom() {
// Saving the narrative does NOT re-cut the shots: cutting costs money and
// takes fifteen seconds, so it is a button of its own.
var sn = document.getElementById("savenarr");
if (sn) sn.onclick = async function () {
hideMsg("msg");
var txt = (document.getElementById("narr") || {}).value || "";
if (txt.trim().length < 10) { showMsg("msg", "There is no script there to save."); return; }
sn.disabled = true; sn.textContent = "Saving…";
var r = await api("set_narrative", { email: email, project_id: pid, text: txt });
sn.disabled = false; sn.textContent = "Save the words";
if (!r.ok) { showMsg("msg", r.error || "That did not save."); return; }
showMsg("msg", r.status_text || "Saved.", "ok");
load();
};
// Flush an unsaved edit before either button acts, so both work on the
// words that are on screen rather than the words on file.
async function flushNarrative() {
var box = document.getElementById("narr");
if (box && box.value.trim().length >= 10) {
await api("set_narrative", { email: email, project_id: pid, text: box.value });
}
}
var su = document.getElementById("setupshots");
if (su) su.onclick = async function () {
hideMsg("msg");
su.disabled = true; su.textContent = "Dividing…";
await flushNarrative();
var r = await api("setup_shots", { email: email, project_id: pid });
su.disabled = false; su.textContent = "Setup shots";
if (!r.ok) { showMsg("msg", r.error || "Could not divide that."); return; }
// Notes are the splitter telling you the script is the wrong length for
// the shots bought. That is a writing problem and only you can fix it, so
// it is shown rather than swallowed.
var out = document.getElementById("setupout");
if (out) {
out.innerHTML = (r.notes || []).map(function (t) {
return '<div class="warn" style="margin:0 0 10px">' + esc(t) + '</div>'; }).join("");
}
showMsg("msg", r.status_text || "Divided into shots.", "ok");
load();
};
// Reading it aloud. Separate button, separate money, and it moves nothing.
var ra = document.getElementById("readaloud");
if (ra) ra.onclick = async function () {
hideMsg("msg");
ra.disabled = true; ra.textContent = "Speaking it…";
await flushNarrative();
var r = await api("align_script", { email: email, project_id: pid, refresh: true });
if (r.ok && r.pending) {
var waited = 0;
r = await new Promise(function (done) {
var t = setInterval(async function () {
waited += 4;
var g = await api("align_script", { email: email, project_id: pid });
if (g.ok && !g.pending) { clearInterval(t); done(g); return; }
if (waited > 240) { clearInterval(t); done({ ok: false, error: "That is taking unusually long." }); }
}, 4000);
});
}
ra.disabled = false; ra.textContent = "Read it aloud";
if (!r.ok) { showMsg("msg", r.error || "Could not read that."); return; }
if (r.note) { showMsg("msg", r.note); return; }
// THE PLAYER. This is what was missing: the audio was made and uploaded
// and then rendered into an element that only exists on the Layout view,
// so pressing the button produced fifteen seconds of nothing.
var out = document.getElementById("readout");
if (out) {
out.innerHTML =
(r.audio ? '<audio controls src="' + esc(r.audio) + '" style="width:100%;max-width:460px"></audio>' : '')
+ '<p class="foot" style="margin:10px 0 0">' + (r.spoken || 0).toFixed(1)
+ 's spoken by ElevenLabs. The shot division above is unchanged &mdash; this is a '
+ 'read-through, not a re-cut.</p>'
+ (r.shots || []).map(function (s) {
return '<div class="foot" style="padding:5px 0;border-top:1px solid var(--line)">'
+ 'Shot ' + s.index + ' &middot; ' + (s.start || 0).toFixed(1) + 's to '
+ (s.end || 0).toFixed(1) + 's</div>'; }).join("");
}
showMsg("msg", "Read through in " + (r.spoken || 0).toFixed(1) + " seconds.", "ok");
};
var rv = document.getElementById("revise");
if (rv) rv.onclick = async function () {
hideMsg("msg");
var fb = document.getElementById("fb").value.trim();
if (fb.length < 5) { showMsg("msg", "Tell us what you would like changed."); return; }
rv.disabled = true; rv.textContent = "Sending…";
var wsel = document.getElementById("writer");
var res = await api("revise_script", {
email: email, project_id: pid, feedback: fb,
writer: (wsel && wsel.value) || ""
});
if (!res.ok) { rv.disabled = false; rv.textContent = "Rewrite the words"; showMsg("msg", res.error || "That did not go through."); return; }
showMsg("msg", "Rewriting now…", "ok");
load();
};
var ap = document.getElementById("approve");
if (ap) ap.onclick = async function () {
hideMsg("msg");
ap.disabled = true; ap.textContent = "Approving…";
var res = await api("approve_script", { email: email, project_id: pid });
if (!res.ok) { ap.disabled = false; ap.textContent = "Approve and lock it"; showMsg("msg", res.error || "That did not go through."); return; }
showMsg("msg", res.status_text || "Approved.", "ok");
load();
};
}
async function load() {
if (!pid || !email) return;
if (!PRESETS.length) {
var c = await api("camera_presets", {});
if (c.ok) {
PRESETS = c.presets || []; MODELS = c.models || [];
WRITERS = c.writers || [];
}
}
var r = await api("get_project", { email: email, project_id: pid });
if (!r.ok) { showMsg("msg", r.error || "Could not load that script."); return; }
if (!PHOTOS.length && r.project && r.project.pet_id) {
var ph = await api("list_photos", { email: email, pet_id: r.project.pet_id });
if (ph.ok) PHOTOS = ph.photos || [];
}
render(r);
}
load();
</script>
</body>
</html>