From 7d3a23dfc18ae3cb9d255fa996e66fe8368f752a Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 20 Feb 2026 22:31:04 -0700 Subject: [PATCH] allowedTracks gating, constraint/exclusive helpers, duplicate trigger source validation, re-enable untimed block validation --- cmd/qrunproxy/main.go | 2 +- cmd/qrunproxy/mockshow.go | 68 +++++++++++++++++++-------- cmd/qrunproxy/show.go | 15 +++--- cmd/qrunproxy/timeline.go | 99 ++++++++++++++++++++++++++++++--------- 4 files changed, 135 insertions(+), 49 deletions(-) diff --git a/cmd/qrunproxy/main.go b/cmd/qrunproxy/main.go index c49e181..d83dfc1 100644 --- a/cmd/qrunproxy/main.go +++ b/cmd/qrunproxy/main.go @@ -28,7 +28,7 @@ func main() { runAndExit = strings.Fields(*runAndExitStr) } - show := GenerateMockShow(5, 100, 1000) + show := GenerateMockShow(5, 10, 30) if err := show.Validate(); err != nil { fmt.Fprintf(os.Stderr, "Error validating show: %v\n", err) os.Exit(1) diff --git a/cmd/qrunproxy/mockshow.go b/cmd/qrunproxy/mockshow.go index ea2d1d6..18bed36 100644 --- a/cmd/qrunproxy/mockshow.go +++ b/cmd/qrunproxy/mockshow.go @@ -30,8 +30,9 @@ func GenerateMockShow(numTracks, numCues, numBlocks int) *Show { show := &Show{} blockIdx := 0 - nextBlockID := func() string { - id := fmt.Sprintf("b%d", blockIdx) + curCueName := "" + nextBlockID := func(trackIdx int) string { + id := fmt.Sprintf("%s-t%d-b%d", curCueName, trackIdx, blockIdx) blockIdx++ return id } @@ -69,7 +70,7 @@ func GenerateMockShow(numTracks, numCues, numBlocks int) *Show { typ, name = "light", lightNamePool[rng.IntN(len(lightNamePool))] } return &Block{ - ID: nextBlockID(), + ID: nextBlockID(trackIdx), Type: typ, Track: fmt.Sprintf("track_%d", trackIdx), Name: name, @@ -80,7 +81,12 @@ func GenerateMockShow(numTracks, numCues, numBlocks int) *Show { placed := 0 cueIdx := 0 scene := 0 - lastOnTrack := make(map[int]*Block) + chainFromByTrack := make(map[int]*Block) + needsEnd := make(map[string]*Block) + allowedTracks := make(map[int]bool, numTracks) + for i := range numTracks { + allowedTracks[i] = true + } for placed < numBlocks && cueIdx < numCues { scene++ @@ -90,38 +96,57 @@ func GenerateMockShow(numTracks, numCues, numBlocks int) *Show { if placed >= numBlocks || cueIdx >= numCues { break } - clear(lastOnTrack) + for trackIdx, blk := range chainFromByTrack { + if needsEnd[blk.ID] == nil { + delete(chainFromByTrack, trackIdx) + } + } + curCueName = fmt.Sprintf("S%d Q%d", scene, intra) cue := &Block{ - ID: fmt.Sprintf("q%d", cueIdx), + ID: curCueName, Type: "cue", - Name: fmt.Sprintf("S%d Q%d", scene, intra), + Name: curCueName, } show.Blocks = append(show.Blocks, cue) cueIdx++ blocksThisCue := 1 + rng.IntN(numTracks*2) cueTargets := []TriggerTarget{} + for id, blk := range needsEnd { + cueTargets = append(cueTargets, TriggerTarget{Block: blk.ID, Hook: "END"}) + delete(needsEnd, id) + for ti := range numTracks { + if chainFromByTrack[ti] == blk { + allowedTracks[ti] = true + } + } + } for range blocksThisCue { if placed >= numBlocks { break } trackIdx := rng.IntN(numTracks) - if prev := lastOnTrack[trackIdx]; prev != nil && !prev.hasDefinedTiming() { + if !allowedTracks[trackIdx] { continue } block := randBlock(trackIdx) show.Blocks = append(show.Blocks, block) placed++ - if prev := lastOnTrack[trackIdx]; prev != nil { + if prev := chainFromByTrack[trackIdx]; prev != nil { show.Triggers = append(show.Triggers, &Trigger{ Source: TriggerSource{Block: prev.ID, Signal: "END"}, Targets: []TriggerTarget{{Block: block.ID, Hook: "START"}}, }) + delete(needsEnd, prev.ID) } else { cueTargets = append(cueTargets, TriggerTarget{Block: block.ID, Hook: "START"}) } - lastOnTrack[trackIdx] = block + if !block.hasDefinedTiming() { + needsEnd[block.ID] = block + allowedTracks[trackIdx] = false + } + chainFromByTrack[trackIdx] = block } if len(cueTargets) > 0 { @@ -133,17 +158,21 @@ func GenerateMockShow(numTracks, numCues, numBlocks int) *Show { } endTargets := []TriggerTarget{} - for _, blk := range lastOnTrack { - if blk.hasDefinedTiming() { - continue - } + for id, blk := range needsEnd { endTargets = append(endTargets, TriggerTarget{Block: blk.ID, Hook: "END"}) + delete(needsEnd, id) + for ti := range numTracks { + if chainFromByTrack[ti] == blk { + allowedTracks[ti] = true + } + } } - if len(endTargets) > 0 && cueIdx < numCues { + if len(endTargets) > 0 { + endCueName := fmt.Sprintf("S%d End", scene) endCue := &Block{ - ID: fmt.Sprintf("q%d", cueIdx), + ID: endCueName, Type: "cue", - Name: fmt.Sprintf("S%d End", scene), + Name: endCueName, } show.Blocks = append(show.Blocks, endCue) cueIdx++ @@ -156,10 +185,11 @@ func GenerateMockShow(numTracks, numCues, numBlocks int) *Show { for cueIdx < numCues { scene++ + emptyCueName := fmt.Sprintf("S%d Q1", scene) cue := &Block{ - ID: fmt.Sprintf("q%d", cueIdx), + ID: emptyCueName, Type: "cue", - Name: fmt.Sprintf("S%d Q1", scene), + Name: emptyCueName, } show.Blocks = append(show.Blocks, cue) cueIdx++ diff --git a/cmd/qrunproxy/show.go b/cmd/qrunproxy/show.go index a7f19f4..ccce6ee 100644 --- a/cmd/qrunproxy/show.go +++ b/cmd/qrunproxy/show.go @@ -91,6 +91,7 @@ func (show *Show) Validate() error { } hookTargeted := map[blockEvent]bool{} startTargeted := map[string]bool{} + sourceUsed := map[blockEvent]bool{} for _, trigger := range show.Triggers { sourceBlock := blocksByID[trigger.Source.Block] if sourceBlock == nil { @@ -99,6 +100,11 @@ func (show *Show) Validate() error { if !isValidEventForBlock(sourceBlock, trigger.Source.Signal) { return fmt.Errorf("trigger source signal %q is invalid for block %q", trigger.Source.Signal, trigger.Source.Block) } + src := blockEvent{trigger.Source.Block, trigger.Source.Signal} + if sourceUsed[src] { + return fmt.Errorf("duplicate trigger source: block %q signal %q", trigger.Source.Block, trigger.Source.Signal) + } + sourceUsed[src] = true for _, target := range trigger.Targets { targetBlock := blocksByID[target.Block] @@ -122,12 +128,9 @@ func (show *Show) Validate() error { if !startTargeted[block.ID] { return fmt.Errorf("block %q has no trigger for its START", block.ID) } - /* - TODO: Put this back when mock is fixed - if !block.hasDefinedTiming() && !hookTargeted[blockEvent{block.ID, "FADE_OUT"}] && !hookTargeted[blockEvent{block.ID, "END"}] { - return fmt.Errorf("block %q has no defined timing and nothing triggers its FADE_OUT or END", block.ID) - } - */ + if !block.hasDefinedTiming() && !hookTargeted[blockEvent{block.ID, "FADE_OUT"}] && !hookTargeted[blockEvent{block.ID, "END"}] { + return fmt.Errorf("block %q has no defined timing and nothing triggers its FADE_OUT or END", block.ID) + } } for _, trigger := range show.Triggers { diff --git a/cmd/qrunproxy/timeline.go b/cmd/qrunproxy/timeline.go index 02f94f0..ca0f567 100644 --- a/cmd/qrunproxy/timeline.go +++ b/cmd/qrunproxy/timeline.go @@ -33,16 +33,76 @@ type TimelineCell struct { track *TimelineTrack `json:"-"` } +func (c *TimelineCell) String() string { + return fmt.Sprintf("%s/%s@%s:r%d", c.BlockID, c.Event, c.track.ID, c.row) +} + type constraint struct { kind string a *TimelineCell b *TimelineCell } +func (c constraint) satisfied() bool { + switch c.kind { + case "same_row": + return c.a.row == c.b.row + case "next_row": + return c.b.row > c.a.row + } + return true +} + +func (c constraint) String() string { + switch c.kind { + case "same_row": + return fmt.Sprintf("same_row(%s, %s)", c.a, c.b) + case "next_row": + return fmt.Sprintf("next_row(%s -> %s)", c.a, c.b) + } + return fmt.Sprintf("%s(%s, %s)", c.kind, c.a, c.b) +} + type exclusiveGroup struct { members []*TimelineCell } +func (g exclusiveGroup) satisfied(tracks []*TimelineTrack) bool { + row := g.members[0].row + memberTracks := map[*TimelineTrack]bool{} + for _, m := range g.members { + memberTracks[m.track] = true + if m.row != row { + return true + } + } + for _, t := range tracks { + if memberTracks[t] { + continue + } + if row >= len(t.Cells) { + continue + } + c := t.Cells[row] + if c.IsGap || c.BlockID == "" { + continue + } + return false + } + return true +} + +func (g exclusiveGroup) String() string { + s := "exclusive(" + for i, m := range g.members { + if i > 0 { + s += ", " + } + s += m.String() + } + return s + ")" +} + type cellKey struct { blockID string event string @@ -189,15 +249,13 @@ func (tl *Timeline) assignRows() error { return nil } for _, c := range tl.constraints { - switch c.kind { - case "same_row": - if c.a.row != c.b.row { - return fmt.Errorf("assignRows: unsatisfied %s constraint: %s/%s (row %d) vs %s/%s (row %d)", c.kind, c.a.BlockID, c.a.Event, c.a.row, c.b.BlockID, c.b.Event, c.b.row) - } - case "next_row": - if c.b.row <= c.a.row { - return fmt.Errorf("assignRows: unsatisfied %s constraint: %s/%s (row %d) must follow %s/%s (row %d)", c.kind, c.b.BlockID, c.b.Event, c.b.row, c.a.BlockID, c.a.Event, c.a.row) - } + if !c.satisfied() { + return fmt.Errorf("assignRows: unsatisfied %s", c) + } + } + for _, g := range tl.exclusives { + if !g.satisfied(tl.Tracks) { + return fmt.Errorf("assignRows: unsatisfied %s", g) } } return fmt.Errorf("assignRows: did not converge") @@ -205,38 +263,33 @@ func (tl *Timeline) assignRows() error { func (tl *Timeline) enforceConstraints() bool { for _, c := range tl.constraints { + if c.satisfied() { + continue + } switch c.kind { case "same_row": if c.a.row < c.b.row { tl.insertGap(c.a.track, c.a.row) - return true - } else if c.b.row < c.a.row { + } else { tl.insertGap(c.b.track, c.b.row) - return true } case "next_row": - if c.b.row <= c.a.row { - tl.insertGap(c.b.track, c.b.row) - return true - } + tl.insertGap(c.b.track, c.b.row) } + return true } return false } func (tl *Timeline) enforceExclusives() bool { for _, g := range tl.exclusives { + if g.satisfied(tl.Tracks) { + continue + } row := g.members[0].row - allAligned := true memberTracks := map[*TimelineTrack]bool{} for _, m := range g.members { memberTracks[m.track] = true - if m.row != row { - allAligned = false - } - } - if !allAligned { - continue } for _, t := range tl.Tracks { if memberTracks[t] {