paytolaugh/script.html
gator a24f8182f7 Remove the spliced duplicate, part 1 of 4
The page has carried a commented-out copy of its own tail since a bad patch
spliced it in. It is inert, but 66KB of duplicated source in a file that gets
patched regularly is a hazard worth removing. Deleted in parts because the
patch API can only remove text by quoting all of it.
2026-09-15 03:55:51 +00:00

3021 lines
144 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);
// Clamp to what the stepper actually allows, not to the stage. These differ
// for an approved project: it sits at stage 2 but must be able to reach the
// Filming panel, which is where the button to start filming lives. Clamping
// to stage undid the click on every redraw.
var reach = reachOf(p.status);
if (VIEW === null || VIEW > reach) VIEW = Math.min(stage, reach);
// 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>' : "";
// The price as the server computes it NOW. price_usd is stamped on the row
// when the project is created, so it goes stale the moment the markup
// constant changes - which it just did, from 5x to actual cost. The stored
// value is only a fallback for a response that predates the fresh quote.
var price = (r.quote && typeof r.quote.price_usd === 'number')
? r.quote.price_usd : Number(p.price_usd || 0);
var head = '<div class="quote" style="margin-bottom:22px">'
// The currency sign is the HTML entity, never the character. A dollar sign
// next to a quote is a substitution token to patch_git_file, and a $
// escape is no safer - it is decoded first and springs the same trap.
+ '<div><div class="big">&#36;' + Number(price).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>
<!-- Everything below is a duplicate of this page, spliced in by a bad patch:
a dollar sign before a quote is a substitution token to patch_git_file.
It is commented out to the end of the file. The live copy is ABOVE.
// 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>