Trailers

Loading…

+ (filmed * 0.02).toFixed(2) + ''); } if (unfilmed && !undrawn) out.push(''); if (filmed) out.push(''); } 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 " + mmss(t.measured_seconds) + ", against a " + mmss(t.target_seconds) + " target"; if (over > 5) line += " — " + Math.round(over) + " seconds over." + " 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 "
  • " + Number(l.seconds || 0).toFixed(1) + "s · shot " + esc(String(l.shot)) + " · “" + esc(String(l.text || "").slice(0, 80)) + "”
  • "; }).join(""); return '
    ' + line + "
    " + (worst && over > 5 ? "" : "") + "
    "; } // 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 = '

    Watch the storyboard

    ' + '

    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.

    '; if (running) out += '

    Speaking every shot… ' + esc(st) + '

    '; else if (st === "ready") { var spoken = shots.filter(function (s) { return s.reel_url; }).length; var silent = shots.length - spoken; out += '

    ' + spoken + ' shots have dialogue and are spoken' + (silent ? '; ' + silent + ' are silent and hold on the frame' : '') + '.

    '; } else if (/^failed/.test(st)) out += '

    ' + esc(st) + '

    '; else if (st) out += '

    The last attempt stopped before it finished — ' + 'the server restarted under it. Ask again.

    '; if (!REEL) { out += '
    ' + '
    '; return out; } var s = REEL[RAT] || REEL[0] || {}; var lines = (s.lines || []).map(function (l) { return '
    ' + esc(l.who || "") + (l.lip_sync ? "" : " (v.o.)") + '' + esc(l.text || "") + '
    '; }).join("") || '
    no dialogue in this shot
    '; out += '
    ' + '
    opens on
    ' + '
    ends on
    ' + '
    ' + esc(s.id) + ' · ' + esc(s.title || "") + '
    ' + '
    ' + esc(s.setting || "") + (s.scene ? ' · ' + esc(s.scene) : "") + (s.day ? ' · ' + esc(s.day) : "") + '
    ' + '
    ' + lines + '
    ' + '
    ' + '
    ' + '' + '' + '' + '' + '' + '' + (RAT + 1) + ' of ' + REEL.length + '' + '
    '; 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 = '

    Shorter cuts of this same shoot

    ' + '

    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”.

    '; if (busyCuts()) out += '

    Working out what to drop…

    '; else if (st === "thinking") out += '

    The last attempt stopped ' + 'before it finished — the server restarted under it. Ask again.

    '; else if (/^failed/.test(st)) out += '

    ' + esc(st) + '

    '; out += cuts.map(function (c) { var stat = STATS[c.id]; var play = (PLAYING === c.id) ? '
    ' + '
    loading…' + '
    ' : ''; var tools = ''; if (c.state === "made" && c.video_url) tools += 'Published · in My videos'; else if (c.state === "making") tools += 'joining…'; else if (filmed) tools += ''; return '
    ' + esc(c.name || "") + '' + '' + mmss(c.seconds || 0) + ' · ' + (c.shot_ids || []).length + ' shots' + (c.strategy ? '

    ' + esc(c.strategy) + '

    ' : '') + (c.why ? '

    ' + esc(c.why) + '

    ' : '') + (c.sacrifices ? '

    gives up: ' + esc(c.sacrifices) + '

    ' : '') + (stat ? '

    ' + stat.views + ' viewer' + (stat.views === 1 ? '' : 's') + ' · median ' + stat.median_percent + '% watched · ' + stat.completion_rate + '% reached the end

    ' : '') + '

    ' + esc((c.shot_ids || []).join(" ")) + (reordered(c.shot_ids) ? ' · reordered' : '') + '

    ' + '
    ' + tools + '
    ' + play + '
    '; }).join(""); if (!cuts.length && st !== "thinking") out += '

    No cuts proposed yet.

    '; return out + '
    '; } 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 += '

    What this trailer does not tell a viewer

    ' + (v ? '

    ' + esc(v) + '

    ' : '') + (list.length ? '
      ' + list.map(function (g) { return '
    1. ' + esc(g.what || "") + 'shot ' + esc(g.shot || "?") + ' → ' + esc(g.fix || "") + '
    2. '; }).join("") + '
    ' : '') + '
    '; } 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) ? "" : ''; } return '

    ' + esc(r.label) + ' watched it without reading the book' + (r.score ? '' + r.score + '/100' : '') + '

    ' + (s.summary ? '
    ' + esc(s.summary) + '
    ' : '') + '
    main character: ' + esc(s.who || "not clear to them") + '
    ' + '
    what they want: ' + esc(s.wants || "not clear to them") + '
    ' + '
    what is in the way: ' + esc(s.obstacle || "not clear to them") + '
    ' + (s.would_watch ? '
    would they watch the film: ' + esc(s.would_watch) + '
    ' : '') + (s.unanswered && s.unanswered.length ? '
    still asking:
    ' + ul(s.unanswered, "") : '') + (e.missed && e.missed.length ? '
    they missed:
    ' + ul(e.missed, "") : '') + (e.got_wrong && e.got_wrong.length ? '
    the trailer misled them:
    ' + ul(e.got_wrong, "wrong") : '') + '
    '; }).join(""); return '
    ' + out + '
    '; } 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 = '

    ' + esc(p.title || meta.label) + '

    ' + (p.tagline ? '' + esc(p.tagline) + '' : '') + '
    ' + (p.logline ? '

    ' + esc(p.logline) + '

    ' : '') + '

    ' + esc(meta.label) + (Number(meta.iteration) > 1 ? ' · revision ' + meta.iteration : '') + ' · ' + shots.length + ' shots · ' + Math.floor(secs / 60) + ':' + String(Math.round(secs % 60)).padStart(2, "0") + ' · ' + cast.length + ' characters

    ' + timingHtml(); var probs = draft.problems ? '
    The checker has objections
    ' : ""; var castHtml = '
    ' + cast.map(function (c) { var sheets = (c.head_url || c.front_url || c.side_url) ? '
    ' + [c.head_url, c.front_url, c.side_url].map(function (u) { return u ? '' : ''; }).join("") + '
    ' : ''; return '
    ' + sheets + '' + esc(c.name || c.id) + '' + '' + esc(c.role || "") + (c.voice_id ? " · voiced" : " · silent") + '
    ' + esc((c.appearance || "").slice(0, 150)) + '
    '; }).join("") + '
    '; var body = insertHtml("start"); shots.forEach(function (s) { body += shotHtml(s, voice) + insertHtml(s.id); }); return head + probs + '
    ' + barHtml(meta) + '
    ' + '
    ' + reelHtml() + '
    ' + spineHtml() + cutsHtml(meta) + reviewsHtml(meta) + castHtml + body; } function insertHtml(after) { if (EDITING === "new:" + after) { return '
    ' + '' + '
    ' + '
    '; } return '
    '; } // 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 '
    ' + (vc.summary ? '
    ' + esc(vc.summary) + '
    ' : '') + vc.faults.map(function (f) { return '
    ' + esc(f.kind || "") + '' + (f.at != null ? ' at ' + Number(f.at).toFixed(1) + 's' : '') + ' · ' + esc(f.what || "") + (f.prompt_fix ? 'fix: ' + esc(f.prompt_fix) + '' : '') + '
    '; }).join("") + '
    '; } function shotHtml(s, voice) { if (s.writing) { return '
    ' + esc(s.id) + '
    ' + '

    ' + esc(s.title || "New scene") + '

    ' + '

    The writer is turning your note into a shot…

    ' + '

    ' + esc(String(s.rough || "").slice(0, 300)) + '

    '; } var lines = (s.audio || []).map(function (a) { var who = voice[a.character_id] || {}; return '
    ' + '' + '
    ' + esc(who.name || a.character_id) + ' ' + (a.lip_sync ? 'on camera' : 'voice over') + '
    ' + esc(a.text || "") + (a.delivery ? '
    ' + esc(a.delivery) + '' : '') + '
    '; }).join(""); var media = ""; if (s.clip_url) { media = '
    the filmed shot' + (s.clip_seconds ? ' · ' + Number(s.clip_seconds).toFixed(1) + 's' : '') + '
    ' + (s.opening_url ? '
    filmed from this frame
    ' : '') + '
    '; } else if (s.opening_url || s.closing_url) { media = '
    ' + (s.opening_url ? '
    opens on
    ' : '') + (s.closing_url ? '
    ends on
    ' : '') + '
    '; } var chk = s.audio_check || {}; var chkHtml = ""; if (chk.verdict && chk.verdict !== "no dialogue") { var good = chk.verdict === "ok" || chk.verdict === "drifted"; chkHtml = 'audio ' + esc(chk.verdict) + (chk.coverage != null ? ' ' + Math.round(chk.coverage * 100) + '%' : '') + (chk.missing ? ' — lost “' + esc(String(chk.missing).slice(0, 40)) + '”' : '') + ''; } // 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 = 'picture ' + esc(vc.verdict) + (faults.length ? ' · ' + faults.length + ' fault' + (faults.length === 1 ? '' : 's') : '') + (vc.people_on_screen != null ? ' · ' + vc.people_on_screen + ' on screen' : '') + ''; } var tools; if (s.drawing) tools = 'drawing…'; else if (s.filming) tools = 'filming…'; else if (EDITING === s.id) { tools = '
    ' + '
    ' + '
    '; } else { tools = '' + '' + (s.opening_url ? '' : '') + chkHtml + eyeHtml + ''; } return '
    ' + esc(s.id) + '
    ' + '

    ' + esc(s.title || "") + '

    ' + '
    ' + esc(s.setting || "") + ' · ' + Number(s.edit_seconds || 0).toFixed(1) + 's' + (s.scene ? ' · ' + esc(s.scene) + '' : '') + (s.day ? ' · ' + esc(s.day) + '' : '') + '
    ' + media + '

    ' + esc(s.action || "") + '

    ' + (s.camera ? '

    camera: ' + esc(s.camera) + '

    ' : '') + (s.continuity ? '

    continuity: ' + esc(s.continuity) + '

    ' : '') + lines + faultsHtml(s) + '
    ' + tools + '
    ' + '' + '
    '; } 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-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 = "…"; var a = await api("shot_audio", { draft_id: CUR, shot_id: sid }); btn.disabled = false; btn.innerHTML = "▶"; 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(); })();