diff --git a/README.md b/README.md index 4230f3a9..aae99b37 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/config/loadConfig.ts b/src/config/loadConfig.ts index 53aa5712..8198feeb 100644 --- a/src/config/loadConfig.ts +++ b/src/config/loadConfig.ts @@ -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 = { diff --git a/src/editor/EditorRoot.test.ts b/src/editor/EditorRoot.test.ts index d1ba9db6..399677de 100644 --- a/src/editor/EditorRoot.test.ts +++ b/src/editor/EditorRoot.test.ts @@ -31,7 +31,9 @@ import { splitCueWords, generateWordTextCues , - sortScenesForEditorList + sortScenesForEditorList, + toAutomationClip, + applyClipToAutomationEntry } from "./EditorRoot"; describe("formatTime", () => { @@ -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( diff --git a/src/editor/EditorRoot.ts b/src/editor/EditorRoot.ts index 99f49cf2..46fbe6c0 100644 --- a/src/editor/EditorRoot.ts +++ b/src/editor/EditorRoot.ts @@ -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[] = [ @@ -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; @@ -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 { const state: EditorState = { timeline: null, @@ -2056,6 +2112,19 @@ export async function createEditorRoot(init: EditorInit): Promise +
+ Edit clip points +
+ + +
+ +
+
+
` ) .join("")} @@ -2106,6 +2175,102 @@ export async function createEditorRoot(init: EditorInit): Promise { + const pointContainer = row.querySelector(".editor-automation-clip-points"); + const segmentContainer = row.querySelector(".editor-automation-clip-segments"); + if (!pointContainer || !segmentContainer) { + return; + } + const clip = toAutomationClip(automation[index]); + pointContainer.innerHTML = clip.points + .map( + (point, pointIndex) => ` +
+ P${pointIndex + 1} + + +
` + ) + .join(""); + segmentContainer.innerHTML = clip.segmentMeta + .map( + (segment, segmentIndex) => ` +
+ S${segmentIndex + 1} + + +
` + ) + .join(""); + + pointContainer.querySelectorAll(".editor-automation-clip-point").forEach((pointRow) => { + const pointIndex = Number(pointRow.dataset.pointIndex); + const applyPointMove = (): void => { + const timeValue = Number(pointRow.querySelector("[data-point-field='time']")?.value ?? 0); + const pointValue = Number(pointRow.querySelector("[data-point-field='value']")?.value ?? 0); + const slideEnabled = Boolean( + row.querySelector("[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("[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(".editor-automation-clip-segment").forEach((segmentRow) => { + const segmentIndex = Number(segmentRow.dataset.segmentIndex); + segmentRow.querySelector("[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("[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("[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("[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("[data-action='add-automation']")?.addEventListener("click", () => { diff --git a/src/editor/automationClip.test.ts b/src/editor/automationClip.test.ts new file mode 100644 index 00000000..55849d68 --- /dev/null +++ b/src/editor/automationClip.test.ts @@ -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"); + }); +}); diff --git a/src/editor/automationClip.ts b/src/editor/automationClip.ts new file mode 100644 index 00000000..2ecf4b82 --- /dev/null +++ b/src/editor/automationClip.ts @@ -0,0 +1,142 @@ +export type CurveType = + | "Linear" + | "SingleCurve" + | "DoubleCurve" + | "Hold" + | "Stairs" + | "Smooth" + | "Pulse" + | "Wave"; + +export type InteractionMode = "free" | "step"; + +export type ControlPoint = { + time: number; + value: number; +}; + +export type SegmentMeta = { + curveType: CurveType; + tension: number; +}; + +export type AutomationClip = { + points: ControlPoint[]; + mode: InteractionMode; + segmentMeta: SegmentMeta[]; +}; + +export const MIN_POINTS = 2; +export const DEFAULT_SEGMENT_META: SegmentMeta = { curveType: "Linear", tension: 0 }; + +export const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)); + +export const sortPoints = (points: ControlPoint[]): ControlPoint[] => [...points].sort((a, b) => a.time - b.time); + +export const snapTime = (time: number, gridSize: number, snapEnabled: boolean): number => { + if (!snapEnabled || gridSize <= 0 || !Number.isFinite(time)) { + return time; + } + return Math.round(time / gridSize) * gridSize; +}; + +export const createAutomationClip = (points: ControlPoint[], mode: InteractionMode = "free"): AutomationClip => { + const normalized = sortPoints(points).map((point) => ({ ...point })); + if (normalized.length < MIN_POINTS) { + throw new Error("Automation clips require at least two control points."); + } + return { + points: normalized, + mode, + segmentMeta: Array.from({ length: normalized.length - 1 }, () => ({ ...DEFAULT_SEGMENT_META })) + }; +}; + +const normalizeSegmentMeta = (clip: AutomationClip): SegmentMeta[] => { + return Array.from({ length: Math.max(0, clip.points.length - 1) }, (_, index) => ({ + curveType: clip.segmentMeta[index]?.curveType ?? "Linear", + tension: clamp(clip.segmentMeta[index]?.tension ?? 0, -1, 1) + })); +}; + +export const insertPoint = ( + clip: AutomationClip, + point: ControlPoint, + options: { gridSize: number; snapEnabled: boolean } +): AutomationClip => { + const time = snapTime(point.time, options.gridSize, options.snapEnabled); + const value = clamp(point.value, 0, 1); + const duplicate = clip.points.find((entry) => Math.abs(entry.time - time) < 1e-6 && Math.abs(entry.value - value) < 1e-6); + if (duplicate) { + return clip; + } + const nextPoints = sortPoints([...clip.points, { time, value }]); + const next: AutomationClip = { + ...clip, + points: nextPoints, + segmentMeta: normalizeSegmentMeta({ ...clip, points: nextPoints }) + }; + return next; +}; + +export const removePoint = (clip: AutomationClip, pointIndex: number): AutomationClip => { + if (clip.points.length <= MIN_POINTS) { + return clip; + } + const nextPoints = clip.points.filter((_, index) => index !== pointIndex); + if (nextPoints.length < MIN_POINTS) { + return clip; + } + return { + ...clip, + points: nextPoints, + segmentMeta: normalizeSegmentMeta({ ...clip, points: nextPoints }) + }; +}; + +export const movePoint = ( + clip: AutomationClip, + pointIndex: number, + target: ControlPoint, + options: { gridSize: number; snapEnabled: boolean; slideMode: boolean } +): AutomationClip => { + const points = clip.points.map((point) => ({ ...point })); + if (!points[pointIndex]) { + return clip; + } + const current = points[pointIndex]; + const nextTime = snapTime(target.time, options.gridSize, options.snapEnabled); + const deltaTime = nextTime - current.time; + points[pointIndex] = { time: nextTime, value: clamp(target.value, 0, 1) }; + + if (options.slideMode && Math.abs(deltaTime) > 1e-9) { + for (let index = pointIndex + 1; index < points.length; index += 1) { + points[index] = { ...points[index], time: points[index].time + deltaTime }; + } + } + + const nextPoints = sortPoints(points); + return { + ...clip, + points: nextPoints, + segmentMeta: normalizeSegmentMeta({ ...clip, points: nextPoints }) + }; +}; + +export const setSegmentTension = (clip: AutomationClip, segmentIndex: number, tension: number): AutomationClip => { + if (segmentIndex < 0 || segmentIndex >= clip.segmentMeta.length) { + return clip; + } + const segmentMeta = clip.segmentMeta.map((meta, index) => + index === segmentIndex ? { ...meta, tension: clamp(tension, -1, 1) } : meta + ); + return { ...clip, segmentMeta }; +}; + +export const setSegmentCurveType = (clip: AutomationClip, segmentIndex: number, curveType: CurveType): AutomationClip => { + if (segmentIndex < 0 || segmentIndex >= clip.segmentMeta.length) { + return clip; + } + const segmentMeta = clip.segmentMeta.map((meta, index) => (index === segmentIndex ? { ...meta, curveType } : meta)); + return { ...clip, segmentMeta }; +};