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:
parent
076621f809
commit
70fa5ae9bb
1 changed files with 113 additions and 23 deletions
136
script.html
136
script.html
|
|
@ -253,7 +253,7 @@ function render(r) {
|
|||
+ '<div class="sub">' + videoSecondsOf(p) + ' seconds · ' + (p.segments || 4) + ' shots'
|
||||
+ (modelFor(p) ? ' · ' + esc(modelFor(p).label) : '') + '</div></div>'
|
||||
+ '<div class="sub">' + (canEditScript
|
||||
? left + " rewrite" + (left === 1 ? "" : "s") + " left"
|
||||
? (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
|
||||
|
|
@ -277,19 +277,50 @@ function render(r) {
|
|||
// 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">'
|
||||
+ '<p class="foot" style="margin:0 0 14px">Speaks the whole piece once in your pet’s '
|
||||
+ 'voice, measures where every word actually falls, and cuts the shots at real sentence '
|
||||
+ 'endings. It divides the words — it does not change them. '
|
||||
// Measured, not guessed: an 80s piece came back at $0.1589 for 946 characters,
|
||||
// which is the ElevenLabs rate of $22 per 131,000. "About a cent" was wrong by
|
||||
// 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>'
|
||||
+ voiceLine
|
||||
+ '<p class="foot" style="margin:0 0 14px">Divides the piece into shots at '
|
||||
+ esc(vp.engine || 'the engine') + '’s own measured speaking rate. Runs here in '
|
||||
+ 'code — 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>';
|
||||
+ '<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 — 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 '
|
||||
|
|
@ -409,7 +440,7 @@ function render(r) {
|
|||
+ '<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" ' + (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
|
||||
? '<div class="field"><label for="writer">Who rewrites it</label>'
|
||||
+ '<span class="hint">Different models are different comedians. The same note sent '
|
||||
|
|
@ -422,9 +453,29 @@ function render(r) {
|
|||
}).join("")
|
||||
+ '</select></div>'
|
||||
: '')
|
||||
+ '<button class="btn ghost" id="revise" ' + (left <= 0 ? "disabled" : "") + '>'
|
||||
+ (left > 0 ? "Rewrite the words" : "No rewrites left") + '</button>'
|
||||
+ '<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)'
|
||||
: '')
|
||||
+ ' — 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 — the scene, the camera and '
|
||||
+ 'the opening frame — 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;
|
||||
|
|
@ -1426,16 +1477,41 @@ function bindBottom() {
|
|||
load();
|
||||
};
|
||||
|
||||
var su = document.getElementById("setupshots");
|
||||
if (su) su.onclick = async function () {
|
||||
hideMsg("msg");
|
||||
// Save anything typed but not saved first, so the cut is of the words on
|
||||
// screen rather than the words on file.
|
||||
// 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 });
|
||||
}
|
||||
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 });
|
||||
if (r.ok && r.pending) {
|
||||
var waited = 0;
|
||||
|
|
@ -1448,11 +1524,25 @@ function bindBottom() {
|
|||
}, 4000);
|
||||
});
|
||||
}
|
||||
su.disabled = false; su.textContent = "Setup shots";
|
||||
if (!r.ok) { showMsg("msg", r.error || "Could not cut that."); return; }
|
||||
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; }
|
||||
showMsg("msg", "Cut into shots at real sentence endings.", "ok");
|
||||
load();
|
||||
// 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 — 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 + ' · ' + (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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue