Storyboard: collapsible scenes (green filmed / red needs work), share a shot, share a scene

This commit is contained in:
gator 2026-09-22 22:17:54 +00:00
parent a7f7d326f1
commit 41125f6fa2

View file

@ -97,6 +97,15 @@
#zoom img{max-width:100%;max-height:100%;border-radius:6px}
#zoom .cap{position:fixed;left:0;right:0;bottom:10px;text-align:center;color:#ddd;font-size:13px}
.shot{border-top:1px solid var(--line);padding:16px 0;display:flex;gap:16px}
.scenebar{display:flex;align-items:center;gap:12px;width:100%;box-sizing:border-box;border:0;border-radius:10px;padding:10px 14px;margin:14px 0 4px;cursor:pointer;font:inherit;font-size:14px;text-align:left;color:#fff}
.scenebar.ok{background:#1f7a3a}
.scenebar.todo{background:#a8322a}
.scenebar .n{font:12px var(--mono,monospace);opacity:.85;min-width:64px}
.scenebar .t{flex:1;font-weight:600}
.scenebar .st{font-size:12px;opacity:.9}
.scenebar .arrow{opacity:.8}
.sceneshare{display:flex;gap:8px;align-items:center;margin:0 0 8px;font-size:12px;color:var(--muted)}
.sharelink{font:12px var(--mono,monospace);word-break:break-all}
.shot .num{width:40px;flex:none;color:var(--muted);font-variant-numeric:tabular-nums}
.shot .body{flex:1;min-width:0}
.shot h3{margin:0 0 3px;font-size:15px}
@ -1025,7 +1034,7 @@
// A CHAPTER IS A HUNDRED SHOTS. Twelve to a page, with back and next, so
// the page stays quick and the author is never scrolling through a wall.
var per = 12, pages = shots.length > 24 ? Math.ceil(shots.length / per) : 1;
var per = 12, pages = (shots.length > 24 && !hasScenes(shots)) ? Math.ceil(shots.length / per) : 1;
if (PAGE_FOR !== draft.id) { PAGE_FOR = draft.id; PAGE = 0; }
PAGE = Math.max(0, Math.min(PAGE, pages - 1));
var shown = pages > 1 ? shots.slice(PAGE * per, (PAGE + 1) * per) : shots;
@ -1038,9 +1047,13 @@
+ '</select> of ' + pages + '</span>'
+ '<button type="button" class="btn" data-page="next"' + (PAGE >= pages - 1 ? ' disabled' : '') + '>Next &#9654;</button></div>'
: "";
var body = pager + (PAGE === 0 ? insertHtml("start") : "");
var body;
if (hasScenes(shots)) body = scenesHtml(shots, voice, draft);
else {
body = pager + (PAGE === 0 ? insertHtml("start") : "");
shown.forEach(function (s) { body += shotHtml(s, voice) + insertHtml(s.id); });
body += pager;
}
DRAFTNOW = draft;
return head + partsHtml(draft) + contestHtml() + guideHtml(meta) + probs + '<div class="bar">' + barHtml(meta) + '</div>'
@ -1405,6 +1418,49 @@
return '<div class="bg">' + pic + '<div class="bgtxt">' + body + '</div></div>';
}
// SCENES. Consecutive shots in one place are a scene. Every scene is a bar
// the author opens when they want it - green when every shot in it is
// filmed and current, red when it still needs work - so nobody scrolls
// through seventy cards to find one.
function hasScenes(shots) { return shots.length > 8 && shots.filter(function (x) { return x.scene; }).length > shots.length / 2; }
function sceneKey(s) { return String(s.place || s.scene || s.setting || "").toLowerCase().replace(/^(int|ext)\.?\s*\/?\s*(int|ext)?\.?\s*/, "").replace(/\s*[-–—]\s*(day|night|dawn|dusk|morning|evening|afternoon|late afternoon|continuous|the storm|the tornado).*$/, "").replace(/[^a-z0-9]+/g, " ").trim(); }
function sceneGroups(shots) {
var groups = [], cur = null;
shots.forEach(function (s) {
var k = sceneKey(s);
if (!cur || cur.key !== k) { cur = { key: k, name: s.scene || s.setting || k, shots: [] }; groups.push(cur); }
cur.shots.push(s);
});
return groups;
}
function shotDone(s) { return !!s.clip_url && !s.clip_stale && !s.picture_stale; }
var OPEN = {};
try { OPEN = JSON.parse(localStorage.getItem("ptl_open_scenes") || "{}"); } catch (e) { OPEN = {}; }
function rememberOpen() { try { localStorage.setItem("ptl_open_scenes", JSON.stringify(OPEN)); } catch (e) {} }
var SHARES = {};
function scenesHtml(shots, voice, draft) {
var out = insertHtml("start");
sceneGroups(shots).forEach(function (g, gi) {
var filmed = g.shots.filter(function (x) { return x.clip_url; }).length, done = g.shots.every(shotDone);
var busy = g.shots.some(function (x) { return x.writing || x.drawing || stillFilming(x); });
var id = draft.id + "|" + gi + "|" + g.key, open = !!OPEN[id];
var secs = g.shots.reduce(function (a, x) { return a + Number(x.edit_seconds || 0); }, 0);
out += '<button type="button" class="scenebar ' + (done ? 'ok' : 'todo') + '" data-scene="' + esc(id) + '">'
+ '<span class="arrow">' + (open ? '&#9660;' : '&#9654;') + '</span>'
+ '<span class="n">Scene ' + (gi + 1) + '</span>'
+ '<span class="t">' + esc(g.name) + '</span>'
+ '<span class="st">shots ' + esc(g.shots[0].id) + '&ndash;' + esc(g.shots[g.shots.length - 1].id) + ' &middot; ' + g.shots.length + ' shot' + (g.shots.length === 1 ? '' : 's') + ' &middot; ' + secs.toFixed(0) + ' s &middot; '
+ (busy ? 'working&hellip;' : done ? 'all filmed' : filmed + ' of ' + g.shots.length + ' filmed') + '</span></button>';
if (open) {
var ids = g.shots.filter(function (x) { return x.clip_url; }).map(function (x) { return x.id; });
out += '<div class="sceneshare">' + (ids.length ? '<button type="button" class="btn small ghost" data-sharescene="' + esc(ids.join(",")) + '" data-scenelabel="' + esc("Scene " + (gi + 1) + " - " + g.name) + '">Share this scene (' + ids.length + ' clip' + (ids.length === 1 ? '' : 's') + ', played in order)</button>' : 'Nothing filmed in this scene yet.')
+ (SHARES["scene:" + ids.join(",")] ? ' <a class="sharelink" href="' + esc(SHARES["scene:" + ids.join(",")]) + '" target="_blank">' + esc(SHARES["scene:" + ids.join(",")]) + '</a>' : '') + '</div>';
g.shots.forEach(function (x) { out += shotHtml(x, voice) + insertHtml(x.id); });
}
});
return out;
}
function shotHtml(s, voice) {
if (s.writing) {
return '<div class="shot"><div class="num">' + esc(s.id) + '</div><div class="body">'
@ -1507,6 +1563,8 @@
+ (s.opening_url ? 'Redraw the shot picture' : 'Draw the shot picture (actors in the background)') + ' &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; about $' + filmCost(s).toFixed(2) + '</button>' : '')
+ (s.clip_url ? '<button type="button" class="btn" data-shareshot="' + esc(s.id) + '">Share this shot</button>'
+ (SHARES["shot:" + s.id] ? ' <a class="sharelink" href="' + esc(SHARES["shot:" + s.id]) + '" target="_blank">' + esc(SHARES["shot:" + s.id]) + '</a>' : '') : '')
+ (s.clip_url && s.take_url ? '<button type="button" class="btn" data-recut="' + esc(s.id)
+ '" title="Cuts and mixes the shot again from the footage already paid for">Cut it again from the same footage &middot; free</button>' : '')
+ chkHtml + eyeHtml
@ -1589,6 +1647,23 @@
if (r) { showMsg("msg", "Shot " + ssid + " is now " + Number(r.seconds).toFixed(1) + " s." + (PKG.shots.some(function (x) { return x.id === ssid && x.clip_url; }) ? " The clip was filmed at the old length; make the shot again to refilm." : ""), "ok"); await refresh(); }
return;
}
if ((el = hit("data-scene"))) { var sk = el.getAttribute("data-scene"); OPEN[sk] = !OPEN[sk]; rememberOpen(); openTab(CUR); return; }
if ((el = hit("data-shareshot"))) {
var shid = el.getAttribute("data-shareshot");
el.disabled = true;
r = await act("share_clips", { draft_id: CUR, shot_ids: [shid], label: "Shot " + shid });
if (r) { SHARES["shot:" + shid] = r.url; showMsg("msg", "Shot " + shid + ": " + r.url, "ok"); openTab(CUR); }
else el.disabled = false;
return;
}
if ((el = hit("data-sharescene"))) {
var sids = el.getAttribute("data-sharescene").split(",");
el.disabled = true;
r = await act("share_clips", { draft_id: CUR, shot_ids: sids, label: el.getAttribute("data-scenelabel") });
if (r) { SHARES["scene:" + sids.join(",")] = r.url; showMsg("msg", r.clips + " clips, played in order: " + r.url, "ok"); openTab(CUR); }
else el.disabled = false;
return;
}
if ((el = hit("data-bgedit"))) { BGEDIT = el.getAttribute("data-bgedit"); openTab(CUR); return; }
if ((el = hit("data-bgcancel"))) { BGEDIT = null; openTab(CUR); return; }
if ((el = hit("data-bggen"))) {