Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ npm run preview
- Append `?editor=1` in dev builds to open the Scene + Timeline Editor (or toggle "Editor mode" in the debug overlay). The editor shows a live preview, edits hot-apply to the running demo, and changes persist to localStorage.
- The timeline editor layout uses a narrow Scenes sidebar, a center workspace with Preview plus a single accordion stack for Basic/Main Slots/Transition, and a right Inspector for scene/layer/cue editing. The Generate Text Cues tool opens from a bottom-left button as a modal.
- The timeline area now dedicates at least half the editor height, renders all scenes on the main track, and adds extra rows for selected-scene layers and scene automation entries.
- Automation clip editing foundations now treat automation as point-driven envelopes (per-segment curve type + tension metadata), with helpers for snapped point add/remove/move, slide-mode temporal shifting, and segment-level curve/tension updates for timeline UI integration.
- While zoomed in, use **Shift + mouse wheel** (or horizontal trackpad scroll) to pan left/right across the playlist without resetting zoom.
- Playlist quick controls: mouse wheel = zoom to cursor, Shift+wheel/horizontal scroll = pan timeline, drag empty lane = scrub pan.
- The playlist now includes a dedicated timeline scrollbar under the lanes; the thumb shrinks as you zoom in and can be dragged/clicked for coarse navigation across the full song length.
Expand Down
8 changes: 8 additions & 0 deletions src/config/loadConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ export type RawParamAutomation = {
t0: number | string;
t1: number | string;
ease?: string;
clip?: {
mode?: "free" | "step";
points?: Array<{ time: number; value: number }>;
segmentMeta?: Array<{
curveType?: "Linear" | "SingleCurve" | "DoubleCurve" | "Hold" | "Stairs" | "Smooth" | "Pulse" | "Wave";
tension?: number;
}>;
};
};

export type TransitionConfig = {
Expand Down
29 changes: 28 additions & 1 deletion src/editor/EditorRoot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ import {
splitCueWords,
generateWordTextCues
,
sortScenesForEditorList
sortScenesForEditorList,
toAutomationClip,
applyClipToAutomationEntry
} from "./EditorRoot";

describe("formatTime", () => {
Expand Down Expand Up @@ -117,6 +119,31 @@ describe("buildEditorTimelineTracks", () => {
});
});

describe("automation clip conversion helpers", () => {
it("builds a fallback clip from standard automation fields", () => {
const clip = toAutomationClip({ param: "speed", from: 0.2, to: 0.8, t0: 10, t1: 12 });
expect(clip.points).toEqual([
{ time: 10, value: 0.2 },
{ time: 12, value: 0.8 }
]);
});

it("writes clip metadata back to automation entry fields", () => {
const clip = toAutomationClip({ param: "speed", from: 0, to: 1, t0: 0, t1: 1 });
clip.points = [
{ time: 8, value: 0.1 },
{ time: 9, value: 0.5 },
{ time: 11, value: 0.9 }
];
const next = applyClipToAutomationEntry({ param: "speed", from: 0, to: 1, t0: 0, t1: 1 }, clip);
expect(next.from).toBe(0.1);
expect(next.to).toBe(0.9);
expect(next.t0).toBe(8);
expect(next.t1).toBe(11);
expect(next.clip?.points).toHaveLength(3);
});
});

describe("getSceneTimelineClips", () => {
it("returns sorted scene clips with computed end values", () => {
const clips = getSceneTimelineClips(
Expand Down
165 changes: 165 additions & 0 deletions src/editor/EditorRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ import {
import { clearTimelineDraft, downloadTimeline, loadTimelineDraft, saveTimelineDraft } from "./serialization";
import { getManifestDebugConfig } from "../renderer/effects/manifest";
import { transitionOptions } from "../renderer/transitions";
import {
createAutomationClip,
insertPoint,
movePoint,
removePoint,
setSegmentCurveType,
setSegmentTension,
type AutomationClip,
type CurveType
} from "./automationClip";

const ERA_PRESETS: EraPreset[] = ["8bit", "16bit", "ps1", "pcdemo", "future"];
const BLEND_MODES: BlendMode[] = [
Expand Down Expand Up @@ -69,6 +79,16 @@ const EASE_NAMES = [
"easeOutElastic",
"easeInOutCirc"
];
const CURVE_TYPE_NAMES: CurveType[] = [
"Linear",
"SingleCurve",
"DoubleCurve",
"Hold",
"Stairs",
"Smooth",
"Pulse",
"Wave"
];

type EditorState = {
timeline: RawTimelineConfig | null;
Expand Down Expand Up @@ -678,6 +698,42 @@ export const generateWordTextCues = (options: BulkCueGenerationOptions): RawText
});
};

export const toAutomationClip = (entry: RawParamAutomation): AutomationClip => {
const t0 = parseTimelineTimeValue(entry.t0);
const t1 = parseTimelineTimeValue(entry.t1);
const fallback = createAutomationClip([
{ time: t0, value: entry.from },
{ time: t1, value: entry.to }
]);
if (!entry.clip?.points || entry.clip.points.length < 2) {
return fallback;
}
const clip = createAutomationClip(entry.clip.points, entry.clip.mode ?? "free");
clip.segmentMeta = clip.segmentMeta.map((meta, index) => ({
curveType: entry.clip?.segmentMeta?.[index]?.curveType ?? meta.curveType,
tension: entry.clip?.segmentMeta?.[index]?.tension ?? meta.tension
}));
return clip;
};

export const applyClipToAutomationEntry = (entry: RawParamAutomation, clip: AutomationClip): RawParamAutomation => {
const points = clip.points;
const first = points[0];
const last = points[points.length - 1];
return {
...entry,
from: first.value,
to: last.value,
t0: first.time,
t1: last.time,
clip: {
mode: clip.mode,
points: points.map((point) => ({ ...point })),
segmentMeta: clip.segmentMeta.map((meta) => ({ ...meta }))
}
};
};

export async function createEditorRoot(init: EditorInit): Promise<EditorController> {
const state: EditorState = {
timeline: null,
Expand Down Expand Up @@ -2056,6 +2112,19 @@ export async function createEditorRoot(init: EditorInit): Promise<EditorControll
<button type="button" data-action="move-up">↑</button>
<button type="button" data-action="move-down">↓</button>
<button type="button" data-action="delete">Delete</button>
<details class="editor-automation-clip">
<summary>Edit clip points</summary>
<div class="editor-automation-clip-actions">
<button type="button" data-action="clip-add-point">+ Point @ mid</button>
<button type="button" data-action="clip-remove-point">- Last point</button>
</div>
<label>
<span>Slide mode</span>
<input type="checkbox" data-action="clip-slide-mode" />
</label>
<div class="editor-automation-clip-points"></div>
<div class="editor-automation-clip-segments"></div>
</details>
</div>`
)
.join("")}
Expand Down Expand Up @@ -2106,6 +2175,102 @@ export async function createEditorRoot(init: EditorInit): Promise<EditorControll
[next[index + 1], next[index]] = [next[index], next[index + 1]];
onChange(next);
});

const renderClipEditors = (): void => {
const pointContainer = row.querySelector<HTMLDivElement>(".editor-automation-clip-points");
const segmentContainer = row.querySelector<HTMLDivElement>(".editor-automation-clip-segments");
if (!pointContainer || !segmentContainer) {
return;
}
const clip = toAutomationClip(automation[index]);
pointContainer.innerHTML = clip.points
.map(
(point, pointIndex) => `
<div class="editor-automation-clip-point" data-point-index="${pointIndex}">
<span>P${pointIndex + 1}</span>
<input type="number" step="0.05" data-point-field="time" value="${point.time}" />
<input type="number" step="0.05" min="0" max="1" data-point-field="value" value="${point.value}" />
</div>`
)
.join("");
segmentContainer.innerHTML = clip.segmentMeta
.map(
(segment, segmentIndex) => `
<div class="editor-automation-clip-segment" data-segment-index="${segmentIndex}">
<span>S${segmentIndex + 1}</span>
<select data-segment-field="curveType">
${CURVE_TYPE_NAMES.map((name) => `<option value="${name}" ${name === segment.curveType ? "selected" : ""}>${name}</option>`).join("")}
</select>
<input type="number" step="0.05" min="-1" max="1" data-segment-field="tension" value="${segment.tension}" />
</div>`
)
.join("");

pointContainer.querySelectorAll<HTMLDivElement>(".editor-automation-clip-point").forEach((pointRow) => {
const pointIndex = Number(pointRow.dataset.pointIndex);
const applyPointMove = (): void => {
const timeValue = Number(pointRow.querySelector<HTMLInputElement>("[data-point-field='time']")?.value ?? 0);
const pointValue = Number(pointRow.querySelector<HTMLInputElement>("[data-point-field='value']")?.value ?? 0);
const slideEnabled = Boolean(
row.querySelector<HTMLInputElement>("[data-action='clip-slide-mode']")?.checked
);
const moved = movePoint(toAutomationClip(automation[index]), pointIndex, { time: timeValue, value: pointValue }, {
gridSize: 0.1,
snapEnabled: true,
slideMode: slideEnabled
});
const next = automation.map((entry) => ({ ...entry }));
next[index] = applyClipToAutomationEntry(next[index], moved);
onChange(next);
};
pointRow.querySelectorAll<HTMLInputElement>("[data-point-field]").forEach((input) => {
input.addEventListener("change", applyPointMove);
});
pointRow.addEventListener("contextmenu", (event) => {
event.preventDefault();
const removed = removePoint(toAutomationClip(automation[index]), pointIndex);
const next = automation.map((entry) => ({ ...entry }));
next[index] = applyClipToAutomationEntry(next[index], removed);
onChange(next);
});
});

segmentContainer.querySelectorAll<HTMLDivElement>(".editor-automation-clip-segment").forEach((segmentRow) => {
const segmentIndex = Number(segmentRow.dataset.segmentIndex);
segmentRow.querySelector<HTMLSelectElement>("[data-segment-field='curveType']")?.addEventListener("change", (event) => {
const select = event.currentTarget as HTMLSelectElement;
const updated = setSegmentCurveType(toAutomationClip(automation[index]), segmentIndex, select.value as CurveType);
const next = automation.map((entry) => ({ ...entry }));
next[index] = applyClipToAutomationEntry(next[index], updated);
onChange(next);
});
segmentRow.querySelector<HTMLInputElement>("[data-segment-field='tension']")?.addEventListener("change", (event) => {
const input = event.currentTarget as HTMLInputElement;
const updated = setSegmentTension(toAutomationClip(automation[index]), segmentIndex, Number(input.value));
const next = automation.map((entry) => ({ ...entry }));
next[index] = applyClipToAutomationEntry(next[index], updated);
onChange(next);
});
});
};

row.querySelector<HTMLButtonElement>("[data-action='clip-add-point']")?.addEventListener("click", () => {
const clip = toAutomationClip(automation[index]);
const midTime = (clip.points[0].time + clip.points[clip.points.length - 1].time) * 0.5;
const midValue = (clip.points[0].value + clip.points[clip.points.length - 1].value) * 0.5;
const updated = insertPoint(clip, { time: midTime, value: midValue }, { gridSize: 0.1, snapEnabled: true });
const next = automation.map((entry) => ({ ...entry }));
next[index] = applyClipToAutomationEntry(next[index], updated);
onChange(next);
});
row.querySelector<HTMLButtonElement>("[data-action='clip-remove-point']")?.addEventListener("click", () => {
const clip = toAutomationClip(automation[index]);
const updated = removePoint(clip, clip.points.length - 1);
const next = automation.map((entry) => ({ ...entry }));
next[index] = applyClipToAutomationEntry(next[index], updated);
onChange(next);
});
renderClipEditors();
});

container.querySelector<HTMLButtonElement>("[data-action='add-automation']")?.addEventListener("click", () => {
Expand Down
89 changes: 89 additions & 0 deletions src/editor/automationClip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
createAutomationClip,
insertPoint,
movePoint,
removePoint,
setSegmentCurveType,
setSegmentTension,
snapTime
} from "./automationClip";

describe("automationClip", () => {
it("snaps time to grid when enabled", () => {
expect(snapTime(1.12, 0.25, true)).toBe(1);
expect(snapTime(1.12, 0.25, false)).toBe(1.12);
});

it("adds points with snapping and keeps sorted timeline", () => {
const clip = createAutomationClip([
{ time: 0, value: 0 },
{ time: 2, value: 1 }
]);

const next = insertPoint(clip, { time: 1.13, value: 0.4 }, { gridSize: 0.25, snapEnabled: true });
expect(next.points).toEqual([
{ time: 0, value: 0 },
{ time: 1.25, value: 0.4 },
{ time: 2, value: 1 }
]);
});

it("removes points but preserves a minimum of two points", () => {
const clip = createAutomationClip([
{ time: 0, value: 0 },
{ time: 1, value: 0.3 },
{ time: 2, value: 1 }
]);
const oneRemoved = removePoint(clip, 1);
expect(oneRemoved.points).toEqual([
{ time: 0, value: 0 },
{ time: 2, value: 1 }
]);
const blocked = removePoint(oneRemoved, 0);
expect(blocked.points).toEqual(oneRemoved.points);
});

it("moves only one point when slide mode is off", () => {
const clip = createAutomationClip([
{ time: 0, value: 0 },
{ time: 1, value: 0.5 },
{ time: 2, value: 1 }
]);

const moved = movePoint(clip, 1, { time: 1.5, value: 0.25 }, { gridSize: 0.25, snapEnabled: true, slideMode: false });
expect(moved.points).toEqual([
{ time: 0, value: 0 },
{ time: 1.5, value: 0.25 },
{ time: 2, value: 1 }
]);
});

it("slides all subsequent points when slide mode is on", () => {
const clip = createAutomationClip([
{ time: 0, value: 0 },
{ time: 1, value: 0.5 },
{ time: 2, value: 1 }
]);

const moved = movePoint(clip, 1, { time: 1.5, value: 0.25 }, { gridSize: 0.25, snapEnabled: true, slideMode: true });
expect(moved.points).toEqual([
{ time: 0, value: 0 },
{ time: 1.5, value: 0.25 },
{ time: 2.5, value: 1 }
]);
});

it("sets per-segment tension and curve type with clamping", () => {
const clip = createAutomationClip([
{ time: 0, value: 0 },
{ time: 1, value: 0.5 },
{ time: 2, value: 1 }
]);

const tensioned = setSegmentTension(clip, 0, 4);
expect(tensioned.segmentMeta[0].tension).toBe(1);
const curved = setSegmentCurveType(tensioned, 0, "Hold");
expect(curved.segmentMeta[0].curveType).toBe("Hold");
});
});
Loading