Auto commit Sat Mar 21 03:02:09 AM CDT 2026

This commit is contained in:
Tretzi
2026-03-21 03:02:19 -05:00
parent a334af0080
commit 2c8a8eb43f
3 changed files with 245 additions and 1 deletions

View File

@@ -354,7 +354,7 @@ internal fun TimelineGraph(
val curveId = draggingCurveId
if (pointIndex != null && curveId != null) {
val targetCurve = if (curveId == leftId) curveLeft else curveRight
val updated = targetCurve.movePointVertical(pointIndex, primary.position.y, h)
val updated = targetCurve.movePointVerticalWithPropagation(pointIndex, primary.position.y, h)
onUpdateCurve(curveId, updated)
onCurveEditedHaptic()
primary.consume()

View File

@@ -210,6 +210,18 @@ data class TimelineCurve(
return copy(points = updated)
}
fun movePointVerticalWithPropagation(index: Int, newY: Float, heightPx: Float): TimelineCurve {
if (index !in points.indices) return this
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList()
val draggedTime = updated[index].tSec
// Update the dragged point and all points after it (same or later time)
for (i in index until updated.size) {
updated[i] = updated[i].copy(value01 = v)
}
return copy(points = updated)
}
fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? {
val radiusPx = 36f
return points.mapIndexedNotNull { idx, p ->

View File

@@ -0,0 +1,232 @@
package com.mindmachine.mvp.session
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
private const val MIN_DURATION_SEC = 60
private const val MAX_DURATION_SEC = 8 * 60 * 60
private const val TIMELINE_STEP_SEC = 60
data class TimelineEditorState(
val durationSec: Int,
val selection: TimelineSelection,
val viewport: TimelineViewport,
val activeCurve: ActiveCurve,
val visualLeft: TimelineCurve,
val visualRight: TimelineCurve,
val audioLeft: TimelineCurve,
val audioRight: TimelineCurve,
val isPlaying: Boolean,
val playheadSec: Float,
) {
enum class ActiveCurve {
VISUAL_LEFT,
VISUAL_RIGHT,
AUDIO_LEFT,
AUDIO_RIGHT,
}
companion object {
fun default(durationSec: Int = 20 * 60): TimelineEditorState {
val dur = snapDuration(durationSec)
val initial = TimelineCurve.constant(0.5f, dur)
return TimelineEditorState(
durationSec = dur,
selection = TimelineSelection(0, dur),
viewport = TimelineViewport(
startSec = 0f,
secondsPerScreen = min(10f * 60f, dur.toFloat())
),
activeCurve = ActiveCurve.VISUAL_LEFT,
visualLeft = initial,
visualRight = initial,
audioLeft = initial,
audioRight = initial,
isPlaying = false,
playheadSec = 0f,
)
}
private fun snapDuration(durationSec: Int): Int =
((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
}
fun curveFor(id: ActiveCurve): TimelineCurve = when (id) {
ActiveCurve.VISUAL_LEFT -> visualLeft
ActiveCurve.VISUAL_RIGHT -> visualRight
ActiveCurve.AUDIO_LEFT -> audioLeft
ActiveCurve.AUDIO_RIGHT -> audioRight
}
fun withCurve(id: ActiveCurve, curve: TimelineCurve): TimelineEditorState {
val normalized = curve.ensureMinutePoints(durationSec)
return when (id) {
ActiveCurve.VISUAL_LEFT -> copy(visualLeft = normalized)
ActiveCurve.VISUAL_RIGHT -> copy(visualRight = normalized)
ActiveCurve.AUDIO_LEFT -> copy(audioLeft = normalized)
ActiveCurve.AUDIO_RIGHT -> copy(audioRight = normalized)
}
}
fun extendDurationTo(atLeastSec: Int): TimelineEditorState {
val newDur = ((atLeastSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
if (newDur <= durationSec) return this
return copy(
durationSec = newDur,
selection = selection.copy(endSec = max(selection.endSec, newDur)),
visualLeft = visualLeft.extendTo(newDur),
visualRight = visualRight.extendTo(newDur),
audioLeft = audioLeft.extendTo(newDur),
audioRight = audioRight.extendTo(newDur),
)
}
fun setDurationSec(newDurationSec: Int): TimelineEditorState {
val clamped = ((newDurationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
if (clamped == durationSec) return this
return if (clamped > durationSec) {
extendDurationTo(clamped).copy(selection = selection.copy(startSec = 0, endSec = clamped))
} else {
val newViewport = viewport.copy(startSec = viewport.startSec.coerceAtMost(clamped.toFloat()))
copy(
durationSec = clamped,
selection = TimelineSelection(0, clamped),
visualLeft = visualLeft.trimTo(clamped),
visualRight = visualRight.trimTo(clamped),
audioLeft = audioLeft.trimTo(clamped),
audioRight = audioRight.trimTo(clamped),
viewport = newViewport,
playheadSec = playheadSec.coerceIn(0f, clamped.toFloat()),
)
}
}
fun clampSelection(): TimelineEditorState {
val start = selection.startSec.coerceIn(0, durationSec)
var end = selection.endSec.coerceIn(0, durationSec)
if (end - start < MIN_DURATION_SEC) end = (start + MIN_DURATION_SEC).coerceAtMost(durationSec)
val clamped = TimelineSelection(start, end)
return copy(durationSec = max(durationSec, clamped.endSec), selection = clamped).let {
it.copy(durationSec = it.durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC))
}
}
}
data class TimelineSelection(
val startSec: Int,
val endSec: Int,
) {
init {
require(endSec >= startSec) { "endSec must be >= startSec" }
}
val durationSec: Int get() = endSec - startSec
}
data class TimelineViewport(
val startSec: Float,
val secondsPerScreen: Float,
) {
fun applyZoomPan(
zoomChange: Float,
panPx: Float,
widthPx: Float,
centroidPx: Float,
): TimelineViewport {
val minSecondsPerScreen = 30f
val maxSecondsPerScreen = 24f * 60f * 60f
val current = secondsPerScreen
val newSecondsPerScreen = (current / zoomChange).coerceIn(minSecondsPerScreen, maxSecondsPerScreen)
val secondsPerPxBefore = current / widthPx
val secondsPerPxAfter = newSecondsPerScreen / widthPx
val timeAtCentroidBefore = startSec + centroidPx * secondsPerPxBefore
val newStart = timeAtCentroidBefore - centroidPx * secondsPerPxAfter
val panSec = -panPx * secondsPerPxAfter
return copy(
startSec = (newStart + panSec).coerceAtLeast(0f),
secondsPerScreen = newSecondsPerScreen,
)
}
fun timeToX(tSec: Int, widthPx: Float): Float {
val rel = tSec.toFloat() - startSec
return (rel / secondsPerScreen) * widthPx
}
fun xToTimeSec(xPx: Float, widthPx: Float): Float {
return startSec + (xPx / widthPx) * secondsPerScreen
}
}
data class TimelineCurve(
val points: List<TimelinePoint>
) {
companion object {
fun constant(value01: Float, durationSec: Int): TimelineCurve {
val v = value01.coerceIn(0f, 1f)
return TimelineCurve(
points = (0..durationSec step TIMELINE_STEP_SEC).map { TimelinePoint(it, v) }
)
}
}
fun valueAt(tSec: Float): Float {
val sorted = points.sortedBy { it.tSec }
if (sorted.isEmpty()) return 0.5f
if (sorted.size == 1) return sorted.first().value01
val t = tSec.coerceAtLeast(0f)
if (t <= sorted.first().tSec) return sorted.first().value01
if (t >= sorted.last().tSec) return sorted.last().value01
val rightIdx = sorted.indexOfFirst { it.tSec.toFloat() >= t }.coerceAtLeast(1)
val left = sorted[rightIdx - 1]
val right = sorted[rightIdx]
val dt = (right.tSec - left.tSec).toFloat().coerceAtLeast(1e-3f)
val u = ((t - left.tSec) / dt).coerceIn(0f, 1f)
return (left.value01 + (right.value01 - left.value01) * u).coerceIn(0f, 1f)
}
fun ensureMinutePoints(durationSec: Int): TimelineCurve {
val snappedDuration = ((durationSec.coerceIn(MIN_DURATION_SEC, MAX_DURATION_SEC) + 30) / 60) * 60
val normalized = (0..snappedDuration step TIMELINE_STEP_SEC).map { minuteSec ->
TimelinePoint(minuteSec, valueAt(minuteSec.toFloat()))
}
return copy(points = normalized)
}
fun trimTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
fun movePointVertical(index: Int, newY: Float, heightPx: Float): TimelineCurve {
if (index !in points.indices) return this
val v = (1f - (newY / heightPx)).coerceIn(0f, 1f)
val updated = points.toMutableList()
updated[index] = updated[index].copy(value01 = v)
return copy(points = updated)
}
fun hitTestPoint(offset: androidx.compose.ui.geometry.Offset, viewport: TimelineViewport, widthPx: Float, heightPx: Float): Int? {
val radiusPx = 36f
return points.mapIndexedNotNull { idx, p ->
val x = viewport.timeToX(p.tSec, widthPx)
if (x < -radiusPx || x > widthPx + radiusPx) return@mapIndexedNotNull null
val y = (1f - p.value01) * heightPx
val dx = offset.x - x
val dy = offset.y - y
val dist2 = dx * dx + dy * dy
if (dist2 <= radiusPx * radiusPx) idx to dist2 else null
}.minByOrNull { it.second }?.first
}
fun extendTo(newDurationSec: Int): TimelineCurve = ensureMinutePoints(newDurationSec)
}
data class TimelinePoint(
val tSec: Int,
val value01: Float,
)