diff --git a/script.html b/script.html index 4fb89d8..7e8bbec 100644 --- a/script.html +++ b/script.html @@ -86,7 +86,7 @@ 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 = []; +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]; @@ -256,8 +256,39 @@ function render(r) { ? left + " rewrite" + (left === 1 ? "" : "s") + " left" : "Words approved") + ''; - var full = s.full_text - ? '

The whole piece

' + esc(s.full_text) + '
' : ""; + // 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 ? "" : + '

The whole piece

' + + (canEditScript + ? '
' + + '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.' + + '
' + + '' + + '
' + : '
' + esc(narrative) + '
'); + + // 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. + var setup = !canEditScript ? '' : + '

Setup shots

' + + '

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. About a cent.

' + + '' + + '
'; + + var staleNote = (stale && canEditScript) + ? '
The words above have changed since these shots were cut, so this is ' + + 'the previous division. Press Setup shots to cut the new words.
' : ''; var cams = '' + PRESETS.map(function (c) { return ''; @@ -372,8 +403,20 @@ function render(r) { + '

Change the words

' + '
' + 'Be specific. “Too polite, he would be smugger” beats “make it funnier”. ' - + 'Setting and camera you can just edit directly above.' + + 'To change a word yourself, edit the piece above and press Setup shots.' + '
' + + (WRITERS.length + ? '
' + + 'Different models are different comedians. The same note sent ' + + 'to each one comes back with different jokes.' + + '
' + : '') + '' + '' @@ -381,10 +424,15 @@ function render(r) { 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 - + '

The words, shot by shot

' - + s.segments.map(linesHtml).join("") - + actions; + + actions + + setup + + '

The words, shot by shot

' + + staleNote + + s.segments.map(linesHtml).join(""); } else if (VIEW === 2) { body = head + '

How it will be laid out. ' @@ -1046,14 +1094,17 @@ function bindAlign() { var out = document.getElementById("alignout"); function paint(r) { - var bought = ((DATA && DATA.script && DATA.script.segments) || []).length * 8; + // 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 = '

Measured: ' + (r.spoken || 0).toFixed(1) + 's spoken, about ' + filmed.toFixed(1) + 's filmed, against ' + bought + 's of shots.

' + (r.audio ? '' : '') + (r.shots || []).map(function (s) { - var fit = Math.abs((s.filmed || 0) - 8) <= 1.2; + var fit = Math.abs((s.filmed || 0) - each) <= 1.2; return '
' + 'shot ' + s.index + ' · ' + (s.start || 0).toFixed(1) + '-' + (s.end || 0).toFixed(1) + 's spoken · ~' + (s.filmed || 0).toFixed(1) + 's filmed' @@ -1096,9 +1147,14 @@ function bindAlign() { load(); }; - api("align_script", { email: email, project_id: pid }).then(function (r) { - if (r && r.ok && !r.pending && r.audio) paint(r); - }); + // 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() { @@ -1350,13 +1406,61 @@ function bindPost() { } 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(); + }; + + 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. + 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 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); + }); + } + su.disabled = false; su.textContent = "Setup shots"; + if (!r.ok) { showMsg("msg", r.error || "Could not cut that."); return; } + if (r.note) { showMsg("msg", r.note); return; } + showMsg("msg", "Cut into shots at real sentence endings.", "ok"); + load(); + }; + 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 res = await api("revise_script", { email: email, project_id: pid, feedback: fb }); + 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(); @@ -1376,7 +1480,10 @@ 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 || []; } + 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; }