Split Setup shots from Script audio; name the voice; drop the rewrite cap

The one button did two unrelated jobs and showed the result of neither.

- Setup shots is now local division only: Python at the engine's own measured
  speaking rate, ~500ms, free, no LLM and no TTS. Answers "divide with local
  code, because each voice has a different cadence".
- Script audio is a separate button that reads it aloud AND SHOWS THE PLAYER.
  The audio was always being made and uploaded, then rendered into #alignout,
  which only exists on the Layout view - so pressing the button on the Words
  view produced fifteen seconds of nothing.
- The voice is now named. With an Omni engine the film ships Omni's own native
  voice, so the ElevenLabs read-through is a stand-in and says so.
- Rewrites are no longer capped at three. The real cost from OpenRouter is
  shown beside the buttons, added to the bill at cost with no markup.
- "Approve and lock it" now explains what it does.
This commit is contained in:
gator 2026-09-14 23:26:01 +00:00
parent 076621f809
commit 70fa5ae9bb

View file

@ -253,7 +253,7 @@ function render(r) {
+ '<div class="sub">' + videoSecondsOf(p) + ' seconds · ' + (p.segments || 4) + ' shots' + '<div class="sub">' + videoSecondsOf(p) + ' seconds · ' + (p.segments || 4) + ' shots'
+ (modelFor(p) ? ' · ' + esc(modelFor(p).label) : '') + '</div></div>' + (modelFor(p) ? ' · ' + esc(modelFor(p).label) : '') + '</div></div>'
+ '<div class="sub">' + (canEditScript + '<div class="sub">' + (canEditScript
? left + " rewrite" + (left === 1 ? "" : "s") + " left" ? (used === 1 ? "1 rewrite" : used + " rewrites") + " so far"
: "Words approved") + '</div></div>'; : "Words approved") + '</div></div>';
// The whole piece, which is what was actually written - the shots below are // The whole piece, which is what was actually written - the shots below are
@ -277,19 +277,50 @@ function render(r) {
// piece through ElevenLabs to find real word endings - about a cent, fifteen // 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 // seconds, and it rewrites where every shot begins and ends. This used to
// happen unasked on page load. // 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 ? '' : var setup = !canEditScript ? '' :
'<h2 class="sec" style="margin-top:34px">Setup shots</h2><div class="card">' '<h2 class="sec" style="margin-top:34px">Setup shots</h2><div class="card">'
+ '<p class="foot" style="margin:0 0 14px">Speaks the whole piece once in your pet&rsquo;s ' + voiceLine
+ 'voice, measures where every word actually falls, and cuts the shots at real sentence ' + '<p class="foot" style="margin:0 0 14px">Divides the piece into shots at '
+ 'endings. It divides the words &mdash; it does not change them. ' + esc(vp.engine || 'the engine') + '&rsquo;s own measured speaking rate. Runs here in '
// Measured, not guessed: an 80s piece came back at $0.1589 for 946 characters, + 'code &mdash; nothing is sent to a language model and nothing is spoken, so it is '
// which is the ElevenLabs rate of $22 per 131,000. "About a cent" was wrong by + 'instant and free. Press it whenever the words change.</p>'
// more than tenfold, so quote the real number for the words actually on screen.
+ 'Costs about ' + Math.max(1, Math.round(narrative.length * 22 / 1310))
+ ' cents at this length.</p>'
+ '<button type="button" class="btn' + (stale ? '' : ' ghost') + '" id="setupshots">' + '<button type="button" class="btn' + (stale ? '' : ' ghost') + '" id="setupshots">'
+ 'Setup shots</button>' + 'Setup shots</button>'
+ '<span class="foot" style="margin-left:12px" id="setupnote"></span></div>'; + '<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) var staleNote = (stale && canEditScript)
? '<div class="warn">The words above have changed since these shots were cut, so this is ' ? '<div class="warn">The words above have changed since these shots were cut, so this is '
@ -409,7 +440,7 @@ function render(r) {
+ '<div class="field"><label for="fb">What would you change</label>' + '<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”. ' + '<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>' + 'To change a word yourself, edit the piece above and press Setup shots.</span>'
+ '<textarea id="fb" ' + (left <= 0 ? "disabled" : "") + ' placeholder="Shot three is too wordy, and he would never admit the robot is good at anything."></textarea></div>' + '<textarea id="fb" placeholder="Shot three is too wordy, and he would never admit the robot is good at anything."></textarea></div>'
+ (WRITERS.length + (WRITERS.length
? '<div class="field"><label for="writer">Who rewrites it</label>' ? '<div class="field"><label for="writer">Who rewrites it</label>'
+ '<span class="hint">Different models are different comedians. The same note sent ' + '<span class="hint">Different models are different comedians. The same note sent '
@ -422,9 +453,29 @@ function render(r) {
}).join("") }).join("")
+ '</select></div>' + '</select></div>'
: '') : '')
+ '<button class="btn ghost" id="revise" ' + (left <= 0 ? "disabled" : "") + '>' + '<button class="btn ghost" id="revise">Rewrite the words</button>'
+ (left > 0 ? "Rewrite the words" : "No rewrites left") + '</button>'
+ '<button class="btn" id="approve" style="margin-left:10px">Approve and lock it</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>'; + '</div>';
var body; var body;
@ -1426,16 +1477,41 @@ function bindBottom() {
load(); load();
}; };
var su = document.getElementById("setupshots"); // Flush an unsaved edit before either button acts, so both work on the
if (su) su.onclick = async function () { // words that are on screen rather than the words on file.
hideMsg("msg"); async function flushNarrative() {
// Save anything typed but not saved first, so the cut is of the words on
// screen rather than the words on file.
var box = document.getElementById("narr"); var box = document.getElementById("narr");
if (box && box.value.trim().length >= 10) { if (box && box.value.trim().length >= 10) {
await api("set_narrative", { email: email, project_id: pid, text: box.value }); await api("set_narrative", { email: email, project_id: pid, text: box.value });
} }
su.disabled = true; su.textContent = "Speaking it…"; }
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 }); var r = await api("align_script", { email: email, project_id: pid, refresh: true });
if (r.ok && r.pending) { if (r.ok && r.pending) {
var waited = 0; var waited = 0;
@ -1448,11 +1524,25 @@ function bindBottom() {
}, 4000); }, 4000);
}); });
} }
su.disabled = false; su.textContent = "Setup shots"; ra.disabled = false; ra.textContent = "Read it aloud";
if (!r.ok) { showMsg("msg", r.error || "Could not cut that."); return; } if (!r.ok) { showMsg("msg", r.error || "Could not read that."); return; }
if (r.note) { showMsg("msg", r.note); return; } if (r.note) { showMsg("msg", r.note); return; }
showMsg("msg", "Cut into shots at real sentence endings.", "ok"); // THE PLAYER. This is what was missing: the audio was made and uploaded
load(); // 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"); var rv = document.getElementById("revise");