From 93c8068b90eb0a3ff647d50d34478badeb739b59 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 19 Jun 2026 10:01:21 +0300 Subject: [PATCH] Movie Editor: i2v anchors drop on the Plan, not the timeline The timeline is the rendered result, so setting up a generation must not touch it. Removed the clip-level anchor drop and the timeline track's image->anchored-scene drop. Image media now drops onto Plan cards (sets that scene's i2v anchor) or onto empty Plan space (creates a new anchored scene). Video clips (finished results) still drop on the timeline track. Plan-only image filter; teal drop-target hint. Co-Authored-By: Claude Opus 4.8 --- movie_editor/frontend/plan_strip.js | 23 ++++++++++++++++++++++- movie_editor/frontend/styles.css | 1 + movie_editor/frontend/timeline.js | 23 ++++++----------------- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/movie_editor/frontend/plan_strip.js b/movie_editor/frontend/plan_strip.js index 7ce6813b..178702d4 100644 --- a/movie_editor/frontend/plan_strip.js +++ b/movie_editor/frontend/plan_strip.js @@ -60,6 +60,24 @@ const l = el("label", "plan-field"); l.append(el("span", null, labelText)); l.append(control); return l; } + // i2v anchors are dropped on the PLAN (never the timeline). Accept image media only. + function attachAnchorDrop(node, st, onImage) { + node.addEventListener("dragover", (e) => { + if (!e.dataTransfer.types.includes("application/funpack-media")) return; + e.preventDefault(); e.dataTransfer.dropEffect = "copy"; node.classList.add("drop-target"); + }); + node.addEventListener("dragleave", () => node.classList.remove("drop-target")); + node.addEventListener("drop", (e) => { + node.classList.remove("drop-target"); + const id = e.dataTransfer.getData("application/funpack-media"); + if (!id) return; + const asset = (st.mediaBin || []).find((m) => m.id === id); + if (!asset || asset.kind !== "image") return; // images = anchors; videos go to the timeline + e.preventDefault(); e.stopPropagation(); + onImage(id); + }); + } + function cardEditor(st, sc) { const root = rootOf(sc); const ed = el("div", "plan-editor"); @@ -152,6 +170,7 @@ chev.onclick = (e) => { e.stopPropagation(); expandedId = isOpen ? null : sc.id; S.selectScene(sc.id); render(st); }; c.append(chev); } + if (!S.isVideoClip(sc)) attachAnchorDrop(c, st, (id) => S.assignMediaToScene(sc.id, id)); c.onclick = () => { if (st.selectedSceneId !== sc.id) S.selectScene(sc.id); }; return c; } @@ -166,9 +185,11 @@ const rowEl = el("div", "plan-row"); scenes.forEach((sc, i) => rowEl.append(cardEl(st, sc, i))); const add = el("button", "plan-add", "+ Add scene"); - add.title = "Add a new scene to the plan"; + add.title = "Add a new scene to the plan · or drop an image here to anchor a new scene"; add.onclick = () => S.addScene(); rowEl.append(add); + // Drop an image on empty plan space → a new scene anchored to it (cards stopPropagation). + attachAnchorDrop(rowEl, st, (id) => S.addImageClip(id)); body.append(rowEl); if (expandedId) { const sc = S.scene(expandedId); diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index cb5d5e09..39bece6a 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -1426,6 +1426,7 @@ body.tour-mode { overflow: hidden; } } .plan-card:hover { border-color: color-mix(in srgb, var(--amber) 50%, var(--line)); } .plan-card.sel, .plan-card.open { border-color: var(--amber); } +.plan-card.drop-target, .plan-row.drop-target { outline: 2px dashed var(--teal); outline-offset: -2px; border-radius: 8px; } .plan-thumb { width: 40px; height: 40px; flex: 0 0 40px; border-radius: 6px; overflow: hidden; background: var(--ink-3); display: flex; align-items: center; justify-content: center; } .plan-thumb img { width: 100%; height: 100%; object-fit: cover; } .plan-thumb-glyph { font-family: var(--font-mono); font-size: 15px; color: var(--faint); } diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index d581dac3..446b468d 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -600,16 +600,9 @@ }); } - // accept a media asset dragged from the bin → sets this clip's source - clip.addEventListener("dragover", (e) => { if (e.dataTransfer.types.includes("application/funpack-media")) { e.preventDefault(); clip.classList.add("drop-target"); } }); - clip.addEventListener("dragleave", () => clip.classList.remove("drop-target")); - clip.addEventListener("drop", (e) => { - const id = e.dataTransfer.getData("application/funpack-media"); - clip.classList.remove("drop-target"); - // Dropping on a clip sets ITS anchor; stop the event reaching the track-level - // drop (which would otherwise also create a brand-new clip). - if (id) { e.preventDefault(); e.stopPropagation(); S.assignMediaToScene(scene.id, id); } - }); + // i2v anchors are set on the PLAN, not here — the timeline is the rendered result. + // (Drop an image onto a Plan card to anchor that scene.) Video clips still drop on the + // timeline track below. // i2v anchor thumbnail (image + mixed + generated_frame) const mref = anchorMediaRef(scene, p); @@ -1878,14 +1871,10 @@ const asset = (st.mediaBin || []).find((m) => m.id === id); if (asset?.kind === "video") { e.preventDefault(); - S.addVideoClip(id); - } else if (asset?.kind === "image") { - // CapCut-style: drop an image on empty timeline → default-length generative - // scene anchored to it. (Dropping ON a clip sets that clip's anchor instead; - // that handler stops propagation so this one doesn't also fire.) - e.preventDefault(); - S.addImageClip(id); + S.addVideoClip(id); // a finished video is a result — it belongs on the timeline } + // Images are i2v ANCHORS — drop them on a Plan card (or the Plan strip) instead, so + // the timeline (the rendered result) is never changed by setting up a generation. }); lay.forEach(({ seg, o, d }) => { if (seg.kind === "gap") return;