Add the button that runs the picture check

Separate from filming on purpose: a re-film decision wants the audio and
picture verdicts together, and an existing film can be checked without
paying to shoot it again.
This commit is contained in:
gator 2026-09-17 20:57:20 +00:00
parent 5eb4aac2cc
commit ac4ece69a5

View file

@ -211,7 +211,11 @@
}
function tick() {
var busy = DRAFTS.some(busyDraft) || busyRun() || busyShots() || busyReel();
var es = String(PKG.eyes_state || "");
var busyEyes = es && es !== "ready" && !/^failed/.test(es)
&& (Date.now() - (Date.parse(PKG.eyes_started || "") || 0)) < 20 * 60 * 1000;
var busy = DRAFTS.some(busyDraft) || busyRun() || busyShots() || busyReel()
|| busyEyes;
if (busy && !timer) timer = setInterval(refresh, 5000);
if (!busy && timer) { clearInterval(timer); timer = null; }
}
@ -407,6 +411,730 @@
: 'Revise the whole script') + '</button>');
}
if (filmed) {
var es = String(PKG.eyes_state || "");
var looking = es && es !== "ready" && !/^failed/.test(es)
&& (Date.now() - (Date.parse(PKG.eyes_started || "") || 0)) < 20 * 60 * 1000;
if (looking)
out.push('<button type="button" class="btn" disabled>Looking at the '
+ 'footage… ' + esc(es) + '</button>');
else
out.push('<button type="button" class="btn" data-eyes="' + esc(meta.id)
+ '" title="Samples frames from every clip and asks what is wrong '
+ 'with the picture, then whether it matches the script.">'
+ (es === "ready" ? "Check the picture again" : "Check the picture")
+ ' &middot;
out.push('<button type="button" class="btn primary" data-film="' + esc(meta.id)
+ '">Film ' + (filmed ? 'the ' + unfilmed + ' remaining shots' : 'all ' + shots.length + ' shots')
+ ' &middot; $' + (unfilmed * 0.75).toFixed(2) + '</button>');
if (filmed)
out.push('<button type="button" class="btn primary" data-assemble="' + esc(meta.id)
+ '">Make the trailer &middot; ' + filmed + ' of ' + shots.length + ' shots</button>');
}
return out.join("");
}
// A cut may move shots as well as drop them, which makes it a different
// film rather than a shorter one. Worth saying out loud.
function reordered(ids) {
var a = (ids || []).map(String);
var b = a.slice().sort();
return a.join("") !== b.join("");
}
// What the storyboard will actually cost to draw. Three sheets for every
// character who is seen or heard, one more per extra day they are dressed
// differently, and two frames per shot.
function boardImages(shots, cast) {
var seen = {};
shots.forEach(function (s) {
(s.visible_characters || []).forEach(function (c) { seen[String(c)] = 1; });
(s.audio || []).forEach(function (a) {
if (String(a.text || "").trim()) seen[String(a.character_id)] = 1; });
});
var present = cast.filter(function (c) { return seen[String(c.id)]; });
var days = {};
shots.forEach(function (s) {
var d = String(s.day || "").trim();
if (!d) return;
(s.visible_characters || []).forEach(function (c) {
(days[String(c)] = days[String(c)] || {})[d] = 1; });
});
var extra = 0;
present.forEach(function (c) {
var wd = c.wardrobe_by_day || {};
var base = Object.keys(wd).sort()[0] || "";
Object.keys(days[String(c.id)] || {}).forEach(function (d) {
if (d !== base && wd[d]) extra += 1; });
});
return present.length * 3 + extra + shots.length * 2;
}
function mmss(n) {
return Math.floor(n / 60) + ":" + String(Math.round(n % 60)).padStart(2, "0");
}
// The header's runtime is the sum of what the writer typed. This is what the
// lines take when the cast actually says them, which is a different number
// and the only one worth cutting against.
function timingHtml() {
var t = PKG.timing;
if (!t || !t.measured_seconds) return "";
var over = Number(t.over_by || 0);
var line = "Spoken by the cast this script runs <b>" + mmss(t.measured_seconds)
+ "</b>, against a " + mmss(t.target_seconds) + " target";
if (over > 5) line += " — <b>" + Math.round(over) + " seconds over</b>."
+ " Revising the whole script will cut that much.";
else if (over < -15) line += " — " + Math.round(-over) + " seconds short.";
else line += " — that fits.";
var worst = (t.longest_lines || []).slice(0, 3).map(function (l) {
return "<li>" + Number(l.seconds || 0).toFixed(1) + "s &middot; shot "
+ esc(String(l.shot)) + " &middot; “"
+ esc(String(l.text || "").slice(0, 80)) + "”</li>"; }).join("");
return '<div class="timing' + (over > 5 ? " over" : "") + '"><div>' + line + "</div>"
+ (worst && over > 5 ? "<ul>" + worst + "</ul>" : "") + "</div>";
}
// A job lives in the worker's memory and dies with it. Every deploy kills
// one, and the flag it set stays set, so anything older than this is treated
// as gone rather than leaving the page waiting on it for ever.
function busyCuts() {
if (String(PKG.cuts_state || "") !== "thinking") return false;
var t = Date.parse(PKG.cuts_started || "");
if (!t) return false;
return (Date.now() - t) < 15 * 60 * 1000;
}
// ---- the storyboard player -------------------------------------------
// One call fetches the whole reel; after that nothing touches the network,
// which is what makes stop and jump-to instant.
var REEL = null, RAT = -1, RTIMER = null;
var reelAudio = new Audio();
function reelHtml() {
var st = String(PKG.reel_state || "");
var shots = (PKG.shots || []);
if (!shots.length) return "";
var running = st && st !== "ready" && !/^failed/.test(st);
var out = '<div class="reel"><h4>Watch the storyboard</h4>'
+ '<p class="lede">The frames in order with the lines spoken over them — '
+ 'the trailer before it is filmed. Sequence problems are obvious here '
+ 'and nearly invisible in the script below.</p>';
if (running)
out += '<p class="busy">Speaking every shot… ' + esc(st) + '</p>';
else if (st === "ready") {
var spoken = shots.filter(function (s) { return s.reel_url; }).length;
var silent = shots.length - spoken;
out += '<p class="lede">' + spoken + ' shots have dialogue and are spoken'
+ (silent ? '; ' + silent + ' are silent and hold on the frame' : '')
+ '.</p>';
}
else if (/^failed/.test(st))
out += '<p class="cont">' + esc(st) + '</p>';
else if (st)
out += '<p class="cont">The last attempt stopped before it finished — '
+ 'the server restarted under it. Ask again.</p>';
if (!REEL) {
out += '<div class="tools" style="justify-content:center">'
+ '<button type="button" class="btn primary" data-reelgo="1"'
+ (running ? ' disabled' : '') + '>'
+ (st === "ready" ? "Play the storyboard" : "Prepare and play")
+ '</button></div></div>';
return out;
}
var s = REEL[RAT] || REEL[0] || {};
var lines = (s.lines || []).map(function (l) {
return '<div><span class="who">' + esc(l.who || "")
+ (l.lip_sync ? "" : " (v.o.)") + '</span>' + esc(l.text || "") + '</div>';
}).join("") || '<div class="w">no dialogue in this shot</div>';
out += '<div class="reelframes">'
+ '<figure><img id="rfa" class="on" src="' + esc(s.opening_url || "")
+ '" alt=""><figcaption>opens on</figcaption></figure>'
+ '<figure><img id="rfb" src="' + esc(s.closing_url || "")
+ '" alt=""><figcaption>ends on</figcaption></figure></div>'
+ '<div class="reelnow"><div class="t">' + esc(s.id) + ' · '
+ esc(s.title || "") + '</div>'
+ '<div class="w">' + esc(s.setting || "")
+ (s.scene ? ' · ' + esc(s.scene) : "")
+ (s.day ? ' · ' + esc(s.day) : "") + '</div>'
+ '<div class="said">' + lines + '</div></div>'
+ '<div class="reelprog"><i id="rprog"></i></div>'
+ '<div class="reelbar">'
+ '<button type="button" class="btn" data-reelstep="-1">◀ back</button>'
+ '<button type="button" class="btn primary" data-reeltoggle="1">'
+ (reelAudio.paused ? "Play" : "Pause") + '</button>'
+ '<button type="button" class="btn" data-reelstep="1">next ▶</button>'
+ '<button type="button" class="btn" data-reelstop="1">Stop</button>'
+ '<button type="button" class="btn" data-reeljump="' + esc(s.id)
+ '">Jump to this shot</button>'
+ '<span class="pos">' + (RAT + 1) + ' of ' + REEL.length + '</span>'
+ '</div></div>';
return out;
}
function reelPaint() {
var host = document.getElementById("reelhost");
if (host) host.innerHTML = reelHtml();
}
// The two frames split the shot's running time: the opening frame holds for
// the first half, the closing frame for the second. It is a storyboard, not
// an animation, but it reads as movement and it marks where the shot lands.
function reelShow(i) {
if (!REEL || i < 0 || i >= REEL.length) { reelStop(); return; }
RAT = i;
reelPaint();
var s = REEL[i];
var dur = Math.max(Number(s.seconds) || 0, Number(s.edit_seconds) || 0, 2.5);
if (s.audio_url) { reelAudio.src = s.audio_url; reelAudio.play().catch(function(){}); }
else reelAudio.pause();
var t0 = Date.now(), swapped = false;
clearInterval(RTIMER);
RTIMER = setInterval(function () {
var gone = (Date.now() - t0) / 1000;
var bar = document.getElementById("rprog");
if (bar) bar.style.width = Math.min(100, 100 * gone / dur) + "%";
if (!swapped && gone > dur / 2) {
swapped = true;
var a = document.getElementById("rfa"), b = document.getElementById("rfb");
if (a && b && REEL[RAT].closing_url) { a.classList.remove("on"); b.classList.add("on"); }
}
if (gone >= dur) reelShow(RAT + 1);
}, 120);
}
function reelStop() {
clearInterval(RTIMER); RTIMER = null;
reelAudio.pause();
REEL = null; RAT = -1;
openTab(CUR);
}
// A cut is an ordering, not a render. Playing one is free, so all of them
// can be looked at before anything is joined into a file.
function cutsHtml(meta) {
if (meta.writer !== RUN.lead_writer) return "";
var shots = PKG.shots || [];
var filmed = shots.filter(function (s) { return s.clip_url; }).length;
var cuts = PKG.cuts || [];
var st = String(PKG.cuts_state || "");
var out = '<div class="cuts"><h4>Shorter cuts of this same shoot</h4>'
+ '<p class="lede">Nothing here is re-filmed. A cut is an order to play '
+ 'the shots in, so making another one costs nothing — which is why '
+ 'the question is not “what is the right length” but “which '
+ 'version do people watch to the end”.</p>';
if (busyCuts()) out += '<p class="busy">Working out what to drop…</p>';
else if (st === "thinking") out += '<p class="cont">The last attempt stopped '
+ 'before it finished — the server restarted under it. Ask again.</p>';
else if (/^failed/.test(st)) out += '<p class="cont">' + esc(st) + '</p>';
out += cuts.map(function (c) {
var stat = STATS[c.id];
var play = (PLAYING === c.id)
? '<div class="playbox"><video id="cutv" controls playsinline></video>'
+ '<div class="bar2"><span id="cutat">loading…</span>'
+ '<button type="button" class="btn" data-stop="1">Close</button></div></div>'
: '';
var tools = '<button type="button" class="btn" data-play="' + esc(c.id) + '">'
+ (PLAYING === c.id ? 'Playing' : 'Watch it · free') + '</button>';
if (c.state === "made" && c.video_url)
tools += '<a class="btn" href="/videos.html">Published · in My videos</a>';
else if (c.state === "making")
tools += '<span class="busy">joining…</span>';
else if (filmed)
tools += '<button type="button" class="btn primary" data-makecut="' + esc(c.id)
+ '">Publish this version</button>';
return '<div class="cut"><b>' + esc(c.name || "") + '</b>'
+ '<span class="len">' + mmss(c.seconds || 0) + ' · '
+ (c.shot_ids || []).length + ' shots</span>'
+ (c.strategy ? '<p class="strat">' + esc(c.strategy) + '</p>' : '')
+ (c.why ? '<p class="why">' + esc(c.why) + '</p>' : '')
+ (c.sacrifices ? '<p class="sac">gives up: ' + esc(c.sacrifices) + '</p>' : '')
+ (stat ? '<p class="stat">' + stat.views + ' viewer'
+ (stat.views === 1 ? '' : 's') + ' · median ' + stat.median_percent
+ '% watched · ' + stat.completion_rate + '% reached the end</p>' : '')
+ '<p class="ids">' + esc((c.shot_ids || []).join(" "))
+ (reordered(c.shot_ids) ? ' &middot; reordered' : '') + '</p>'
+ '<div class="tools">' + tools + '</div>' + play + '</div>';
}).join("");
if (!cuts.length && st !== "thinking")
out += '<p class="cont">No cuts proposed yet.</p>';
return out + '</div>';
}
function reviewsHtml(meta) {
var mine = REVIEWS.filter(function (r) {
return r.subject === meta.writer && Number(r.iteration) === Number(meta.iteration); });
if (!mine.length) return "";
var gaps = mine.filter(function (r) { return r.reviewer === "__gaps__"; })[0];
var readers = mine.filter(function (r) { return r.reviewer !== "__gaps__"; });
var out = "";
if (gaps) {
var v = gaps.strengths && gaps.strengths.verdict;
var list = gaps.suggestions || [];
out += '<div class="gaps"><h4>What this trailer does not tell a viewer</h4>'
+ (v ? '<p class="verdict">' + esc(v) + '</p>' : '')
+ (list.length ? '<ol>' + list.map(function (g) {
return '<li>' + esc(g.what || "")
+ '<span class="fix"><span class="sh">shot ' + esc(g.shot || "?") + '</span> &rarr; '
+ esc(g.fix || "") + '</span></li>'; }).join("") + '</ol>' : '')
+ '</div>';
}
out += readers.map(function (r) {
var s = r.strengths || {}, e = r.errors || {};
if (typeof s !== "object") s = {};
if (typeof e !== "object") e = {};
function ul(items, cls) {
return (!items || !items.length) ? ""
: '<ul class="' + cls + '">' + items.map(function (x) {
return '<li>' + esc(typeof x === "string" ? x : JSON.stringify(x)) + '</li>';
}).join("") + '</ul>';
}
return '<div class="rd"><h4>' + esc(r.label) + ' watched it without reading the book'
+ (r.score ? '<span class="n" title="how much of the real story they got">'
+ r.score + '/100</span>' : '') + '</h4>'
+ (s.summary ? '<blockquote>' + esc(s.summary) + '</blockquote>' : '')
+ '<div class="kv">main character: ' + esc(s.who || "not clear to them") + '</div>'
+ '<div class="kv">what they want: ' + esc(s.wants || "not clear to them") + '</div>'
+ '<div class="kv">what is in the way: ' + esc(s.obstacle || "not clear to them") + '</div>'
+ (s.would_watch ? '<div class="kv">would they watch the film: '
+ esc(s.would_watch) + '</div>' : '')
+ (s.unanswered && s.unanswered.length
? '<div class="kv">still asking:</div>' + ul(s.unanswered, "") : '')
+ (e.missed && e.missed.length ? '<div class="kv">they missed:</div>' + ul(e.missed, "") : '')
+ (e.got_wrong && e.got_wrong.length
? '<div class="kv wrong">the trailer misled them:</div>' + ul(e.got_wrong, "wrong") : '')
+ '</div>';
}).join("");
return '<div class="revs">' + out + '</div>';
}
function paneHtml(meta, draft) {
var p = PKG, shots = (p.shots || []).slice().sort(function (a, b) {
return String(a.id) < String(b.id) ? -1 : 1; });
var cast = p.characters || [], voice = {};
cast.forEach(function (c) { voice[c.id] = c; });
var secs = shots.reduce(function (a, s) { return a + (Number(s.edit_seconds) || 0); }, 0);
var head = '<div class="head"><h2>' + esc(p.title || meta.label) + '</h2>'
+ (p.tagline ? '<span class="tag">' + esc(p.tagline) + '</span>' : '') + '</div>'
+ (p.logline ? '<p>' + esc(p.logline) + '</p>' : '')
+ '<p class="facts">' + esc(meta.label)
+ (Number(meta.iteration) > 1 ? ' &middot; revision ' + meta.iteration : '')
+ ' &middot; ' + shots.length + ' shots &middot; '
+ Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, "0")
+ ' &middot; ' + cast.length + ' characters</p>'
+ timingHtml();
var probs = draft.problems
? '<div class="probs"><b>The checker has objections</b><ul>'
+ draft.problems.split("\n").filter(Boolean).map(function (x) {
return '<li>' + esc(x) + '</li>'; }).join("") + '</ul></div>'
: "";
var castHtml = '<div class="cast">' + cast.map(function (c) {
var sheets = (c.head_url || c.front_url || c.side_url)
? '<div class="sheets">' + [c.head_url, c.front_url, c.side_url].map(function (u) {
return u ? '<img src="' + esc(u) + '" alt="">' : ''; }).join("") + '</div>' : '';
return '<div class="ch">' + sheets + '<b>' + esc(c.name || c.id) + '</b>'
+ '<span class="vo">' + esc(c.role || "") + (c.voice_id ? " · voiced" : " · silent")
+ '</span><div class="ap">' + esc((c.appearance || "").slice(0, 150)) + '</div></div>';
}).join("") + '</div>';
var body = insertHtml("start");
shots.forEach(function (s) { body += shotHtml(s, voice) + insertHtml(s.id); });
return head + probs + '<div class="bar">' + barHtml(meta) + '</div>'
+ '<div id="reelhost">' + reelHtml() + '</div>'
+ spineHtml() + cutsHtml(meta) + reviewsHtml(meta) + castHtml + body;
}
function insertHtml(after) {
if (EDITING === "new:" + after) {
return '<div class="ins" style="text-align:left">'
+ '<textarea class="rough" id="newrough" rows="4" placeholder="'
+ 'What happens in this scene, who is in it and what they say. A sentence or two is enough '
+ '— the writer turns it into a proper shot."></textarea>'
+ '<div class="tools"><button type="button" class="btn primary" data-addgo="'
+ esc(after) + '">Write this scene</button>'
+ '<button type="button" class="btn" data-cancel="1">Cancel</button></div></div>';
}
return '<div class="ins"><button type="button" class="btn" data-add="' + esc(after)
+ '">+ Add a scene ' + (after === "start" ? "at the start" : "after " + esc(after)) + '</button></div>';
}
// The faults themselves, with the prompt change that would prevent each -
// which is the half that makes the check actionable rather than a verdict.
function faultsHtml(s) {
var vc = s.vision_check;
if (!vc || !(vc.faults || []).length) return "";
return '<div class="eyes">'
+ (vc.summary ? '<div class="sum">' + esc(vc.summary) + '</div>' : '')
+ vc.faults.map(function (f) {
return '<div class="f ' + (f.severity === "serious" ? "serious" : "")
+ '"><b>' + esc(f.kind || "") + '</b>'
+ (f.at != null ? ' at ' + Number(f.at).toFixed(1) + 's' : '')
+ ' &middot; ' + esc(f.what || "")
+ (f.prompt_fix ? '<span class="fix">fix: ' + esc(f.prompt_fix)
+ '</span>' : '') + '</div>';
}).join("") + '</div>';
}
function shotHtml(s, voice) {
if (s.writing) {
return '<div class="shot"><div class="num">' + esc(s.id) + '</div><div class="body">'
+ '<h3>' + esc(s.title || "New scene") + '</h3>'
+ '<p class="busy">The writer is turning your note into a shot…</p>'
+ '<p class="cont">' + esc(String(s.rough || "").slice(0, 300)) + '</p></div></div>';
}
var lines = (s.audio || []).map(function (a) {
var who = voice[a.character_id] || {};
return '<div class="line">'
+ '<button type="button" class="play" data-shot="' + esc(s.id) + '">&#9654;</button>'
+ '<div class="say"><span class="who">' + esc(who.name || a.character_id) + '</span> '
+ (a.lip_sync ? '<span class="lip">on camera</span>' : '<span class="vobadge">voice over</span>')
+ '<br>' + esc(a.text || "")
+ (a.delivery ? '<br><span class="dir">' + esc(a.delivery) + '</span>' : '')
+ '</div><span class="dur" data-dur="' + esc(s.id) + '"></span></div>';
}).join("");
var media = "";
if (s.clip_url) {
media = '<div class="frames"><figure><video src="' + esc(s.clip_url) + '" controls '
+ 'preload="metadata"></video><figcaption>the filmed shot'
+ (s.clip_seconds ? ' &middot; ' + Number(s.clip_seconds).toFixed(1) + 's' : '')
+ '</figcaption></figure>'
+ (s.opening_url ? '<figure><img src="' + esc(s.opening_url) + '" alt=""><figcaption>filmed from this frame</figcaption></figure>' : '')
+ '</div>';
} else if (s.opening_url || s.closing_url) {
media = '<div class="frames">'
+ (s.opening_url ? '<figure><img src="' + esc(s.opening_url) + '" alt=""><figcaption>opens on</figcaption></figure>' : '')
+ (s.closing_url ? '<figure><img src="' + esc(s.closing_url) + '" alt=""><figcaption>ends on</figcaption></figure>' : '')
+ '</div>';
}
var chk = s.audio_check || {};
var chkHtml = "";
if (chk.verdict && chk.verdict !== "no dialogue") {
var good = chk.verdict === "ok" || chk.verdict === "drifted";
chkHtml = '<span class="chk ' + (good ? "ok" : "bad") + '" title="'
+ esc("heard: " + String(chk.heard || "")) + '">audio ' + esc(chk.verdict)
+ (chk.coverage != null ? ' ' + Math.round(chk.coverage * 100) + '%' : '')
+ (chk.missing ? ' &mdash; lost “' + esc(String(chk.missing).slice(0, 40)) + '”' : '')
+ '</span>';
}
// What the eyes found, if they have looked. Kinds: render (the picture is
// broken), continuity (the frames disagree), script (wrong shot, well made).
var vc = s.vision_check || null;
var eyeHtml = "";
if (vc && vc.verdict) {
var bad = vc.verdict === "broken";
var faults = vc.faults || [];
eyeHtml = '<span class="chk ' + (bad ? "bad" : (faults.length ? "" : "ok"))
+ '" title="' + esc(vc.summary || "") + '">picture ' + esc(vc.verdict)
+ (faults.length ? ' &middot; ' + faults.length + ' fault'
+ (faults.length === 1 ? '' : 's') : '')
+ (vc.people_on_screen != null ? ' &middot; ' + vc.people_on_screen
+ ' on screen' : '') + '</span>';
}
var tools;
if (s.drawing) tools = '<span class="busy">drawing…</span>';
else if (s.filming) tools = '<span class="busy">filming…</span>';
else if (EDITING === s.id) {
tools = '<div style="width:100%"><textarea class="rough" id="rough" rows="3" placeholder="'
+ 'What should change in this shot?">' + esc(s.rough || "") + '</textarea>'
+ '<div class="tools"><button type="button" class="btn primary" data-rewrite="' + esc(s.id)
+ '">Rewrite this shot</button>'
+ '<button type="button" class="btn" data-cancel="1">Cancel</button></div></div>';
} else {
tools = '<button type="button" class="btn" data-edit="' + esc(s.id) + '">Change this shot</button>'
+ '<button type="button" class="btn" data-drawone="' + esc(s.id) + '">'
+ (s.opening_url ? 'Redraw' : 'Draw') + ' &middot; $0.14</button>'
+ (s.opening_url ? '<button type="button" class="btn" data-filmone="' + esc(s.id) + '">'
+ (s.clip_url ? 'Film it again' : 'Film this shot') + ' &middot; $0.75</button>' : '')
+ chkHtml + eyeHtml
+ '<button type="button" class="btn" data-delete="' + esc(s.id) + '">Remove</button>';
}
return '<div class="shot"><div class="num">' + esc(s.id) + '</div><div class="body">'
+ '<h3>' + esc(s.title || "") + '</h3>'
+ '<div class="where">' + esc(s.setting || "") + ' &middot; '
+ Number(s.edit_seconds || 0).toFixed(1) + 's'
+ (s.scene ? ' &middot; <span class="scene">' + esc(s.scene) + '</span>' : '')
+ (s.day ? ' &middot; <span class="scene">' + esc(s.day) + '</span>' : '')
+ '</div>'
+ media
+ '<p class="act">' + esc(s.action || "") + '</p>'
+ (s.camera ? '<p class="cam">camera: ' + esc(s.camera) + '</p>' : '')
+ (s.continuity ? '<p class="cont">continuity: ' + esc(s.continuity) + '</p>' : '')
+ lines
+ faultsHtml(s)
+ '<div class="tools">' + tools + '</div>'
+ '<textarea class="note" rows="2" data-note="' + esc(s.id) + '" '
+ 'placeholder="Your note on this shot, for the next full revision.">'
+ esc(NOTES[s.id] || "") + '</textarea>'
+ '</div></div>';
}
async function act(name, payload) {
var r = await api(name, payload);
if (!r.ok) { showMsg("msg", r.error || "That did not work."); return null; }
hideMsg("msg");
return r;
}
document.getElementById("pane").addEventListener("click", async function (e) {
var hit = function (attr) { return e.target.closest("[" + attr + "]"); };
var el, r;
if ((el = hit("data-cancel"))) { EDITING = null; openTab(CUR); return; }
if ((el = hit("data-add"))) { EDITING = "new:" + el.getAttribute("data-add"); openTab(CUR); return; }
if ((el = hit("data-edit"))) { EDITING = el.getAttribute("data-edit"); openTab(CUR); return; }
if ((el = hit("data-addgo"))) {
var rough = document.getElementById("newrough").value.trim();
if (rough.length < 20) { showMsg("msg", "Say what happens in the scene."); return; }
el.disabled = true;
r = await act("add_shot", { draft_id: CUR, after: el.getAttribute("data-addgo"), rough: rough });
EDITING = null; if (r) await refresh(); return;
}
if ((el = hit("data-rewrite"))) {
var rw = document.getElementById("rough").value.trim();
if (rw.length < 20) { showMsg("msg", "Say what should change."); return; }
el.disabled = true;
r = await act("write_shot", { draft_id: CUR, shot_id: el.getAttribute("data-rewrite"), rough: rw });
EDITING = null; if (r) await refresh(); return;
}
if ((el = hit("data-drawone"))) {
el.disabled = true;
r = await act("draw_one_shot", { draft_id: CUR, shot_id: el.getAttribute("data-drawone") });
if (r) await refresh(); return;
}
if ((el = hit("data-filmone"))) {
el.disabled = true;
r = await act("film_one_shot_now", { draft_id: CUR, shot_id: el.getAttribute("data-filmone") });
if (r) await refresh(); return;
}
if ((el = hit("data-delete"))) {
if (!confirm("Remove shot " + el.getAttribute("data-delete") + " from this trailer?")) return;
el.disabled = true;
r = await act("delete_shot", { draft_id: CUR, shot_id: el.getAttribute("data-delete") });
if (r) await refresh(); return;
}
if ((el = hit("data-assemble"))) {
el.disabled = true;
r = await act("assemble_trailer", { draft_id: CUR });
if (r) { showMsg("msg", "Joining " + r.shots + " shots. It appears in My videos.", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 2200); }
return;
}
if ((el = hit("data-lead"))) {
el.disabled = true;
r = await act("set_lead", { run_id: runId, draft_id: el.getAttribute("data-lead") });
if (r) await refresh(); return;
}
if ((el = hit("data-draw"))) {
el.disabled = true;
r = await act("draw_storyboard", { draft_id: el.getAttribute("data-draw") });
if (r) await refresh(); return;
}
if ((el = hit("data-reelgo"))) {
el.disabled = true;
var rl = await api("reel", { draft_id: CUR });
if (!rl.ok) { showMsg("msg", rl.error || "Could not load the storyboard."); return; }
if (String(PKG.reel_state || "") !== "ready") {
el.textContent = "Speaking every shot…";
REELWANT = true;
r = await act("prepare_reel", { draft_id: CUR });
if (r) showMsg("msg", "Speaking " + r.shots + " shots. This is the same "
+ "audio the film will use, so it is only paid for once. It starts "
+ "playing on its own when it is ready.", "ok");
else REELWANT = false;
await refresh();
return;
}
hideMsg("msg");
REEL = rl.shots || []; RAT = -1;
reelShow(0);
return;
}
if ((el = hit("data-reeltoggle"))) {
if (reelAudio.paused) { reelAudio.play().catch(function(){}); reelShow(RAT); }
else { reelAudio.pause(); clearInterval(RTIMER); RTIMER = null; reelPaint(); }
return;
}
if ((el = hit("data-reelstep"))) {
var d = Number(el.getAttribute("data-reelstep")) || 1;
reelShow(Math.max(0, Math.min((REEL || []).length - 1, RAT + d)));
return;
}
if ((el = hit("data-reelstop"))) { reelStop(); return; }
if ((el = hit("data-reeljump"))) {
var jid = el.getAttribute("data-reeljump");
clearInterval(RTIMER); RTIMER = null; reelAudio.pause();
var target = [...document.querySelectorAll(".shot")].find(function (x) {
var nu = x.querySelector(".num");
return nu && nu.textContent.trim() === jid; });
if (target) {
document.querySelectorAll(".shot.lit").forEach(function (x) {
x.classList.remove("lit"); });
target.classList.add("lit");
target.scrollIntoView({ behavior: "smooth", block: "center" });
var ta = target.querySelector("textarea.note");
if (ta) setTimeout(function () { ta.focus(); }, 500);
}
return;
}
if ((el = hit("data-eyes"))) {
el.disabled = true; el.textContent = "Looking at the footage…";
r = await act("check_clips", { draft_id: el.getAttribute("data-eyes") });
if (r) showMsg("msg", "Sampling frames from " + r.shots + " clips and "
+ "checking them against the script.", "ok");
await refresh();
return;
}
if ((el = hit("data-cuts"))) {
el.disabled = true; el.textContent = "Working out what to drop…";
r = await act("propose_cuts", { draft_id: el.getAttribute("data-cuts") });
if (r) { showMsg("msg", "Asking for " + r.count + " different cuts.", "ok");
setTimeout(refresh, 25000); }
return;
}
if ((el = hit("data-play"))) {
var cid = el.getAttribute("data-play");
el.disabled = true;
var cc = await api("cut_clips", { draft_id: CUR, cut_id: cid });
el.disabled = false;
if (!cc.ok) { showMsg("msg", cc.error || "That would not play."); return; }
QUEUE = (cc.shots || []).filter(function (s) { return s.clip_url; });
if (!QUEUE.length) {
showMsg("msg", "None of the shots in this cut are filmed yet, so there "
+ "is nothing to play. Film them first.");
return;
}
if (cc.filmed < cc.of)
showMsg("msg", "Playing the " + cc.filmed + " of " + cc.of
+ " shots that are filmed. The rest are skipped.");
PLAYING = cid; QAT = 0;
await openTab(CUR); // the video element does not exist until this lands
playCut();
return;
}
if ((el = hit("data-stop"))) { PLAYING = null; QUEUE = []; openTab(CUR); return; }
if ((el = hit("data-makecut"))) {
el.disabled = true;
r = await act("make_cut", { draft_id: CUR, cut_id: el.getAttribute("data-makecut") });
if (r) { showMsg("msg", "Joining " + r.shots + " shots into this version. "
+ "It appears in My videos with its own share page.", "ok");
await refresh(); }
return;
}
if ((el = hit("data-measure"))) {
el.disabled = true; el.textContent = "Speaking every line…";
r = await act("measure_script", { draft_id: el.getAttribute("data-measure") });
if (r) {
showMsg("msg", "Speaking every line in the cast voices. A minute or two.", "ok");
setTimeout(refresh, 20000);
}
return;
}
if ((el = hit("data-review"))) {
el.disabled = true;
r = await act("start_review", { run_id: runId }); if (r) await refresh(); return;
}
if ((el = hit("data-revise"))) {
el.disabled = true;
r = await act("start_revision", { run_id: runId }); if (r) await refresh(); return;
}
if ((el = hit("data-film"))) {
el.disabled = true;
r = await act("film_draft", { draft_id: el.getAttribute("data-film") });
if (r) { showMsg("msg", "Filming " + r.shots + " shots, about $" + r.usd
+ ". It appears in My videos as it goes.", "ok");
setTimeout(function () { location.href = "/videos.html"; }, 2500); }
return;
}
if ((el = hit("data-retry"))) {
el.disabled = true;
r = await act("retry_writer", { draft_id: el.getAttribute("data-retry") });
if (r) await refresh(); return;
}
var btn = e.target.closest(".play");
if (!btn) return;
var sid = btn.getAttribute("data-shot");
btn.disabled = true; btn.innerHTML = "&hellip;";
var a = await api("shot_audio", { draft_id: CUR, shot_id: sid });
btn.disabled = false; btn.innerHTML = "&#9654;";
if (!a.ok) { showMsg("msg", a.error || "That would not play."); return; }
hideMsg("msg");
if (!a.url) return;
document.querySelectorAll('[data-dur="' + sid + '"]').forEach(function (x) {
x.textContent = a.seconds.toFixed(1) + "s / " + Number(a.planned).toFixed(1) + "s";
x.className = "dur" + (a.seconds > Number(a.planned) ? " over" : "");
});
audio.pause(); audio.src = a.url; audio.play();
});
// Play the cut's clips one after another in a single element. This is the
// whole preview: no joining, no encoding, no upload, nothing to pay for.
function playCut() {
var v = document.getElementById("cutv");
if (!v || !QUEUE.length) return;
var at = document.getElementById("cutat");
function step() {
if (QAT >= QUEUE.length) {
if (at) at.textContent = "end of this cut";
return;
}
var s = QUEUE[QAT];
if (at) at.textContent = "shot " + s.id + " · " + (QAT + 1)
+ " of " + QUEUE.length + (s.title ? " · " + s.title : "");
v.src = s.clip_url;
v.play().catch(function () {});
}
v.onended = function () { QAT += 1; step(); };
v.onerror = function () { QAT += 1; step(); };
step();
}
var noteTimers = {};
document.getElementById("pane").addEventListener("input", function (e) {
var ta = e.target;
var sid = ta.getAttribute && ta.getAttribute("data-note");
if (!sid) return;
ta.classList.remove("saved");
clearTimeout(noteTimers[sid]);
noteTimers[sid] = setTimeout(async function () {
var r = await api("add_shot_note", { draft_id: CUR, shot_id: sid, note: ta.value });
if (r.ok) { NOTES[sid] = ta.value; ta.classList.add("saved"); }
}, 700);
});
load();
})();
</script>
</body>
</html>
+ (filmed * 0.02).toFixed(2) + '</button>');
}
if (unfilmed && !undrawn)
out.push('<button type="button" class="btn primary" data-film="' + esc(meta.id)
+ '">Film ' + (filmed ? 'the ' + unfilmed + ' remaining shots' : 'all ' + shots.length + ' shots')