From 2c31d9505c3799af75638dc830980d7af426a6f4 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 20 Feb 2026 20:10:43 -0700 Subject: [PATCH] Refactor show model/validation into show.go and simplify timeline internals --- cmd/qrunproxy/main.go | 12 --- cmd/qrunproxy/show.go | 127 ++++++++++++++++++++++++++++++ cmd/qrunproxy/timeline.go | 158 ++++++++++++++++---------------------- 3 files changed, 194 insertions(+), 103 deletions(-) create mode 100644 cmd/qrunproxy/show.go diff --git a/cmd/qrunproxy/main.go b/cmd/qrunproxy/main.go index 7fa603b..20369ae 100644 --- a/cmd/qrunproxy/main.go +++ b/cmd/qrunproxy/main.go @@ -102,18 +102,6 @@ func main() { } } -func loadMockShow() (*Show, error) { - buf, err := staticFS.ReadFile("static/show.json") - if err != nil { - return nil, err - } - var show Show - if err := json.Unmarshal(buf, &show); err != nil { - return nil, err - } - return &show, nil -} - func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) diff --git a/cmd/qrunproxy/show.go b/cmd/qrunproxy/show.go new file mode 100644 index 0000000..a5c364b --- /dev/null +++ b/cmd/qrunproxy/show.go @@ -0,0 +1,127 @@ +package main + +import ( + "encoding/json" + "fmt" +) + +type Show struct { + Tracks []*Track `json:"tracks"` + Blocks []*Block `json:"blocks"` + Triggers []*Trigger `json:"triggers"` +} + +type Track struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Block struct { + ID string `json:"id"` + Type string `json:"type"` + Track string `json:"track,omitempty"` + Name string `json:"name"` + Loop bool `json:"loop,omitempty"` +} + +type Trigger struct { + Source TriggerSource `json:"source"` + Targets []TriggerTarget `json:"targets"` +} + +type TriggerSource struct { + Block string `json:"block"` + Signal string `json:"signal"` +} + +type TriggerTarget struct { + Block string `json:"block"` + Hook string `json:"hook"` +} + +func loadMockShow() (*Show, error) { + buf, err := staticFS.ReadFile("static/show.json") + if err != nil { + return nil, err + } + var show Show + if err := json.Unmarshal(buf, &show); err != nil { + return nil, err + } + return &show, nil +} + +func isValidEventForBlock(block *Block, event string) bool { + if block.Type == "cue" { + return event == "GO" + } + switch event { + case "START", "FADE_OUT", "END": + return true + default: + return false + } +} + +func (show *Show) validate() error { + if show == nil { + return fmt.Errorf("show is nil") + } + + trackIDs := map[string]bool{} + for _, track := range show.Tracks { + if trackIDs[track.ID] { + return fmt.Errorf("duplicate track id %q", track.ID) + } + trackIDs[track.ID] = true + } + + blocksByID := map[string]*Block{} + for _, block := range show.Blocks { + if blocksByID[block.ID] != nil { + return fmt.Errorf("duplicate block id %q", block.ID) + } + blocksByID[block.ID] = block + if block.Type == "cue" { + continue + } + if !trackIDs[block.Track] { + return fmt.Errorf("block %q uses unknown track %q", block.ID, block.Track) + } + } + + startTargeted := map[string]bool{} + for _, trigger := range show.Triggers { + sourceBlock := blocksByID[trigger.Source.Block] + if sourceBlock == nil { + return fmt.Errorf("trigger source block %q not found", trigger.Source.Block) + } + if !isValidEventForBlock(sourceBlock, trigger.Source.Signal) { + return fmt.Errorf("trigger source signal %q is invalid for block %q", trigger.Source.Signal, trigger.Source.Block) + } + + for _, target := range trigger.Targets { + targetBlock := blocksByID[target.Block] + if targetBlock == nil { + return fmt.Errorf("trigger target block %q not found", target.Block) + } + if !isValidEventForBlock(targetBlock, target.Hook) { + return fmt.Errorf("trigger target hook %q is invalid for block %q", target.Hook, target.Block) + } + if target.Hook == "START" { + startTargeted[target.Block] = true + } + } + } + + for _, block := range show.Blocks { + if block.Type == "cue" { + continue + } + if !startTargeted[block.ID] { + return fmt.Errorf("block %q has no trigger for its START", block.ID) + } + } + + return nil +} diff --git a/cmd/qrunproxy/timeline.go b/cmd/qrunproxy/timeline.go index 7488157..9c2d258 100644 --- a/cmd/qrunproxy/timeline.go +++ b/cmd/qrunproxy/timeline.go @@ -1,69 +1,34 @@ package main -import "fmt" - const cueTrackID = "_cue" -type Show struct { - Tracks []*Track `json:"tracks"` - Blocks []*Block `json:"blocks"` - Triggers []*Trigger `json:"triggers"` -} - -type Track struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type Block struct { - ID string `json:"id"` - Type string `json:"type"` - Track string `json:"track,omitempty"` - Name string `json:"name"` - Loop bool `json:"loop,omitempty"` -} - -type Trigger struct { - Source TriggerSource `json:"source"` - Targets []TriggerTarget `json:"targets"` -} - -type TriggerSource struct { - Block string `json:"block"` - Signal string `json:"signal"` -} - -type TriggerTarget struct { - Block string `json:"block"` - Hook string `json:"hook"` -} - type TimelineTrack struct { *Track Cells []*TimelineCell `json:"cells"` } type Timeline struct { - Tracks []*TimelineTrack `json:"tracks"` - Blocks map[string]*Block `json:"blocks"` + Tracks []*TimelineTrack `json:"tracks"` + Blocks map[string]*Block `json:"blocks"` - show *Show `json:"-"` - trackIdx map[string]*TimelineTrack `json:"-"` - constraints []constraint `json:"-"` - exclusives []exclusiveGroup `json:"-"` + show *Show `json:"-"` + trackIdx map[string]*TimelineTrack `json:"-"` + cellIdx map[cellKey]*TimelineCell `json:"-"` + constraints []constraint `json:"-"` + exclusives []exclusiveGroup `json:"-"` } type TimelineCell struct { - BlockID string `json:"block_id,omitempty"` - IsStart bool `json:"is_start,omitempty"` - IsEnd bool `json:"is_end,omitempty"` - Event string `json:"event,omitempty"` - IsTitle bool `json:"is_title,omitempty"` - IsSignal bool `json:"is_signal,omitempty"` - IsGap bool `json:"-"` - IsBreak bool `json:"-"` - row int `json:"-"` - track *TimelineTrack `json:"-"` + BlockID string `json:"block_id,omitempty"` + IsStart bool `json:"is_start,omitempty"` + IsEnd bool `json:"is_end,omitempty"` + Event string `json:"event,omitempty"` + IsTitle bool `json:"is_title,omitempty"` + IsSignal bool `json:"is_signal,omitempty"` + IsGap bool `json:"-"` + IsBreak bool `json:"-"` + row int `json:"-"` + track *TimelineTrack `json:"-"` } type constraint struct { @@ -76,30 +41,13 @@ type exclusiveGroup struct { members []*TimelineCell } -func validateShow(show *Show) error { - startTargeted := map[string]bool{} - for _, trigger := range show.Triggers { - for _, target := range trigger.Targets { - if target.Hook == "START" { - startTargeted[target.Block] = true - } - } - } - - for _, block := range show.Blocks { - if block.Type == "cue" { - continue - } - if !startTargeted[block.ID] { - return fmt.Errorf("block %q has no trigger for its START", block.ID) - } - } - - return nil +type cellKey struct { + blockID string + event string } func BuildTimeline(show *Show) (Timeline, error) { - if err := validateShow(show); err != nil { + if err := show.validate(); err != nil { return Timeline{}, err } @@ -107,6 +55,7 @@ func BuildTimeline(show *Show) (Timeline, error) { show: show, Blocks: map[string]*Block{}, trackIdx: map[string]*TimelineTrack{}, + cellIdx: map[cellKey]*TimelineCell{}, } cueTrack := &TimelineTrack{Track: &Track{ID: cueTrackID, Name: "Cue"}} @@ -175,11 +124,8 @@ func getBlockCells(block *Block) []*TimelineCell { } func (tl *Timeline) findCell(blockID, event string) *TimelineCell { - track := tl.trackIdx[tl.Blocks[blockID].Track] - for _, c := range track.Cells { - if !c.IsGap && c.BlockID == blockID && c.Event == event { - return c - } + if c := tl.cellIdx[cellKey{blockID: blockID, event: event}]; c != nil { + return c } panic("cell not found: " + blockID + " " + event) } @@ -200,6 +146,12 @@ func (tl *Timeline) buildCells(endChains map[string]bool) { cells = getBlockCells(block) } track.appendCells(cells...) + for _, c := range cells { + if c.Event == "" { + continue + } + tl.cellIdx[cellKey{blockID: c.BlockID, event: c.Event}] = c + } if block.Type != "cue" && !endChains[block.ID] && lastOnTrack[block.Track] != block { track.appendCells(&TimelineCell{IsGap: true, IsBreak: true}) } @@ -291,7 +243,24 @@ func (tl *Timeline) enforceExclusives() bool { return false } -func (tl *Timeline) isAllGapRow(row int, except *TimelineTrack) bool { +func (tl *Timeline) shiftBreakDownOne(track *TimelineTrack, row int) { + below := track.Cells[row+1] + track.Cells[row].IsBreak = false + below.IsBreak = true +} + +func (tl *Timeline) canShiftBreakDownOne(track *TimelineTrack, row int) bool { + if row+1 >= len(track.Cells) { + return false + } + below := track.Cells[row+1] + if !below.IsGap || below.IsBreak { + return false + } + return true +} + +func (tl *Timeline) isAllRemovableGapRow(row int, except *TimelineTrack) bool { for _, t := range tl.Tracks { if t == except { continue @@ -299,7 +268,11 @@ func (tl *Timeline) isAllGapRow(row int, except *TimelineTrack) bool { if row >= len(t.Cells) { continue } - if !t.Cells[row].IsGap { + c := t.Cells[row] + if !c.IsGap { + return false + } + if c.IsBreak && !tl.canShiftBreakDownOne(t, row) { return false } } @@ -308,7 +281,11 @@ func (tl *Timeline) isAllGapRow(row int, except *TimelineTrack) bool { func (tl *Timeline) removeGapAt(track *TimelineTrack, index int) { track.Cells = append(track.Cells[:index], track.Cells[index+1:]...) - for i := index; i < len(track.Cells); i++ { + tl.reindexRowsFrom(track, index) +} + +func (tl *Timeline) reindexRowsFrom(track *TimelineTrack, start int) { + for i := start; i < len(track.Cells); i++ { track.Cells[i].row = i } } @@ -332,7 +309,7 @@ func (tl *Timeline) gapInsertionPoint(track *TimelineTrack, index int) int { func (tl *Timeline) insertGap(track *TimelineTrack, beforeIndex int) { beforeIndex = tl.gapInsertionPoint(track, beforeIndex) - if tl.isAllGapRow(beforeIndex, track) { + if tl.isAllRemovableGapRow(beforeIndex, track) { for _, t := range tl.Tracks { if t == track { continue @@ -340,7 +317,12 @@ func (tl *Timeline) insertGap(track *TimelineTrack, beforeIndex int) { if beforeIndex >= len(t.Cells) { continue } - if t.Cells[beforeIndex].IsGap { + c := t.Cells[beforeIndex] + if c.IsBreak { + tl.shiftBreakDownOne(t, beforeIndex) + c = t.Cells[beforeIndex] + } + if c.IsGap && !c.IsBreak { tl.removeGapAt(t, beforeIndex) } } @@ -359,12 +341,6 @@ func (tl *Timeline) insertGap(track *TimelineTrack, beforeIndex int) { break } - newCells := make([]*TimelineCell, 0, len(track.Cells)+1) - newCells = append(newCells, track.Cells[:beforeIndex]...) - newCells = append(newCells, gap) - newCells = append(newCells, track.Cells[beforeIndex:]...) - track.Cells = newCells - for i := beforeIndex + 1; i < len(track.Cells); i++ { - track.Cells[i].row = i - } + track.Cells = append(track.Cells[:beforeIndex], append([]*TimelineCell{gap}, track.Cells[beforeIndex:]...)...) + tl.reindexRowsFrom(track, beforeIndex+1) }