Add pasteable owner photos with descriptions and AI placement

This commit is contained in:
gator 2026-09-07 00:43:52 +00:00
parent c0a7f46094
commit 6fccc3eb3b

View file

@ -23,6 +23,14 @@
.mini span{color:var(--muted);font-weight:400} .mini span{color:var(--muted);font-weight:400}
.shot textarea{min-height:62px} .shot textarea{min-height:62px}
.warn{background:#2a2112;border:1px solid #5b4520;color:#f0d9a7;border-radius:10px;padding:13px 16px;margin-bottom:16px;font-size:14px} .warn{background:#2a2112;border:1px solid #5b4520;color:#f0d9a7;border-radius:10px;padding:13px 16px;margin-bottom:16px;font-size:14px}
.drop{border:1px dashed var(--line);border-radius:12px;padding:26px 20px;text-align:center;color:var(--muted);
background:#0f1117;cursor:pointer;font-size:14.5px}
.drop.hot{border-color:var(--accent);color:var(--ink)}
.pics{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:14px;margin-top:16px}
.pic img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:9px;border:1px solid var(--line);display:block}
.pic input{margin-top:6px;font-size:13px;padding:7px 9px}
.pic .rm{background:none;border:0;color:var(--muted);cursor:pointer;font:11px/1.6 var(--mono);text-decoration:underline;padding:0}
.place{border-top:1px solid var(--line);padding:8px 0;font:12.5px/1.6 var(--mono);color:var(--muted)}
.full{background:#0f1117;border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:24px; .full{background:#0f1117;border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin-bottom:24px;
color:var(--muted);font-size:15.5px;line-height:1.7} color:var(--muted);font-size:15.5px;line-height:1.7}
</style> </style>
@ -171,6 +179,22 @@ function render(r) {
return '<option value="' + esc(c) + '">'; }).join("") + '</datalist>'; return '<option value="' + esc(c) + '">'; }).join("") + '</datalist>';
// Post production only exists once there is footage to cut. // Post production only exists once there is footage to cut.
// The owner's own photographs, scoped to this video only. Described once,
// then matched to shots by reading the script rather than hand-assigned.
var own = locked ? '' :
'<h2 class="sec" style="margin-top:34px">Your own pictures</h2>'
+ '<div class="card">'
+ '<p class="foot" style="margin:0 0 14px">Paste or drop photographs here. Describe each one, '
+ 'and they can be cut in where the script talks about them, or laid across a join to cover it.</p>'
+ '<div class="drop" id="drop" tabindex="0">Paste an image, drop one here, or click to choose</div>'
+ '<input type="file" id="picker" accept="image/*" multiple style="display:none">'
+ '<div class="pics" id="pics"></div>'
+ '<div style="margin-top:16px">'
+ '<button type="button" class="btn ghost" id="suggest">Suggest where they fit</button>'
+ '<span id="sugnote" class="foot" style="margin-left:12px"></span></div>'
+ '<div id="places"></div>'
+ '</div>';
var post = ''; var post = '';
if (p.status === 'ready' || p.cut_url) { if (p.status === 'ready' || p.cut_url) {
post = '<h2 class="sec" style="margin-top:34px">The cut</h2><div class="card">' post = '<h2 class="sec" style="margin-top:34px">The cut</h2><div class="card">'
@ -202,13 +226,123 @@ function render(r) {
out.innerHTML = cams + head + warn + full out.innerHTML = cams + head + warn + full
+ '<h2 class="sec">The shots</h2>' + '<h2 class="sec">The shots</h2>'
+ s.segments.map(function (x) { return shotHtml(x, locked); }).join("") + s.segments.map(function (x) { return shotHtml(x, locked); }).join("")
+ own
+ post + post
+ actions; + actions;
wire(locked); wire(locked);
} }
var PICS = [], SUGGESTED = [];
function renderPics() {
var el = document.getElementById("pics");
if (!el) return;
el.innerHTML = PICS.map(function (p) {
return '<div class="pic">'
+ '<img src="' + esc(p.url) + '" alt="' + esc(p.description || "") + '">'
+ '<input type="text" value="' + esc(p.description || "") + '" readonly>'
+ '<button type="button" class="rm" data-rmpic="' + esc(p.id) + '">remove</button>'
+ '</div>';
}).join("");
[].forEach.call(el.querySelectorAll("[data-rmpic]"), function (b) {
b.onclick = async function () {
var r = await api("delete_project_image", { email: email, image_id: b.getAttribute("data-rmpic") });
if (!r.ok) { showMsg("msg", r.error || "Could not remove that."); return; }
await loadPics();
};
});
}
async function loadPics() {
var r = await api("list_project_images", { email: email, project_id: pid });
PICS = (r.ok && r.images) || [];
renderPics();
}
async function addPic(file) {
hideMsg("msg");
if (!file || !/^image\//.test(file.type)) { showMsg("msg", "That is not an image."); return; }
// Asked for up front: a picture with no description cannot be matched to
// anything, so it would just sit there unused.
var desc = prompt("What is in this picture? A short description is what lets it be matched to the script.");
if (!desc || desc.trim().length < 3) { showMsg("msg", "A description is needed to place the picture."); return; }
var drop = document.getElementById("drop");
if (drop) drop.textContent = "Uploading…";
var img;
try { img = await resizeImage(file, 1600, 0.85); }
catch (e) { showMsg("msg", e.message || "Could not read that image."); return; }
var r = await api("add_project_image", { email: email, project_id: pid,
description: desc.trim(), data_b64: img.b64 });
if (drop) drop.textContent = "Paste an image, drop one here, or click to choose";
if (!r.ok) { showMsg("msg", r.error || "That upload did not work."); return; }
await loadPics();
}
function bindPics() {
var drop = document.getElementById("drop");
if (!drop) return;
var picker = document.getElementById("picker");
drop.onclick = function () { picker.click(); };
picker.onchange = function () {
[].forEach.call(picker.files || [], addPic);
picker.value = "";
};
drop.addEventListener("dragover", function (e) { e.preventDefault(); drop.classList.add("hot"); });
drop.addEventListener("dragleave", function () { drop.classList.remove("hot"); });
drop.addEventListener("drop", function (e) {
e.preventDefault(); drop.classList.remove("hot");
[].forEach.call((e.dataTransfer && e.dataTransfer.files) || [], addPic);
});
// Paste anywhere on the page, which is how people actually do this.
document.addEventListener("paste", function (e) {
var items = (e.clipboardData && e.clipboardData.items) || [];
for (var i = 0; i < items.length; i++) {
if (items[i].type && items[i].type.indexOf("image") === 0) {
var f = items[i].getAsFile();
if (f) { e.preventDefault(); addPic(f); }
return;
}
}
});
var sg = document.getElementById("suggest");
if (sg) sg.onclick = async function () {
hideMsg("msg");
if (!PICS.length) { showMsg("msg", "Add a picture first."); return; }
sg.disabled = true; sg.textContent = "Reading the script…";
var r = await api("suggest_image_placements", { email: email, project_id: pid });
sg.disabled = false; sg.textContent = "Suggest where they fit";
document.getElementById("sugnote").textContent = r.status_text || "";
if (!r.ok) { showMsg("msg", r.error || "Could not suggest."); return; }
SUGGESTED = r.placements || [];
var byId = {};
PICS.forEach(function (p) { byId[p.id] = p; });
document.getElementById("places").innerHTML = SUGGESTED.length
? SUGGESTED.map(function (x) {
var p = byId[x.image_id] || {};
return '<div class="place">shot ' + x.index + ' · '
+ esc((p.description || "").slice(0, 60)) + ' — ' + esc(x.why || "") + '</div>';
}).join("")
+ '<button type="button" class="btn" id="applyp" style="margin-top:14px">Use these</button>'
: '<p class="foot">No picture fitted any shot well enough to use.</p>';
var ap = document.getElementById("applyp");
if (ap) ap.onclick = async function () {
ap.disabled = true; ap.textContent = "Applying…";
var res = await api("apply_image_placements", { email: email, project_id: pid,
placements: SUGGESTED });
if (!res.ok) { ap.disabled = false; ap.textContent = "Use these"; showMsg("msg", res.error || "Could not apply."); return; }
showMsg("msg", res.status_text || "Placed.", "ok");
load();
};
};
loadPics();
}
function wire(locked) { function wire(locked) {
bindPics();
bindPost(); bindPost();
if (locked) { bindBottom(); return; } if (locked) { bindBottom(); return; }