From 0893e5b8cbcd473911843ffceab4cb2cc920fbc4 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 20 Feb 2026 07:57:41 -0700 Subject: [PATCH] Rewrite timeline builder to constraint-based layout with exclusive trigger rows --- cmd/qrunproxy/main.go | 30 +- cmd/qrunproxy/timeline.go | 957 ++++++++++----------------------- cmd/qrunproxy/timeline_test.go | 96 ---- screenshot.sh | 2 +- 4 files changed, 294 insertions(+), 791 deletions(-) delete mode 100644 cmd/qrunproxy/timeline_test.go diff --git a/cmd/qrunproxy/main.go b/cmd/qrunproxy/main.go index 5d8b36a..ec74a10 100644 --- a/cmd/qrunproxy/main.go +++ b/cmd/qrunproxy/main.go @@ -4,6 +4,7 @@ import ( "context" "embed" "encoding/json" + "flag" "fmt" "io/fs" "net" @@ -17,15 +18,13 @@ import ( var staticFS embed.FS func main() { - addr := ":8080" - var runAndExit []string + addr := flag.String("addr", ":8080", "listen address") + runAndExitStr := flag.String("run-and-exit", "", "command to run after server starts, then exit") + flag.Parse() - for _, arg := range os.Args[1:] { - if v, ok := strings.CutPrefix(arg, "--run-and-exit="); ok { - runAndExit = strings.Fields(v) - } else { - addr = arg - } + var runAndExit []string + if *runAndExitStr != "" { + runAndExit = strings.Fields(*runAndExitStr) } show, err := loadMockShow() @@ -56,14 +55,18 @@ func main() { }) if len(runAndExit) > 0 { - ln, err := net.Listen("tcp", addr) + ln, err := net.Listen("tcp", *addr) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + port := fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port) srv := &http.Server{Handler: mux} go srv.Serve(ln) + for i, arg := range runAndExit { + runAndExit[i] = strings.ReplaceAll(arg, "{port}", port) + } cmd := exec.Command(runAndExit[0], runAndExit[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -76,8 +79,13 @@ func main() { return } - fmt.Printf("Listening on %s\n", addr) - if err := http.ListenAndServe(addr, mux); err != nil { + ln, err := net.Listen("tcp", *addr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Listening on %s\n", ln.Addr()) + if err := http.Serve(ln, mux); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } diff --git a/cmd/qrunproxy/timeline.go b/cmd/qrunproxy/timeline.go index c764cbf..6f89207 100644 --- a/cmd/qrunproxy/timeline.go +++ b/cmd/qrunproxy/timeline.go @@ -1,13 +1,8 @@ package main -import ( - "fmt" - "sort" -) const ( cueTrackID = "_cue" - intMax = int(^uint(0) >> 1) ) type Show struct { @@ -63,445 +58,99 @@ type TimelineCell struct { IsSignal bool `json:"is_signal,omitempty"` } +type cellID struct { + track int + index int +} + +type constraint struct { + kind string + a cellID + b cellID +} + +type exclusiveGroup struct { + members []cellID +} + +type trackCell struct { + cell TimelineCell + isGap bool +} + type timelineBuilder struct { - show Show - blocks map[string]Block - trackIDs []string - tracks []Track - startSigs map[string][]TriggerTarget - hasEndSignal map[string]bool - active map[string]string - pending map[string]struct{} - rows []TimelineRow - noTitle map[int]struct{} -} + blocks map[string]Block + trackIDs []string + tracks []Track + trackIdx map[string]int + startSigs map[string][]TriggerTarget -type blockRange struct { - first int - last int - start int - end int -} - -type titleInfo struct { - blockID string - track string - s int - e int - pos int -} - -type titleGroup struct { - pos int - s int - e int - titles []titleInfo -} - -type orderedHooks struct { - order []string - values map[string]string -} - -func newOrderedHooks() orderedHooks { - return orderedHooks{values: map[string]string{}} -} - -func (o *orderedHooks) Set(block, hook string) { - if _, ok := o.values[block]; !ok { - o.order = append(o.order, block) - } - o.values[block] = hook -} - -func (o orderedHooks) Get(block string) (string, bool) { - v, ok := o.values[block] - return v, ok -} - -func (o orderedHooks) Len() int { - return len(o.order) -} - -func (o orderedHooks) ForEach(fn func(block, hook string)) { - for _, block := range o.order { - fn(block, o.values[block]) - } + trackCells [][]trackCell + constraints []constraint + exclusives []exclusiveGroup } func BuildTimeline(show Show) (Timeline, error) { - builder := &timelineBuilder{ - show: show, - blocks: map[string]Block{}, - startSigs: map[string][]TriggerTarget{}, - hasEndSignal: map[string]bool{}, - active: map[string]string{}, - pending: map[string]struct{}{}, - rows: make([]TimelineRow, 0, len(show.Triggers)+8), - noTitle: map[int]struct{}{}, + b := &timelineBuilder{ + blocks: map[string]Block{}, + trackIdx: map[string]int{}, + startSigs: map[string][]TriggerTarget{}, } - builder.trackIDs = append(builder.trackIDs, cueTrackID) - builder.tracks = append(builder.tracks, Track{ID: cueTrackID, Name: "Cue"}) - for _, track := range show.Tracks { - builder.trackIDs = append(builder.trackIDs, track.ID) - builder.tracks = append(builder.tracks, track) + b.trackIDs = append(b.trackIDs, cueTrackID) + b.tracks = append(b.tracks, Track{ID: cueTrackID, Name: "Cue"}) + b.trackIdx[cueTrackID] = 0 + for i, track := range show.Tracks { + b.trackIDs = append(b.trackIDs, track.ID) + b.tracks = append(b.tracks, track) + b.trackIdx[track.ID] = i + 1 } for _, block := range show.Blocks { - builder.blocks[block.ID] = block + b.blocks[block.ID] = block } for _, trigger := range show.Triggers { if trigger.Source.Signal == "START" { - builder.startSigs[trigger.Source.Block] = append(builder.startSigs[trigger.Source.Block], trigger.Targets...) - } - if trigger.Source.Signal == "END" { - builder.hasEndSignal[trigger.Source.Block] = true + b.startSigs[trigger.Source.Block] = append(b.startSigs[trigger.Source.Block], trigger.Targets...) } } - if err := builder.buildRows(); err != nil { + b.trackCells = make([][]trackCell, len(b.trackIDs)) + + if err := b.buildCells(show); err != nil { return Timeline{}, err } - rows := builder.insertTitleRows() + b.assignRows() + + rows := b.renderRows() + blocks := map[string]Block{} - for id, block := range builder.blocks { + for id, block := range b.blocks { blocks[id] = block } return Timeline{ - Tracks: builder.tracks, + Tracks: b.tracks, Blocks: blocks, Rows: rows, }, nil } -func (b *timelineBuilder) buildRows() error { - for i := 0; i < len(b.show.Triggers); i++ { - trigger := b.show.Triggers[i] - if trigger.Source.Signal == "START" { - continue - } - - if b.isChain(trigger) { - if _, hasStartSignals := b.startSigs[trigger.Targets[0].Block]; hasStartSignals { - if err := b.processChainWithStartSignals(trigger); err != nil { - return err - } - continue - } - - nextIndex, err := b.processChainBatch(i) - if err != nil { - return err - } - i = nextIndex - continue - } - - if err := b.processSignal(trigger); err != nil { - return err - } +func (b *timelineBuilder) appendCell(trackID string, cell TimelineCell) cellID { + idx, ok := b.trackIdx[trackID] + if !ok { + return cellID{-1, -1} } - - b.flushPending() - - activeEvents := map[string]TimelineCell{} - for trackID, blockID := range b.active { - if trackID == cueTrackID { - continue - } - b.setCell(activeEvents, trackID, TimelineCell{BlockID: blockID}) - } - if len(activeEvents) > 0 { - b.addRow(b.mkCells(activeEvents)) - } - - return nil + i := len(b.trackCells[idx]) + b.trackCells[idx] = append(b.trackCells[idx], trackCell{cell: cell}) + return cellID{track: idx, index: i} } -func (b *timelineBuilder) processChainWithStartSignals(trigger Trigger) error { - b.flushPending() - - sourceID := trigger.Source.Block - targetID := trigger.Targets[0].Block - trackID := b.getTrack(targetID) - if trackID == "" { - return fmt.Errorf("missing track for block %s", targetID) - } - - ends := map[string]TimelineCell{} - if (b.active[trackID] == sourceID) || b.hasPending(sourceID) { - delete(b.pending, sourceID) - delete(b.active, trackID) - b.setCell(ends, trackID, TimelineCell{BlockID: sourceID, IsEnd: true, Event: "END"}) - } - if len(ends) > 0 { - b.addRow(b.mkCells(ends)) - } - - b.active[trackID] = targetID - starts := map[string]TimelineCell{} - sideEffects := newOrderedHooks() - expanded := b.expandTargets(b.startSigs[targetID]) - expanded.ForEach(func(block, hook string) { - sideEffects.Set(block, hook) - }) - b.setCell(starts, trackID, TimelineCell{BlockID: targetID, IsStart: true, Event: "START", IsSignal: true}) - - b.noTitle[len(b.rows)-1] = struct{}{} - sideEffects.ForEach(func(block, hook string) { - b.applySideEffect(starts, block, hook) - }) - - b.addRow(b.mkCells(starts)) - return nil -} - -func (b *timelineBuilder) processChainBatch(startIndex int) (int, error) { - trigger := b.show.Triggers[startIndex] - batch := []Trigger{trigger} - tracks := map[string]struct{}{b.getTrack(trigger.Source.Block): {}} - j := startIndex + 1 - - for j < len(b.show.Triggers) { - candidate := b.show.Triggers[j] - if candidate.Source.Signal == "START" { - j++ - continue - } - if !b.isChain(candidate) { - break - } - candidateTrack := b.getTrack(candidate.Source.Block) - if _, exists := tracks[candidateTrack]; exists { - break - } - if _, hasStartSignals := b.startSigs[candidate.Targets[0].Block]; hasStartSignals { - break - } - tracks[candidateTrack] = struct{}{} - batch = append(batch, candidate) - j++ - } - - b.flushPending() - - ends := map[string]TimelineCell{} - for _, chain := range batch { - sourceID := chain.Source.Block - trackID := b.getTrack(sourceID) - if trackID == "" { - return startIndex, fmt.Errorf("missing track for block %s", sourceID) - } - if (b.active[trackID] == sourceID) || b.hasPending(sourceID) { - delete(b.pending, sourceID) - delete(b.active, trackID) - b.setCell(ends, trackID, TimelineCell{BlockID: sourceID, IsEnd: true, Event: "END"}) - } - } - if len(ends) > 0 { - b.addRow(b.mkCells(ends)) - } - - starts := map[string]TimelineCell{} - sideEffects := newOrderedHooks() - for _, chain := range batch { - targetID := chain.Targets[0].Block - trackID := b.getTrack(targetID) - if trackID == "" { - return startIndex, fmt.Errorf("missing track for block %s", targetID) - } - b.active[trackID] = targetID - _, hasStartSignals := b.startSigs[targetID] - if hasStartSignals { - expanded := b.expandTargets(b.startSigs[targetID]) - expanded.ForEach(func(block, hook string) { - sideEffects.Set(block, hook) - }) - } - b.setCell(starts, trackID, TimelineCell{BlockID: targetID, IsStart: true, Event: "START", IsSignal: hasStartSignals}) - } - - b.noTitle[len(b.rows)-1] = struct{}{} - sideEffects.ForEach(func(block, hook string) { - b.applySideEffect(starts, block, hook) - }) - - b.addRow(b.mkCells(starts)) - - return j - 1, nil -} - -func (b *timelineBuilder) processSignal(trigger Trigger) error { - b.flushPending() - - isCue := trigger.Source.Signal == "GO" - targets := newOrderedHooks() - for _, target := range trigger.Targets { - targets.Set(target.Block, target.Hook) - } - expanded := b.expandTargets(trigger.Targets) - expanded.ForEach(func(block, hook string) { - targets.Set(block, hook) - }) - - events := map[string]TimelineCell{} - directEnds := map[string]struct{}{} - - if isCue { - b.setCell(events, cueTrackID, TimelineCell{ - BlockID: trigger.Source.Block, - IsStart: true, - IsEnd: true, - Event: trigger.Source.Signal, - IsSignal: true, - }) - } else { - sourceTrack := b.getTrack(trigger.Source.Block) - if sourceTrack != "" && sourceTrack != cueTrackID { - b.setCell(events, sourceTrack, TimelineCell{ - BlockID: trigger.Source.Block, - IsEnd: trigger.Source.Signal == "END", - Event: trigger.Source.Signal, - IsSignal: true, - }) - } - } - - targets.ForEach(func(blockID, hook string) { - trackID := b.getTrack(blockID) - if trackID == "" { - return - } - switch hook { - case "START": - b.active[trackID] = blockID - b.setCell(events, trackID, TimelineCell{ - BlockID: blockID, - IsStart: true, - Event: "START", - }) - case "END": - b.setCell(events, trackID, TimelineCell{ - BlockID: blockID, - IsEnd: true, - Event: "END", - }) - cell := events[trackID] - if cell.BlockID == blockID && cell.Event == "END" { - directEnds[blockID] = struct{}{} - } - case "FADE_OUT": - b.pending[blockID] = struct{}{} - b.setCell(events, trackID, TimelineCell{ - BlockID: blockID, - Event: "FADE_OUT", - }) - } - }) - - b.addRow(b.mkCells(events)) - - for blockID := range directEnds { - delete(b.active, b.getTrack(blockID)) - } - - if !isCue { - if trigger.Source.Signal == "FADE_OUT" { - b.pending[trigger.Source.Block] = struct{}{} - } - if trigger.Source.Signal == "END" { - delete(b.active, b.getTrack(trigger.Source.Block)) - delete(b.pending, trigger.Source.Block) - } - } - - return nil -} - -func (b *timelineBuilder) isChain(trigger Trigger) bool { - if trigger.Source.Signal != "END" || len(trigger.Targets) != 1 { - return false - } - return trigger.Targets[0].Hook == "START" && b.getTrack(trigger.Source.Block) == b.getTrack(trigger.Targets[0].Block) -} - -func (b *timelineBuilder) expandTargets(targets []TriggerTarget) orderedHooks { - result := newOrderedHooks() - queue := append([]TriggerTarget(nil), targets...) - - for len(queue) > 0 { - target := queue[0] - queue = queue[1:] - - if _, exists := result.Get(target.Block); exists { - continue - } - result.Set(target.Block, target.Hook) - - if target.Hook == "START" { - if chained, has := b.startSigs[target.Block]; has { - queue = append(queue, chained...) - } - } - } - - return result -} - -func (b *timelineBuilder) applySideEffect(events map[string]TimelineCell, blockID, hook string) { - trackID := b.getTrack(blockID) - if trackID == "" { +func (b *timelineBuilder) addConstraint(kind string, a, b2 cellID) { + if a.track < 0 || b2.track < 0 { return } - - switch hook { - case "START": - b.active[trackID] = blockID - b.setCell(events, trackID, TimelineCell{BlockID: blockID, IsStart: true, Event: "START"}) - case "END": - b.setCell(events, trackID, TimelineCell{BlockID: blockID, IsEnd: true, Event: "END"}) - delete(b.active, trackID) - case "FADE_OUT": - b.pending[blockID] = struct{}{} - b.setCell(events, trackID, TimelineCell{BlockID: blockID, Event: "FADE_OUT"}) - } -} - -func (b *timelineBuilder) flushPending() { - toEnd := make([]string, 0, len(b.pending)) - for blockID := range b.pending { - if !b.hasEndSignal[blockID] { - toEnd = append(toEnd, blockID) - } - } - if len(toEnd) == 0 { - return - } - sort.Strings(toEnd) - - events := map[string]TimelineCell{} - for _, blockID := range toEnd { - trackID := b.getTrack(blockID) - if trackID == "" { - continue - } - b.setCell(events, trackID, TimelineCell{BlockID: blockID, IsEnd: true, Event: "END"}) - } - if len(events) > 0 { - b.addRow(b.mkCells(events)) - } - - for _, blockID := range toEnd { - delete(b.active, b.getTrack(blockID)) - delete(b.pending, blockID) - } -} - -func (b *timelineBuilder) hasPending(blockID string) bool { - _, ok := b.pending[blockID] - return ok + b.constraints = append(b.constraints, constraint{kind: kind, a: a, b: b2}) } func (b *timelineBuilder) getTrack(blockID string) string { @@ -515,314 +164,256 @@ func (b *timelineBuilder) getTrack(blockID string) string { return block.Track } -func (b *timelineBuilder) setCell(events map[string]TimelineCell, trackID string, cell TimelineCell) { - existing, ok := events[trackID] - if !ok { - events[trackID] = cell +type blockCells struct { + start cellID + title cellID + fadeOut cellID + end cellID +} + +func (b *timelineBuilder) buildCells(show Show) error { + cells := map[string]blockCells{} + + for _, block := range show.Blocks { + trackID := b.getTrack(block.ID) + if trackID == "" { + continue + } + if block.Type == "cue" { + cueID := b.appendCell(trackID, TimelineCell{ + BlockID: block.ID, + IsStart: true, + IsEnd: true, + Event: "GO", + }) + cells[block.ID] = blockCells{start: cueID, title: cueID, fadeOut: cueID, end: cueID} + continue + } + startID := b.appendCell(trackID, TimelineCell{BlockID: block.ID, IsStart: true, Event: "START"}) + titleID := b.appendCell(trackID, TimelineCell{BlockID: block.ID, IsTitle: true}) + fadeOutID := b.appendCell(trackID, TimelineCell{BlockID: block.ID, Event: "FADE_OUT"}) + endID := b.appendCell(trackID, TimelineCell{BlockID: block.ID, IsEnd: true, Event: "END"}) + cells[block.ID] = blockCells{start: startID, title: titleID, fadeOut: fadeOutID, end: endID} + } + + for _, trigger := range show.Triggers { + if trigger.Source.Signal == "START" { + continue + } + + sourceCell := cells[trigger.Source.Block] + var sourceID cellID + switch trigger.Source.Signal { + case "GO": + sourceID = sourceCell.start + case "END": + sourceID = sourceCell.end + case "FADE_OUT": + sourceID = sourceCell.fadeOut + default: + continue + } + + group := exclusiveGroup{members: []cellID{sourceID}} + hasCrossTrack := false + + allTargets := b.expandTargets(trigger.Targets) + for _, target := range allTargets { + tc := cells[target.Block] + var targetID cellID + switch target.Hook { + case "START": + targetID = tc.start + case "END": + targetID = tc.end + case "FADE_OUT": + targetID = tc.fadeOut + default: + continue + } + if sourceID.track == targetID.track { + b.addConstraint("next_row", sourceID, targetID) + } else { + b.addConstraint("same_row", sourceID, targetID) + hasCrossTrack = true + } + group.members = append(group.members, targetID) + } + + if hasCrossTrack { + b.setSignal(sourceID) + } + b.exclusives = append(b.exclusives, group) + } + + return nil +} + +func (b *timelineBuilder) expandTargets(targets []TriggerTarget) []TriggerTarget { + var result []TriggerTarget + seen := map[string]bool{} + queue := append([]TriggerTarget(nil), targets...) + + for len(queue) > 0 { + target := queue[0] + queue = queue[1:] + if seen[target.Block] { + continue + } + seen[target.Block] = true + result = append(result, target) + if target.Hook == "START" { + if chained, has := b.startSigs[target.Block]; has { + queue = append(queue, chained...) + } + } + } + return result +} + +func (b *timelineBuilder) setSignal(id cellID) { + if id.track < 0 { return } - events[trackID] = mergeCell(existing, cell) + b.trackCells[id.track][id.index].cell.IsSignal = true } -func mergeCell(existing, next TimelineCell) TimelineCell { - if existing.IsTitle { - return existing - } - if existing.BlockID == "" { - return next - } - if next.BlockID == "" { - return existing - } - if existing.BlockID != next.BlockID { - return existing - } +func (b *timelineBuilder) assignRows() { + for iter := 0; iter < 10000; iter++ { + found := false + for _, c := range b.constraints { + aRow := b.rowOf(c.a) + bRow := b.rowOf(c.b) - existing.IsStart = existing.IsStart || next.IsStart - existing.IsEnd = existing.IsEnd || next.IsEnd - if existing.Event == "" { - existing.Event = next.Event - } - - if next.Event == "" || existing.Event == next.Event { - existing.IsSignal = existing.IsSignal || next.IsSignal - } - - if next.IsTitle { - existing.IsTitle = true - } - - return existing -} - -func (b *timelineBuilder) mkCells(events map[string]TimelineCell) []TimelineCell { - cells := make([]TimelineCell, 0, len(b.trackIDs)) - for _, trackID := range b.trackIDs { - if cell, ok := events[trackID]; ok { - cells = append(cells, cell) - } else { - cells = append(cells, b.midCell(trackID)) - } - } - return cells -} - -func (b *timelineBuilder) midCell(trackID string) TimelineCell { - if blockID, ok := b.active[trackID]; ok { - return TimelineCell{BlockID: blockID} - } - return TimelineCell{} -} - -func (b *timelineBuilder) addRow(cells []TimelineCell) { - if len(b.rows) > 0 { - last := b.rows[len(b.rows)-1] - if b.sameRowType(last.Cells, cells) { - merge := true - for i := 0; i < len(cells); i++ { - if hasEventOrCue(cells[i]) && hasEventOrCue(last.Cells[i]) { - merge = false - break + switch c.kind { + case "same_row": + if aRow < bRow { + b.insertGap(c.a.track, c.a.index) + found = true + } else if bRow < aRow { + b.insertGap(c.b.track, c.b.index) + found = true + } + case "next_row": + if bRow <= aRow { + b.insertGap(c.b.track, c.b.index) + found = true } } - if merge { - for i := 0; i < len(cells); i++ { - if hasEventOrCue(cells[i]) { - last.Cells[i] = cells[i] - } - } - b.rows[len(b.rows)-1] = last - return + if found { + break } } + if !found { + found = b.enforceExclusives() + } + if !found { + break + } } - - b.rows = append(b.rows, TimelineRow{Cells: cells}) } -func hasEventOrCue(cell TimelineCell) bool { - return cell.Event != "" -} - -func (b *timelineBuilder) rowType(cells []TimelineCell) (cue bool, signal bool) { - for _, cell := range cells { - if cell.BlockID == "" || cell.Event == "" { +func (b *timelineBuilder) enforceExclusives() bool { + for _, g := range b.exclusives { + if len(g.members) == 0 { continue } - block, ok := b.blocks[cell.BlockID] - if ok && block.Type == "cue" { - // Cue rows take precedence for type classification. - return true, false - } - if cell.IsSignal { - signal = true - } - } - return false, signal -} - -func (b *timelineBuilder) sameRowType(a, c []TimelineCell) bool { - cueA, signalA := b.rowType(a) - cueC, signalC := b.rowType(c) - return cueA == cueC && signalA == signalC -} - -func (b *timelineBuilder) insertTitleRows() []TimelineRow { - ranges := map[string]*blockRange{} - order := make([]string, 0, len(b.blocks)) - - for rowIndex, row := range b.rows { - for _, cell := range row.Cells { - if cell.BlockID == "" { - continue - } - rng, ok := ranges[cell.BlockID] - if !ok { - rng = &blockRange{first: rowIndex, last: rowIndex, start: -1, end: intMax} - ranges[cell.BlockID] = rng - order = append(order, cell.BlockID) - } - rng.last = rowIndex - if cell.Event == "START" { - rng.start = maxInt(rng.start, rowIndex) - } - if cell.Event == "END" || cell.Event == "FADE_OUT" { - rng.end = minInt(rng.end, rowIndex) + row := b.rowOf(g.members[0]) + allAligned := true + memberTracks := map[int]bool{} + for _, m := range g.members { + memberTracks[m.track] = true + if b.rowOf(m) != row { + allAligned = false } } - } - - titles := make([]titleInfo, 0, len(ranges)) - for _, blockID := range order { - block := b.blocks[blockID] - if block.Type == "cue" { + if !allAligned { continue } - rng := ranges[blockID] - s := rng.first - if rng.start >= 0 { - s = rng.start - } - e := rng.last - if rng.end != intMax { - e = rng.end - } - titleEnd := e - if rng.end != intMax { - titleEnd = rng.end - 1 - } - titles = append(titles, titleInfo{ - blockID: blockID, - track: b.getTrack(blockID), - s: s, - e: titleEnd, - pos: (s + titleEnd) / 2, - }) - } - sort.SliceStable(titles, func(i, j int) bool { - return titles[i].pos < titles[j].pos - }) - - groups := make([]titleGroup, 0, len(titles)) - for _, title := range titles { - bestIndex := -1 - bestDistance := intMax - for i := 0; i < len(groups); i++ { - group := groups[i] - intersectStart := maxInt(group.s, title.s) - intersectEnd := minInt(group.e, title.e) - if intersectStart > intersectEnd { + for trackIdx := range b.trackCells { + if memberTracks[trackIdx] { continue } - if hasTrack(group, title.track) { + if row >= len(b.trackCells[trackIdx]) { continue } - - candidate := group.pos - if candidate < intersectStart || candidate > intersectEnd { - candidate = (intersectStart + intersectEnd) / 2 + tc := b.trackCells[trackIdx][row] + if tc.isGap || tc.cell.BlockID == "" { + continue } - if b.isNoTitle(candidate) { - found := false - for r := candidate + 1; r <= intersectEnd; r++ { - if !b.isNoTitle(r) { - candidate = r - found = true - break - } - } - if !found { - continue - } - } - distance := absInt(candidate - title.pos) - if distance < bestDistance { - bestDistance = distance - bestIndex = i - } - } - - if bestIndex >= 0 { - group := groups[bestIndex] - group.s = maxInt(group.s, title.s) - group.e = minInt(group.e, title.e) - if group.pos < group.s || group.pos > group.e { - group.pos = (group.s + group.e) / 2 - } - if b.isNoTitle(group.pos) { - for r := group.pos + 1; r <= group.e; r++ { - if !b.isNoTitle(r) { - group.pos = r - break - } - } - } - group.titles = append(group.titles, title) - groups[bestIndex] = group - } else { - pos := title.pos - if b.isNoTitle(pos) { - for r := pos + 1; r <= title.e; r++ { - if !b.isNoTitle(r) { - pos = r - break - } - } - } - groups = append(groups, titleGroup{pos: pos, s: title.s, e: title.e, titles: []titleInfo{title}}) - } - } - sort.Slice(groups, func(i, j int) bool { - return groups[i].pos < groups[j].pos - }) - - finalRows := make([]TimelineRow, 0, len(b.rows)+len(groups)) - groupIndex := 0 - for rowIndex, row := range b.rows { - finalRows = append(finalRows, row) - for groupIndex < len(groups) && groups[groupIndex].pos == rowIndex { - group := groups[groupIndex] - cells := make([]TimelineCell, 0, len(b.trackIDs)) - for col, trackID := range b.trackIDs { - if title, ok := findTitleForTrack(group, trackID); ok { - cells = append(cells, TimelineCell{BlockID: title.blockID, IsTitle: true}) - continue - } - prev := row.Cells[col] - if prev.BlockID != "" && !prev.IsEnd { - cells = append(cells, TimelineCell{BlockID: prev.BlockID}) - } else { - cells = append(cells, TimelineCell{}) - } - } - finalRows = append(finalRows, TimelineRow{Cells: cells}) - groupIndex++ - } - } - - return finalRows -} - -func (b *timelineBuilder) isNoTitle(row int) bool { - _, ok := b.noTitle[row] - return ok -} - -func hasTrack(group titleGroup, track string) bool { - for _, title := range group.titles { - if title.track == track { + b.insertGap(trackIdx, row) return true } } return false } -func findTitleForTrack(group titleGroup, track string) (titleInfo, bool) { - for _, title := range group.titles { - if title.track == track { - return title, true +func (b *timelineBuilder) rowOf(id cellID) int { + return id.index +} + +func (b *timelineBuilder) insertGap(track, beforeIndex int) { + cells := b.trackCells[track] + newCells := make([]trackCell, 0, len(cells)+1) + newCells = append(newCells, cells[:beforeIndex]...) + newCells = append(newCells, trackCell{isGap: true}) + newCells = append(newCells, cells[beforeIndex:]...) + b.trackCells[track] = newCells + + for i := range b.constraints { + c := &b.constraints[i] + if c.a.track == track && c.a.index >= beforeIndex { + c.a.index++ + } + if c.b.track == track && c.b.index >= beforeIndex { + c.b.index++ + } + } + for i := range b.exclusives { + for j := range b.exclusives[i].members { + m := &b.exclusives[i].members[j] + if m.track == track && m.index >= beforeIndex { + m.index++ + } } } - return titleInfo{}, false } -func minInt(a, b int) int { - if a < b { - return a +func (b *timelineBuilder) renderRows() []TimelineRow { + maxLen := 0 + for _, cells := range b.trackCells { + if len(cells) > maxLen { + maxLen = len(cells) + } } - return b -} -func maxInt(a, b int) int { - if a > b { - return a + rows := make([]TimelineRow, maxLen) + for r := range rows { + rows[r].Cells = make([]TimelineCell, len(b.trackIDs)) } - return b -} -func absInt(v int) int { - if v < 0 { - return -v + for trackIdx, cells := range b.trackCells { + activeBlock := "" + for r := 0; r < maxLen; r++ { + if r < len(cells) { + tc := cells[r] + if tc.isGap { + if activeBlock != "" { + rows[r].Cells[trackIdx] = TimelineCell{BlockID: activeBlock} + } + } else { + rows[r].Cells[trackIdx] = tc.cell + if tc.cell.BlockID != "" && !tc.cell.IsEnd { + activeBlock = tc.cell.BlockID + } else if tc.cell.IsEnd { + activeBlock = "" + } + } + } else if activeBlock != "" { + rows[r].Cells[trackIdx] = TimelineCell{BlockID: activeBlock} + } + } } - return v + + return rows } diff --git a/cmd/qrunproxy/timeline_test.go b/cmd/qrunproxy/timeline_test.go deleted file mode 100644 index 62b90b6..0000000 --- a/cmd/qrunproxy/timeline_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "testing" -) - -func TestBuildTimelineFromFixture(t *testing.T) { - raw, err := os.ReadFile("static/show.json") - if err != nil { - t.Fatalf("read fixture: %v", err) - } - - var show Show - if err := json.Unmarshal(raw, &show); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - - timeline, err := BuildTimeline(show) - if err != nil { - t.Fatalf("build timeline: %v", err) - } - if len(timeline.Tracks) != len(show.Tracks)+1 { - t.Fatalf("track count mismatch: got %d want %d", len(timeline.Tracks), len(show.Tracks)+1) - } - if len(timeline.Rows) == 0 { - t.Fatalf("expected timeline rows") - } - - foundCueSignal := false - for _, row := range timeline.Rows { - for _, cell := range row.Cells { - if cell.Event != "GO" || cell.BlockID == "" { - continue - } - block, ok := timeline.Blocks[cell.BlockID] - if ok && block.Type == "cue" { - foundCueSignal = true - break - } - } - if foundCueSignal { - break - } - } - if !foundCueSignal { - t.Fatalf("expected at least one cue cell represented as block_id + GO event") - } -} - -func TestBuildTimelineMergesSameBlockEndCell(t *testing.T) { - show := Show{ - Tracks: []Track{ - {ID: "track1", Name: "Track 1"}, - }, - Blocks: []Block{ - {ID: "cue1", Type: "cue", Name: "Q1"}, - {ID: "a", Type: "light", Track: "track1", Name: "A"}, - }, - Triggers: []Trigger{ - { - Source: TriggerSource{Block: "cue1", Signal: "GO"}, - Targets: []TriggerTarget{ - {Block: "a", Hook: "START"}, - }, - }, - { - Source: TriggerSource{Block: "a", Signal: "END"}, - Targets: []TriggerTarget{ - {Block: "a", Hook: "END"}, - }, - }, - }, - } - - timeline, err := BuildTimeline(show) - if err != nil { - t.Fatalf("build timeline: %v", err) - } - - found := false - for _, row := range timeline.Rows { - for _, cell := range row.Cells { - if cell.BlockID == "a" && cell.Event == "END" { - if !cell.IsSignal || !cell.IsEnd { - t.Fatalf("expected END cell to include signal+end markers, got signal=%v is_end=%v", cell.IsSignal, cell.IsEnd) - } - found = true - } - } - } - if !found { - t.Fatalf("did not find END cell for block a") - } -} diff --git a/screenshot.sh b/screenshot.sh index 3f29098..61130c8 100755 --- a/screenshot.sh +++ b/screenshot.sh @@ -1,2 +1,2 @@ #!/bin/bash -exec go run ./cmd/qrunproxy/ "--run-and-exit=shot-scraper http://localhost:8080/ -o ${2:-/tmp/timeline.png} --width 1000 --height ${1:-1200}" +exec go run ./cmd/qrunproxy/ -addr :0 --run-and-exit="shot-scraper http://localhost:{port}/ -o ${2:-/tmp/timeline.png} --width 1000 --height ${1:-1200}"