feat(ui): Export and Save to media bin on the picked clips
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
e9d0eb226d
commit
7ac7d25a95
@@ -16,7 +16,7 @@ const items = {
|
||||
],
|
||||
|
||||
"timeline.actions": () => [btn("⚡ Auto Montage")],
|
||||
"timeline.toolbar": () => [btn("⤓ Export"), btn("Save to media bin"), btn("⊟ Separate audio"), btn("Remove audio")],
|
||||
"timeline.toolbar": () => [btn("⊟ Separate audio"), btn("Remove audio")],
|
||||
};
|
||||
|
||||
export default Object.entries(items).map(([mount, build]) => ({
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import { segments, isVideoClip, effFps } from "../../shell/scenes.js";
|
||||
|
||||
/** -> { clips, missing }: `missing` counts scenes that have nothing to play yet (no render). */
|
||||
export function clipSpecs(p) {
|
||||
export function clipSpecs(p, only) { // `only`: a Set of scene ids to keep (the picked clips)
|
||||
const renders = p.scene_renders || {};
|
||||
const clips = [];
|
||||
let missing = 0;
|
||||
for (const seg of segments(p)) {
|
||||
if (seg.kind !== "scene" || (seg.scene.excluded && !seg.scene.removed_from_plan)) continue; // removed from the plan still plays
|
||||
if (only && !only.has(seg.scene.id)) continue;
|
||||
const sc = seg.scene, r = renders[sc.id], base = { scene_id: sc.id, dur: seg.dur, fps: effFps(sc, p), gap_after: Math.max(0, +sc.gap_after_sec || 0) };
|
||||
if (isVideoClip(sc)) {
|
||||
if (!(sc.source && sc.source.media_ref)) { missing += 1; continue; }
|
||||
|
||||
@@ -14,3 +14,9 @@ test("playing clips come out in order; scenes with nothing to play are counted,
|
||||
assert.equal(clips[1].gap_after, 1.5, "a pause after a scene is the clip's gap_after");
|
||||
assert.deepEqual([clips[2].bin_media_ref, clips[2].in, clips[2].dur], ["m1", 1, 2]);
|
||||
});
|
||||
|
||||
test("only the picked clips are kept, still in timeline order, and unpicked ones are not counted as missing", () => {
|
||||
const { clips, missing } = clipSpecs(p, new Set(["v", "a"]));
|
||||
assert.deepEqual(clips.map((c) => c.scene_id), ["a", "v"]);
|
||||
assert.equal(missing, 0);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { call } from "../../shell/api.js";
|
||||
import { clipSpecs } from "./clips.js";
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
import { runJob } from "./job.js";
|
||||
|
||||
export default {
|
||||
id: "render",
|
||||
@@ -21,9 +20,7 @@ export default {
|
||||
busy = true; draw();
|
||||
try {
|
||||
await p.flush();
|
||||
const { job_id: job } = await call("POST", `/api/projects/${open.id}/render`, { clips });
|
||||
let state;
|
||||
do { await wait(1500); state = await call("GET", `/api/projects/${open.id}/render/${job}`); } while (state.state === "queued" || state.state === "running");
|
||||
const state = await runJob(`/api/projects/${open.id}/render`, { clips });
|
||||
if (state.state !== "done") return tell(state.detail || "The render failed.");
|
||||
const { media } = await call("POST", "/api/import-clip", { clip: state.media, name: `${open.name}.mp4` });
|
||||
if (app.say) app.say("media"); // the bin shows it now
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// A render-style job: start it, poll it until it stops. -> the final status ({state, detail, media}).
|
||||
import { call } from "../../shell/api.js";
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export async function runJob(path, body) {
|
||||
const { job_id: job } = await call("POST", path, body);
|
||||
let state;
|
||||
do { await wait(1500); state = await call("GET", `${path}/${job}`); } while (state.state === "queued" || state.state === "running");
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Export and Save to media bin: the picked clips, joined in timeline order, as one file. Nothing else is applied.
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { call } from "../../shell/api.js";
|
||||
import { viewUrl } from "../../shell/run.js";
|
||||
import { inPlace } from "../../shell/place.js";
|
||||
import { clipSpecs } from "./clips.js";
|
||||
import { runJob } from "./job.js";
|
||||
|
||||
export default {
|
||||
id: "render-selected",
|
||||
mount: "timeline.toolbar",
|
||||
needs: ["project", "selection"],
|
||||
setup({ host, app }) {
|
||||
const p = app.project, sel = app.selection;
|
||||
const put = inPlace(host);
|
||||
let busy = false;
|
||||
const tell = (text) => c.toast.warn({ text });
|
||||
|
||||
// -> the joined file's media, or null (already told why)
|
||||
async function join() {
|
||||
const open = p.project, { clips, missing } = clipSpecs(open, new Set(sel.ids));
|
||||
if (!clips.length) return tell("Generate the picked clip(s) first, or pick a video clip that has media."), null;
|
||||
if (missing && !(await c.modal.dialogue({ title: "Some clips have no render", message: `${missing} picked clip(s) have no render yet and will be skipped. Continue with ${clips.length}?`, confirmLabel: "Continue" }).result)) return null;
|
||||
await p.flush();
|
||||
const state = await runJob(`/api/projects/${open.id}/export-clips`, { clips });
|
||||
return state.state === "done" ? state.media : (tell(state.detail || "The export failed."), null);
|
||||
}
|
||||
const run = (work) => async () => {
|
||||
if (busy) return;
|
||||
busy = true; draw();
|
||||
try { await work(); } catch (err) { tell(err.message); } finally { busy = false; draw(); }
|
||||
};
|
||||
const exportIt = run(async () => {
|
||||
const media = await join();
|
||||
if (!media) return;
|
||||
const link = Object.assign(document.createElement("a"), { href: viewUrl(media), download: `${p.project.name}.mp4` });
|
||||
link.click();
|
||||
});
|
||||
const toBin = run(async () => {
|
||||
const media = await join();
|
||||
if (!media) return;
|
||||
const { media: entry } = await call("POST", "/api/import-clip", { clip: media, name: `${p.project.name}.mp4` });
|
||||
app.say("media");
|
||||
c.toast.good({ text: `Saved to the media bin as ${entry.name}.` });
|
||||
});
|
||||
function draw() {
|
||||
const off = busy || !p.project || !sel.ids.length;
|
||||
put(c.button.sm({ label: "⤓ Export", tone: "ghost", disabled: off, onClick: exportIt, title: "Join the picked clips and download them" }).node,
|
||||
c.button.sm({ label: "Save to media bin", tone: "ghost", disabled: off, onClick: toBin, title: "Join the picked clips and keep them in the media bin" }).node);
|
||||
}
|
||||
draw();
|
||||
return app.on(draw);
|
||||
},
|
||||
};
|
||||
@@ -13,6 +13,7 @@ export default [
|
||||
"./features/preview/index.js",
|
||||
"./features/menus/index.js",
|
||||
"./features/render/index.js",
|
||||
"./features/render/selected.js",
|
||||
"./features/placeholders/index.js",
|
||||
"./features/generate/warn.js",
|
||||
"./features/composer/index.js",
|
||||
|
||||
Reference in New Issue
Block a user