From 45a3d8ac4c0866c500d54bb70c13386f69a577cd Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:14:54 +0300 Subject: [PATCH] feat(ui): generation progress + clock, Selected (N), frame/I/O/Cmd-comma keys, timeline follows playback, audio track window (volume, start, split, remove), Engine settings link Co-Authored-By: Claude Sonnet 5.5 --- app/ui/composer/elements/nle.js | 6 ++- app/ui/features/audio/tracks.js | 49 +++++++++++++++++++++++ app/ui/features/generate/hub.test.js | 2 +- app/ui/features/generate/index.js | 11 ++--- app/ui/features/generate/progress.js | 38 ++++++++++++++++++ app/ui/features/generate/progress.test.js | 9 +++++ app/ui/features/inspector/scene.js | 2 +- app/ui/features/keys/index.js | 10 +++++ app/ui/features/timeline/index.js | 10 ++--- app/ui/features/timeline/lanes.js | 6 --- app/ui/modules.js | 2 + app/ui/shell/audio.js | 12 ++++++ app/ui/shell/tests/audio.test.js | 10 ++++- 13 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 app/ui/features/audio/tracks.js create mode 100644 app/ui/features/generate/progress.js create mode 100644 app/ui/features/generate/progress.test.js diff --git a/app/ui/composer/elements/nle.js b/app/ui/composer/elements/nle.js index 058b6092..f3065dbf 100644 --- a/app/ui/composer/elements/nle.js +++ b/app/ui/composer/elements/nle.js @@ -155,7 +155,11 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init timeAt, setLanes(next = []) { lanes = next; draw(); }, setZoom(next) { px = Math.max(4, next); draw(); }, - setPlayhead(seconds) { playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`; }, + /** `follow`: scroll along when the playhead leaves the visible part (while playing). */ + setPlayhead(seconds, follow = false) { + playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`; + if (follow) { const x = Math.max(0, seconds) * px + gutter.offsetWidth, w = node.clientWidth; if (x > node.scrollLeft + w - 24 || x < node.scrollLeft + gutter.offsetWidth) node.scrollLeft = Math.max(0, x - w * 0.2); } + }, destroy() { disposers.forEach((d) => d()); if (observer) observer.disconnect(); node.remove(); }, }; }); diff --git a/app/ui/features/audio/tracks.js b/app/ui/features/audio/tracks.js new file mode 100644 index 00000000..7986621f --- /dev/null +++ b/app/ui/features/audio/tracks.js @@ -0,0 +1,49 @@ +// The audio lanes laid over the cut (files from the bin, and a clip's separated sound): drawn on the timeline, slid and trimmed there, +// and a window for each lane's volume, start, split and removal. +import { composer as c } from "../../composer/composer.js"; +import { moveLane, trimLane, removeTrack, splitTrack } from "../../shell/audio.js"; + +const ID = "t:"; + +export const tracksLane = (p) => (p.audio_tracks || []).length ? { + id: "tracks", label: "Audio", kind: "audio", move: true, + clips: p.audio_tracks.map((t) => ({ id: ID + t.id, start: t.start_sec || 0, dur: t.pinned_dur ?? t.source_dur ?? 1, title: t.label || "Audio", trim: t.kind === "separated" ? t.pinned_dur != null : t.source_dur != null })), +} : null; + +export default { + id: "audio-tracks", + mount: "timeline.toolbar", + needs: ["project", "playhead"], + setup({ app }) { + const p = app.project; + const edit = (fn) => p.edit(fn); + function open(id) { + const t = (p.project.audio_tracks || []).find((x) => x.id === id); + if (!t) return; + const separated = t.kind === "separated"; + let volume = t.volume != null ? t.volume : 1, start = t.start_sec || 0; + const body = c.region.stack({ gap: "sm", children: [ + c.hint.default({ text: separated ? "This clip's own sound. It follows its clip; slide or trim it on the timeline." : "A sound over the cut. Slide and trim it on the timeline." }), + c.field.default({ label: "Volume", control: c.slider.readout({ label: "Volume", value: volume, min: 0, max: 2, step: 0.05, precision: 2, onCommit: (v) => { volume = v; } }) }), + separated ? null : c.field.default({ label: "Starts at (s)", control: c.number.md({ label: "Starts at", value: start, min: 0, step: 0.1, onChange: (v) => { start = v; } }) }), + ].filter(Boolean) }); + const win = c.modal.generic({ title: t.label || "Audio track", size: "sm", body }); + win.setFooter({ actions: [ + c.button.sm({ label: "Remove", tone: "danger", onClick: async () => { if (await c.modal.dialogue({ title: "Remove audio track", message: separated ? "Remove this track? The clip gets its sound back." : "Remove this audio track?", tone: "danger", confirmLabel: "Remove" }).result) { edit((pr) => removeTrack(pr, id)); win.close("done"); } } }), + separated ? null : c.button.sm({ label: "Split at playhead", tone: "ghost", onClick: () => { if (edit((pr) => splitTrack(pr, id, app.playhead.at))) win.close("done"); else c.toast.warn({ text: "Put the playhead inside the track to split it." }); } }), + c.button.sm({ label: "Cancel", tone: "ghost", onClick: () => win.close("cancel") }), + c.button.sm({ label: "Save", tone: "primary", onClick: () => { edit((pr) => { const x = pr.audio_tracks.find((y) => y.id === id); if (!x) return false; x.volume = volume; if (!separated) x.start_sec = Math.max(0, start); return true; }); win.close("done"); } }), + ].filter(Boolean) }); + } + const entry = { + owns: (id) => id.startsWith(ID), + lanes: (pr) => [tracksLane(pr)].filter(Boolean), + select: (id) => open(id.slice(ID.length)), + move: (id, d) => edit((pr) => moveLane(pr, id.slice(ID.length), d)), + trim: (id, edge, d) => edit((pr) => trimLane(pr, id.slice(ID.length), edge, d)), + }; + app.timelineLanes.push(entry); + app.say("timeline.lanes"); + return () => { const i = app.timelineLanes.indexOf(entry); if (i >= 0) app.timelineLanes.splice(i, 1); app.say("timeline.lanes"); }; + }, +}; diff --git a/app/ui/features/generate/hub.test.js b/app/ui/features/generate/hub.test.js index 72555392..8e41d18e 100644 --- a/app/ui/features/generate/hub.test.js +++ b/app/ui/features/generate/hub.test.js @@ -14,7 +14,7 @@ const rig = (phase, extra = {}) => { waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra }; const project = { project: doc, get scenes() { return doc.scenes; }, selected: null, edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc) }; - const app = { project, lastRun: { n: 0 }, pipeline: { slots: () => [] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} }; + const app = { project, selection: { ids: [] }, lastRun: { n: 0 }, pipeline: { slots: () => [] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} }; const host = document.createElement("div"); hub.setup({ host, app }); return { handlers, doc, host }; diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 85d257e6..83cf5a14 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -16,7 +16,7 @@ export const renderFor = (sc, p, media, unitSec, promptId) => ({ media, ...(prom export default { id: "generate", mount: "timeline.actions", - needs: ["project", "pipeline", "generate", "api"], + needs: ["project", "pipeline", "generate", "api", "selection"], setup({ host, app }) { const p = app.project, g = app.generate; let said = false; @@ -79,19 +79,20 @@ export default { if (!made && !stopped && !said) tell("Nothing to generate: every scene is left out or is a video clip."); } finally { busy = false; draw(); } } + const pickedScenes = () => { const ids = new Set(app.selection.ids); const hit = p.scenes.filter((s) => ids.has(s.id)); return hit.length ? hit : p.selected ? [p.selected] : []; }; // every picked clip, else the one in focus const unitsOf = (scenes) => [...new Set(scenes.map(genUnitId))]; const put = inPlace(host); let drawn = ""; function draw() { const open = p.project; - const key = `${busy}|${held}|${g.state().phase}|${Boolean(open)}|${Boolean(p.selected)}`; + const picked = open ? pickedScenes().length : 0, key = `${busy}|${held}|${g.state().phase}|${Boolean(open)}|${picked}`; if (key === drawn) return; // progress ticks must not rebuild the buttons drawn = key; const working = busy || held || g.state().phase === QUEUED || g.state().phase === RUNNING; // also a run this page did not start const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open, onClick: () => runUnits(unitsOf(p.scenes)) }); - const one = c.button.sm({ label: "Selected", disabled: working || !p.selected, onClick: () => runUnits(unitsOf([p.selected])) }); + const one = c.button.sm({ label: picked > 1 ? `Selected (${picked})` : "Selected", disabled: working || !picked, onClick: () => runUnits(unitsOf(pickedScenes())) }); const stop = c.button.sm({ label: "■ Stop", tone: "danger", onClick: () => { stopped = true; g.cancel(); } }); stop.node.hidden = !working; put(go.node, one.node, stop.node); @@ -129,9 +130,9 @@ export default { }); draw(); const offRun = g.subscribe(draw); - const offApp = app.on((what) => { if (what === "generate.selected" && !busy && p.selected) runUnits(unitsOf([p.selected])); else draw(); }); + const offApp = app.on((what) => { if (what === "generate.selected" && !busy && pickedScenes().length) runUnits(unitsOf(pickedScenes())); else draw(); }); const offers = [offer(app, { id: "generate-all", label: "Generate", icon: "▶", run: () => { if (!busy && p.project) runUnits(unitsOf(p.scenes)); } }), - offer(app, { id: "generate-selected", label: "Generate selected", icon: "▶", run: () => { if (!busy && p.selected) runUnits(unitsOf([p.selected])); } })]; + offer(app, { id: "generate-selected", label: "Generate selected", icon: "▶", run: () => { if (!busy && pickedScenes().length) runUnits(unitsOf(pickedScenes())); } })]; return () => { offRun(); offApp(); offers.forEach((f) => f()); }; }, }; diff --git a/app/ui/features/generate/progress.js b/app/ui/features/generate/progress.js new file mode 100644 index 00000000..cc7bdb9c --- /dev/null +++ b/app/ui/features/generate/progress.js @@ -0,0 +1,38 @@ +// What a run is doing, beside Generate: the step it is on, a bar, and how long it has been going (this page's own clock). +import { composer as c } from "../../composer/composer.js"; +import { QUEUED, RUNNING } from "../../shell/run.js"; + +export const elapsed = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${String(Math.floor(s / 60)).padStart(2, "0")}m ${String(s % 60).padStart(2, "0")}s`; }; + +export default { + id: "generate-progress", + mount: "timeline.actions", + needs: ["generate"], + setup({ host, app }) { + const g = app.generate; + const bar = c.progress.bar({ value: 0, max: 100, label: "Generating" }); + const text = c.text.sm({ text: "" }); + const wrap = document.createElement("span"); + wrap.style.cssText = "display:inline-flex;align-items:center;gap:8px;min-inline-size:0"; + bar.node.style.inlineSize = "90px"; + wrap.append(bar.node, text.node); + wrap.hidden = true; + host.append(wrap); + let since = 0; + const draw = () => { + const s = g.state(), working = s.phase === QUEUED || s.phase === RUNNING; + if (working && !since) since = Date.now(); + if (!working) since = 0; + wrap.hidden = !working; + if (!working) { return; } + const pr = s.progress; + bar.setValue(pr && pr.max ? (pr.value / pr.max) * 100 : 0); + text.setText(`${s.phase === QUEUED ? "Queued" : pr && pr.max ? `Step ${pr.value}/${pr.max}` : "Running"} · ${elapsed(Date.now() - since)}`); + + }; + const off = g.subscribe(draw); + const timer = setInterval(() => { if (since) draw(); }, 1000); // the clock ticks between ComfyUI's messages + draw(); + return () => { off(); clearInterval(timer); wrap.remove(); }; + }, +}; diff --git a/app/ui/features/generate/progress.test.js b/app/ui/features/generate/progress.test.js new file mode 100644 index 00000000..a51bdb6c --- /dev/null +++ b/app/ui/features/generate/progress.test.js @@ -0,0 +1,9 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { elapsed } from "./progress.js"; + +test("elapsed reads as minutes and seconds", () => { + assert.equal(elapsed(0), "00m 00s"); + assert.equal(elapsed(72_900), "01m 12s"); + assert.equal(elapsed(-5), "00m 00s"); +}); diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index b09c3e60..f794832d 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -31,7 +31,7 @@ export function sceneRows(p, app) { if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })]; const cuts = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).length > 1; return [ - c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick() {} } }), + c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }), ...(cuts ? [c.hint.default({ text: "This scene has editorial cuts — Generate regens the whole uncut scene." })] : []), ...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [ c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }), diff --git a/app/ui/features/keys/index.js b/app/ui/features/keys/index.js index 5b3f9a63..ed1c7db6 100644 --- a/app/ui/features/keys/index.js +++ b/app/ui/features/keys/index.js @@ -1,5 +1,6 @@ // v4's editing keys: undo/redo, delete the selected scene, +/- zoom the timeline, space/K/L play and pause, J back a second, S split. Scene keys do nothing in Simple mode, where no timeline shows. import * as edits from "../../shell/edits.js"; +import { segments } from "../../shell/scenes.js"; export default { id: "keys", @@ -8,6 +9,12 @@ export default { setup({ app }) { const p = app.project, sel = app.selection; const scene = (fn) => () => (app.mode.now === "simple" ? false : fn()); + const frame = () => (p.project && p.project.frame_rate) || 25; + const edge = (which) => { // I / O: the playhead to the picked clip's in / out point + const s = p.project && segments(p.project).find((x) => x.kind === "scene" && x.id === sel.focus); + if (!s) return false; + app.playhead.set(which === "start" ? s.start : s.start + s.dur); + }; const remove = scene(() => sel.ids.length ? sel.ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))) : false); const off = [ app.keys.bind("mod+z", () => p.undo()), @@ -19,6 +26,9 @@ export default { app.keys.bind(" ", (e) => (e.target.closest && e.target.closest("button, a, [role=slider]") ? false : app.say("play.toggle"))), app.keys.bind("k", () => app.say("play.pause")), app.keys.bind("l", () => app.say("play.start")), app.keys.bind("j", () => app.playhead.set(app.playhead.at - 1)), app.keys.bind("s", scene(() => app.say("split"))), + app.keys.bind("arrowleft", scene(() => app.playhead.set(app.playhead.at - 1 / frame()))), app.keys.bind("arrowright", scene(() => app.playhead.set(app.playhead.at + 1 / frame()))), + app.keys.bind("i", scene(() => edge("start"))), app.keys.bind("o", scene(() => edge("end"))), + app.keys.bind("mod+,", () => (app.openSettings ? app.openSettings() : false)), ]; return () => off.forEach((f) => f()); }, diff --git a/app/ui/features/timeline/index.js b/app/ui/features/timeline/index.js index bdee1fd9..272ae6c3 100644 --- a/app/ui/features/timeline/index.js +++ b/app/ui/features/timeline/index.js @@ -5,8 +5,7 @@ import { composer as c } from "../../composer/composer.js"; import { segments, totalSeconds, clock } from "../../shell/scenes.js"; import { inPlace } from "../../shell/place.js"; import * as edits from "../../shell/edits.js"; -import { moveLane, trimLane } from "../../shell/audio.js"; -import { videoLane, audioLane, tracksLane, framesAt } from "./lanes.js"; +import { videoLane, audioLane, framesAt } from "./lanes.js"; const ZOOM = [20, 40, 80, 160, 320]; const sceneIds = (p) => segments(p).filter((s) => s.kind === "scene").map((s) => s.id); @@ -25,11 +24,10 @@ const stage = { pxPerSecond: ZOOM[zoom], onSeek: (sec) => head.set(sec), onSelect: (id, how) => { const own = owner(id); if (own) own.select(id); else sel.pick(id, how, sceneIds(p.project)); }, - onMove: (id, d) => { const own = owner(id); if (own) own.move(id, d); else id.startsWith("t:") && edit((pr) => moveLane(pr, id.slice(2), d)); }, + onMove: (id, d) => { const own = owner(id); if (own) own.move(id, d); }, onTrim: (id, edge, d) => { const own = owner(id); if (own) return own.trim(id, edge, d); - if (id.startsWith("t:")) return edit((pr) => trimLane(pr, id.slice(2), edge, d)); const dur = segments(p.project).find((s) => s.id === id).dur; edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d) : d > 0 && edits.trimLeft(pr, id, d)); // the left edge only cuts in }, @@ -44,14 +42,14 @@ const stage = { const ghostActions = (g) => [{ label: "✕", title: "Remove from the timeline", onClick: () => edit((pr) => { pr.scene_ghosts = (pr.scene_ghosts || []).filter((x) => x.id !== g.id); return true; }) }]; - const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions, (sc) => (app.clipTags || []).flatMap((f) => { try { return f(sc); } catch { return []; } })), audioLane(open, sel.ids), tracksLane(open), ...extra.flatMap((e) => { try { return e.lanes(open); } catch { return []; } })].filter(Boolean) : []); }; // a feature's lanes failing must not stop the baseline's + const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions, (sc) => (app.clipTags || []).flatMap((f) => { try { return f(sc); } catch { return []; } })), audioLane(open, sel.ids), ...extra.flatMap((e) => { try { return e.lanes(open); } catch { return []; } })].filter(Boolean) : []); }; // a feature's lanes failing must not stop the baseline's const setZoom = (z) => { zoom = Math.min(ZOOM.length - 1, Math.max(0, z)); view.setZoom(ZOOM[zoom]); }; host.append(view.node); const off = [app.on((what) => { if (what === "zoom.in") setZoom(zoom + 1); else if (what === "zoom.out") setZoom(zoom - 1); else if (what === "zoom.fit") { const total = p.project ? totalSeconds(p.project) : 0; const fit = ZOOM.filter((z) => z * (total + 2) <= view.node.clientWidth); setZoom(fit.length ? ZOOM.indexOf(fit[fit.length - 1]) : 0); } else draw(); - }), head.on((sec) => view.setPlayhead(sec))]; + }), head.on((sec) => view.setPlayhead(sec, head.playing))]; draw(); return () => { off.forEach((f) => f()); view.destroy(); }; }, diff --git a/app/ui/features/timeline/lanes.js b/app/ui/features/timeline/lanes.js index 02220c93..7515d2e2 100644 --- a/app/ui/features/timeline/lanes.js +++ b/app/ui/features/timeline/lanes.js @@ -31,9 +31,3 @@ export const audioLane = (p, picked) => ({ id: "audio", label: "Original", kind: "audio", clips: segments(p).filter((s) => s.kind === "scene").map((s, i) => ({ id: `a:${s.id}`, start: s.start, dur: s.dur, title: `S${i + 1}`, selected: picked.includes(s.id), excluded: Boolean(s.scene.excluded) })), }); - -/** Audio lanes added to the project (a clip's separated sound, or a file laid over the cut), when there are any. */ -export const tracksLane = (p) => (p.audio_tracks || []).length ? { - id: "tracks", label: "Audio", kind: "audio", move: true, - clips: p.audio_tracks.map((t) => ({ id: `t:${t.id}`, start: t.start_sec || 0, dur: t.pinned_dur ?? t.source_dur ?? 1, title: t.label || "Audio", trim: t.kind === "separated" ? t.pinned_dur != null : t.source_dur != null })), -} : null; diff --git a/app/ui/modules.js b/app/ui/modules.js index e62115a0..34c2a45c 100644 --- a/app/ui/modules.js +++ b/app/ui/modules.js @@ -24,6 +24,7 @@ export default [ "./features/wheel/index.js", "./features/status/index.js", "./features/audio/index.js", + "./features/audio/tracks.js", "./features/imports/index.js", "./features/clipfx/index.js", "./features/overlays/index.js", @@ -32,6 +33,7 @@ export default [ "./features/preview/fx.js", "./features/montage/index.js", "./features/generate/warn.js", + "./features/generate/progress.js", "./features/composer/index.js", "./features/settings/index.js", "./features/welcome/index.js", diff --git a/app/ui/shell/audio.js b/app/ui/shell/audio.js index f9a31bc1..73b8d1e8 100644 --- a/app/ui/shell/audio.js +++ b/app/ui/shell/audio.js @@ -101,3 +101,15 @@ export function trimLane(p, id, edge, deltaSec) { t.offset_sec = (t.offset_sec || 0) + d; return true; } + +/** Cut a file lane in two at `atSec` (timeline seconds); both halves keep playing what they did. -> the new (later) lane, or false. */ +export function splitTrack(p, id, atSec) { + const t = (p.audio_tracks || []).find((x) => x.id === id); + if (!t || t.kind === "separated" || t.source_dur == null) return false; + const start = t.start_sec || 0, cut = atSec - start; + if (!(cut > 0.05 && cut < t.source_dur - 0.05)) return false; + const tail = { ...t, id: newId(), start_sec: atSec, source_in_sec: (t.source_in_sec || 0) + cut, source_dur: t.source_dur - cut }; + t.source_dur = cut; + p.audio_tracks = [...p.audio_tracks, tail]; + return tail; +} diff --git a/app/ui/shell/tests/audio.test.js b/app/ui/shell/tests/audio.test.js index 30865432..422794be 100644 --- a/app/ui/shell/tests/audio.test.js +++ b/app/ui/shell/tests/audio.test.js @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { hasEmbeddedAudio, moveLane, removeTrack, trimLane, separate, syncSeparated, trackFor } from "../audio.js"; +import { hasEmbeddedAudio, moveLane, removeTrack, splitTrack, trimLane, separate, syncSeparated, trackFor } from "../audio.js"; const media = { filename: "a.mp4", subfolder: "", type: "output" }; const proj = () => ({ num_frames_per_scene: 50, frame_rate: 25, scenes: [{ id: "a", text: "" }, { id: "b", text: "", audio_volume: 0.5 }, { id: "v", source: { type: "video", media_ref: "m1" }, source_dur: 3 }], @@ -138,3 +138,11 @@ test("a file lane slides and trims on its own numbers", () => { trimLane(p, "f", "out", -9); assert.equal(p.audio_tracks[0].source_dur, 0.1); }); + +test("splitTrack cuts a file lane in two where the playhead is; separated lanes and cuts at the edge are refused", () => { + const p = { audio_tracks: [{ id: "f", kind: "overlay", media_ref: "m", start_sec: 1, source_in_sec: 2, source_dur: 4, volume: 0.5 }, { id: "s", kind: "separated", pinned_dur: 3 }] }; + assert.equal(splitTrack(p, "f", 1.01), false); + const tail = splitTrack(p, "f", 3); + assert.deepEqual([p.audio_tracks[0].source_dur, tail.start_sec, tail.source_in_sec, tail.source_dur, tail.volume], [2, 3, 4, 2, 0.5]); + assert.equal(splitTrack(p, "s", 1), false); +});