diff --git a/WIP.md b/WIP.md new file mode 100644 index 0000000..f09f43f --- /dev/null +++ b/WIP.md @@ -0,0 +1,33 @@ +# WIP: Iterative buildCells rewrite + +## What's done +- Block has `weight uint64`, `triggers []*Trigger`, `topRow int` fields +- TriggerSource and TriggerTarget have `block *Block` pointer fields +- `linkTriggers()` resolves string IDs to block pointers, populates `block.triggers` +- `computeWeights()` sets weight = `(cue index) << 32` + 0 if cue-ended, 1 otherwise + - Higher weight = more likely to be moved + - Later cues = higher weight +- `buildCells()` is rewritten to be iterative: + 1. Init: cues get sequential topRow, non-cues start at 0 + 2. Loop (trigger pass): move target blocks so trigger source row >= target hook row + 3. Loop (overlap pass): sort same-track blocks by (topRow, weight), push overlaps to prev end + 1 + 4. After convergence: move each cue to max row of its triggered cells + 5. `emitCells()` creates actual TimelineCell objects from computed topRows +- Test uses `BuildTimelineDebug` with testWriter for debug output + +## What's broken +- Cue track comes out unordered after the cue-move step (cues placed at their highest triggered cell row, which scrambles scene order) +- Gap padding in emitCells skips cue track (`if block.Type != "cue"`) so cue cell rows don't match topRow +- The +1 gap between blocks may be wrong (was +2, changed to +1) +- assignRows still can't converge after buildCells — off-by-one misalignments between cue rows and target rows + +## Key design decisions +- topRow coordinate space should include gap cells (emitCells just places cells at topRow) +- blockHeight: cue=0 (own track, can overlap), other=4 +- Gap between same-track blocks = 1 row (chain or gap cell) +- Cross-track alignment still handled by assignRows solver after buildCells + +## Next steps +- Fix cue track: need gap padding on cue track too so cell rows match topRow +- May need to rethink cue-move step — moving cues to highest triggered row scrambles cue order +- Verify topRow math matches actual emitted cell rows end-to-end diff --git a/cmd/qrunproxy/show.go b/cmd/qrunproxy/show.go index 25a2270..fd61514 100644 --- a/cmd/qrunproxy/show.go +++ b/cmd/qrunproxy/show.go @@ -20,8 +20,9 @@ type Block struct { Name string `json:"name"` Loop bool `json:"loop,omitempty"` - weight int + weight uint64 triggers []*Trigger + topRow int } type Trigger struct { diff --git a/cmd/qrunproxy/timeline.go b/cmd/qrunproxy/timeline.go index 5daa4a9..e2cd0c8 100644 --- a/cmd/qrunproxy/timeline.go +++ b/cmd/qrunproxy/timeline.go @@ -216,44 +216,38 @@ func (tl *Timeline) linkTriggers() { } func (tl *Timeline) computeWeights() { - for _, block := range tl.show.Blocks { - block.weight = 0 - } - + cueEndedBlocks := map[string]bool{} for _, trigger := range tl.show.Triggers { if trigger.Source.block.Type != "cue" { continue } for _, target := range trigger.Targets { if target.Hook == "END" || target.Hook == "FADE_OUT" { - if target.block.weight < 1 { - target.block.weight = 1 - } + cueEndedBlocks[target.Block] = true } } } + cueIdx := uint64(0) for _, block := range tl.show.Blocks { if block.Type == "cue" { - tl.computeWeightDFS(block) + tl.setWeightDFS(block, cueIdx<<32, cueEndedBlocks) + cueIdx++ } } } -func (tl *Timeline) computeWeightDFS(b *Block) int { - maxChild := 0 +func (tl *Timeline) setWeightDFS(b *Block, base uint64, cueEndedBlocks map[string]bool) { + if cueEndedBlocks[b.ID] { + b.weight = base + } else { + b.weight = base + 1 + } for _, trigger := range b.triggers { for _, target := range trigger.Targets { - w := tl.computeWeightDFS(target.block) - if w > maxChild { - maxChild = w - } + tl.setWeightDFS(target.block, base, cueEndedBlocks) } } - if maxChild > b.weight { - b.weight = maxChild - } - return b.weight } func (tl *Timeline) findEndChains() map[string]bool { @@ -307,34 +301,163 @@ func (tl *Timeline) findCell(blockID, event string) *TimelineCell { panic("cell not found: " + blockID + " " + event) } +func blockHeight(b *Block) int { + if b.Type == "cue" { + return 0 + } + return 4 +} + +func eventOffset(event string) int { + switch event { + case "GO": + return 0 + case "START": + return 0 + case "FADE_OUT": + return 2 + case "END": + return 3 + default: + return 0 + } +} + func (tl *Timeline) buildCells() { - endChains := tl.findEndChains() - lastOnTrack := map[string]*Block{} + cueIdx := 0 for _, block := range tl.show.Blocks { - lastOnTrack[block.Track] = block + if block.Type == "cue" { + block.topRow = cueIdx + cueIdx++ + } else { + block.topRow = 0 + } + } + + for range 1000 { + changed := false + + for _, trigger := range tl.show.Triggers { + srcRow := trigger.Source.block.topRow + eventOffset(trigger.Source.Signal) + for _, target := range trigger.Targets { + targetRow := srcRow - eventOffset(target.Hook) + if target.block.topRow < targetRow { + target.block.topRow = targetRow + changed = true + } + } + } + + for _, track := range tl.Tracks { + trackBlocks := tl.blocksByTrack(track.ID) + slices.SortFunc(trackBlocks, func(a, b *Block) int { + if a.topRow != b.topRow { + return a.topRow - b.topRow + } + if a.weight != b.weight { + if a.weight < b.weight { + return -1 + } + return 1 + } + return 0 + }) + for i := 1; i < len(trackBlocks); i++ { + prev := trackBlocks[i-1] + cur := trackBlocks[i] + minRow := prev.topRow + blockHeight(prev) + 1 + if cur.topRow < minRow { + cur.topRow = minRow + changed = true + } + } + } + + if !changed { + break + } } for _, block := range tl.show.Blocks { - track := tl.trackIdx[block.Track] - var cells []*TimelineCell - switch block.Type { - case "cue": - cells = getCueCells(block) - default: - cells = getBlockCells(block) + if block.Type != "cue" { + continue } - track.appendCells(cells...) - for _, c := range cells { - if c.Event == "" { - continue + maxRow := block.topRow + for _, trigger := range block.triggers { + for _, target := range trigger.Targets { + r := target.block.topRow + eventOffset(target.Hook) + if r > maxRow { + maxRow = r + } } - tl.cellIdx[cellKey{blockID: c.BlockID, event: c.Event}] = c } - if block.Type != "cue" && lastOnTrack[block.Track] != block { - if endChains[block.ID] { - track.appendCells(&TimelineCell{Type: CellChain}) - } else { - track.appendCells(&TimelineCell{Type: CellGap}) + block.topRow = maxRow + } + + tl.emitCells() + tl.debugState() +} + +func (tl *Timeline) blocksByTrack(trackID string) []*Block { + var blocks []*Block + for _, block := range tl.show.Blocks { + if block.Track == trackID { + blocks = append(blocks, block) + } + } + return blocks +} + +func (tl *Timeline) emitCells() { + endChains := tl.findEndChains() + + type trackEntry struct { + block *Block + } + trackBlocks := map[string][]*Block{} + for _, block := range tl.show.Blocks { + trackBlocks[block.Track] = append(trackBlocks[block.Track], block) + } + + for trackID, blocks := range trackBlocks { + track := tl.trackIdx[trackID] + slices.SortFunc(blocks, func(a, b *Block) int { + return a.topRow - b.topRow + }) + + row := 0 + for i, block := range blocks { + for row < block.topRow { + if block.Type != "cue" { + track.appendCells(&TimelineCell{Type: CellGap}) + } + row++ + } + + var cells []*TimelineCell + switch block.Type { + case "cue": + cells = getCueCells(block) + default: + cells = getBlockCells(block) + } + track.appendCells(cells...) + for _, c := range cells { + if c.Event == "" { + continue + } + tl.cellIdx[cellKey{blockID: c.BlockID, event: c.Event}] = c + } + row += len(cells) + + isLast := i == len(blocks)-1 + if !isLast && block.Type != "cue" { + if endChains[block.ID] { + track.appendCells(&TimelineCell{Type: CellChain}) + } else { + track.appendCells(&TimelineCell{Type: CellGap}) + } + row++ } } } diff --git a/cmd/qrunproxy/timeline_test.go b/cmd/qrunproxy/timeline_test.go index cece91f..4e5a726 100644 --- a/cmd/qrunproxy/timeline_test.go +++ b/cmd/qrunproxy/timeline_test.go @@ -18,7 +18,7 @@ func TestBuildTimelineFromMockShow(t *testing.T) { t.Logf("Validate: %v", time.Since(t1)) t2 := time.Now() - tl, err := BuildTimeline(show) + tl, err := BuildTimelineDebug(show, &testWriter{t}) t.Logf("BuildTimeline: %v", time.Since(t2)) if err != nil { t.Fatalf("BuildTimeline failed: %v", err) @@ -26,6 +26,13 @@ func TestBuildTimelineFromMockShow(t *testing.T) { t.Logf("tracks=%d blocks=%d", len(tl.Tracks), len(tl.Blocks)) } +type testWriter struct{ t *testing.T } + +func (w *testWriter) Write(p []byte) (int, error) { + w.t.Log(string(p)) + return len(p), nil +} + func BenchmarkBuildTimeline(b *testing.B) { show := GenerateMockShow(42, 5, 20, 4, 5) if err := show.Validate(); err != nil {