diff --git a/cmd/qrunproxy/main.go b/cmd/qrunproxy/main.go
new file mode 100644
index 0000000..5d8b36a
--- /dev/null
+++ b/cmd/qrunproxy/main.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "io/fs"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+//go:embed static
+var staticFS embed.FS
+
+func main() {
+ addr := ":8080"
+ var runAndExit []string
+
+ for _, arg := range os.Args[1:] {
+ if v, ok := strings.CutPrefix(arg, "--run-and-exit="); ok {
+ runAndExit = strings.Fields(v)
+ } else {
+ addr = arg
+ }
+ }
+
+ show, err := loadMockShow()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error loading show: %v\n", err)
+ os.Exit(1)
+ }
+
+ timeline, err := BuildTimeline(show)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error building timeline: %v\n", err)
+ os.Exit(1)
+ }
+
+ sub, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+
+ mux := http.NewServeMux()
+ mux.Handle("/", http.FileServer(http.FS(sub)))
+ mux.HandleFunc("/api/show", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, show)
+ })
+ mux.HandleFunc("/api/timeline", func(w http.ResponseWriter, r *http.Request) {
+ writeJSON(w, timeline)
+ })
+
+ if len(runAndExit) > 0 {
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+ srv := &http.Server{Handler: mux}
+ go srv.Serve(ln)
+
+ cmd := exec.Command(runAndExit[0], runAndExit[1:]...)
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmdErr := cmd.Run()
+ srv.Shutdown(context.Background())
+ if cmdErr != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", cmdErr)
+ os.Exit(1)
+ }
+ return
+ }
+
+ fmt.Printf("Listening on %s\n", addr)
+ if err := http.ListenAndServe(addr, mux); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func loadMockShow() (Show, error) {
+ buf, err := staticFS.ReadFile("static/show.json")
+ if err != nil {
+ return Show{}, err
+ }
+ var show Show
+ if err := json.Unmarshal(buf, &show); err != nil {
+ return Show{}, err
+ }
+ return show, nil
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ enc := json.NewEncoder(w)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(v); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+}
diff --git a/cmd/qrunproxy/static/index.html b/cmd/qrunproxy/static/index.html
new file mode 100644
index 0000000..39fe34c
--- /dev/null
+++ b/cmd/qrunproxy/static/index.html
@@ -0,0 +1,216 @@
+
+
+
+
+
+Qrun
+
+
+
+
+
+
+
diff --git a/cmd/qrunproxy/static/show.json b/cmd/qrunproxy/static/show.json
new file mode 100644
index 0000000..da5c0c6
--- /dev/null
+++ b/cmd/qrunproxy/static/show.json
@@ -0,0 +1,159 @@
+{
+ "tracks": [
+ {"id": "track_01kht419m1e65bfgqzj28se459", "name": "Lighting A"},
+ {"id": "track_01kht419n7esr929mxvsacsy0k", "name": "Lighting B"},
+ {"id": "track_01kht419pbfx2992sjpagc0syy", "name": "Video"},
+ {"id": "track_01kht419qdeb1bjm9qe5acas2f", "name": "Video OVL"},
+ {"id": "track_01kht419rffhnayzmgqnnkddtz", "name": "Audio"}
+ ],
+ "blocks": [
+ {"id": "block_01kht41ax6f0ntbmqc5jxfda2m", "type": "cue", "name": "Q10 Preshow"},
+ {"id": "block_01kht41aygemwtrw590cet1en9", "type": "light", "track": "track_01kht419m1e65bfgqzj28se459", "name": "Preshow Wash"},
+ {"id": "block_01kht41azrenbaxyc2vebv7s4q", "type": "light", "track": "track_01kht419n7esr929mxvsacsy0k", "name": "Warm 70%"},
+ {"id": "block_01kht41b10emgt1gfvg7dy60ws", "type": "video", "track": "track_01kht419pbfx2992sjpagc0syy", "name": "Preshow Loop", "loop": true},
+ {"id": "block_01kht41b29eyes3b6neh4h56mn", "type": "audio", "track": "track_01kht419rffhnayzmgqnnkddtz", "name": "Preshow Music", "loop": true},
+ {"id": "block_01kht41b3ge6ntqwjqrjq4wsxm", "type": "cue", "name": "Q11 House Open"},
+ {"id": "block_01kht41b4rfnhvk83p9afhp08y", "type": "cue", "name": "Q12 Top of Show"},
+ {"id": "block_01kht41b5zf4ya2044tczm8tz6", "type": "light", "track": "track_01kht419n7esr929mxvsacsy0k", "name": "Cool 50%"},
+ {"id": "block_01kht41b76ebtraravpknavdm5", "type": "delay", "track": "track_01kht419pbfx2992sjpagc0syy", "name": "3s Delay"},
+ {"id": "block_01kht41b8deg8ae996tzqay0rg", "type": "light", "track": "track_01kht419m1e65bfgqzj28se459", "name": "SC1 Focus"},
+ {"id": "block_01kht41b9neqf84ap7dm3mey9r", "type": "light", "track": "track_01kht419n7esr929mxvsacsy0k", "name": "SC1 Blue 80%"},
+ {"id": "block_01kht41bawfbhr38a49tjvyahy", "type": "video", "track": "track_01kht419pbfx2992sjpagc0syy", "name": "Sc1 Projection"},
+ {"id": "block_01kht41bc3evybtn5hqnk6p2f7", "type": "video", "track": "track_01kht419qdeb1bjm9qe5acas2f", "name": "Lightning Flash"},
+ {"id": "block_01kht41bdafggbbm8959svkm4n", "type": "audio", "track": "track_01kht419rffhnayzmgqnnkddtz", "name": "Storm Ambience", "loop": true},
+ {"id": "block_01kht41bejev2997rfps2kpbat", "type": "cue", "name": "Q13 Sc1 Dialog"},
+ {"id": "block_01kht41bfsfhkvtvze4rh7k7z6", "type": "video", "track": "track_01kht419qdeb1bjm9qe5acas2f", "name": "Wave Overlay"},
+ {"id": "block_01kht41bgzfjcrkwrvrrjkqs0f", "type": "light", "track": "track_01kht419m1e65bfgqzj28se459", "name": "Dialog Spots"},
+ {"id": "block_01kht41bj7ea89xn71nn5h400a", "type": "light", "track": "track_01kht419n7esr929mxvsacsy0k", "name": "Warm 90%"},
+ {"id": "block_01kht41bkee1n81gnfq6bydmm2", "type": "audio", "track": "track_01kht419rffhnayzmgqnnkddtz", "name": "Dialog Underscore"},
+ {"id": "block_01kht41bmnfdbvfr4d08brj19k", "type": "cue", "name": "Q14 Sc2 Trans"},
+ {"id": "block_01kht41bnxfme95cspptf87j9b", "type": "light", "track": "track_01kht419m1e65bfgqzj28se459", "name": "SC2 Focus"},
+ {"id": "block_01kht41bq2ekwswsf51b0h1bd2", "type": "light", "track": "track_01kht419n7esr929mxvsacsy0k", "name": "SC2 Amber 60%"},
+ {"id": "block_01kht41br4eajaj99tmey3eph0", "type": "video", "track": "track_01kht419pbfx2992sjpagc0syy", "name": "Sc2 Background", "loop": true},
+ {"id": "block_01kht41bs5fxrr0a7mznd026v4", "type": "audio", "track": "track_01kht419rffhnayzmgqnnkddtz", "name": "SC2 Atmos", "loop": true}
+ ],
+ "triggers": [
+ {
+ "source": {"block": "block_01kht41ax6f0ntbmqc5jxfda2m", "signal": "GO"},
+ "targets": [
+ {"block": "block_01kht41aygemwtrw590cet1en9", "hook": "START"},
+ {"block": "block_01kht41azrenbaxyc2vebv7s4q", "hook": "START"},
+ {"block": "block_01kht41b10emgt1gfvg7dy60ws", "hook": "START"},
+ {"block": "block_01kht41b29eyes3b6neh4h56mn", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41b3ge6ntqwjqrjq4wsxm", "signal": "GO"},
+ "targets": [
+ {"block": "block_01kht41azrenbaxyc2vebv7s4q", "hook": "FADE_OUT"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41azrenbaxyc2vebv7s4q", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41b5zf4ya2044tczm8tz6", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41b4rfnhvk83p9afhp08y", "signal": "GO"},
+ "targets": [
+ {"block": "block_01kht41aygemwtrw590cet1en9", "hook": "FADE_OUT"},
+ {"block": "block_01kht41b10emgt1gfvg7dy60ws", "hook": "FADE_OUT"},
+ {"block": "block_01kht41b29eyes3b6neh4h56mn", "hook": "FADE_OUT"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41b10emgt1gfvg7dy60ws", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41b76ebtraravpknavdm5", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41b76ebtraravpknavdm5", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41bawfbhr38a49tjvyahy", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bawfbhr38a49tjvyahy", "signal": "START"},
+ "targets": [
+ {"block": "block_01kht41b8deg8ae996tzqay0rg", "hook": "START"},
+ {"block": "block_01kht41b5zf4ya2044tczm8tz6", "hook": "END"},
+ {"block": "block_01kht41bdafggbbm8959svkm4n", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41b5zf4ya2044tczm8tz6", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41b9neqf84ap7dm3mey9r", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41b9neqf84ap7dm3mey9r", "signal": "START"},
+ "targets": [
+ {"block": "block_01kht41bc3evybtn5hqnk6p2f7", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bawfbhr38a49tjvyahy", "signal": "FADE_OUT"},
+ "targets": [
+ {"block": "block_01kht41bc3evybtn5hqnk6p2f7", "hook": "END"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bejev2997rfps2kpbat", "signal": "GO"},
+ "targets": [
+ {"block": "block_01kht41b8deg8ae996tzqay0rg", "hook": "FADE_OUT"},
+ {"block": "block_01kht41b9neqf84ap7dm3mey9r", "hook": "FADE_OUT"},
+ {"block": "block_01kht41bdafggbbm8959svkm4n", "hook": "FADE_OUT"},
+ {"block": "block_01kht41bfsfhkvtvze4rh7k7z6", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41b9neqf84ap7dm3mey9r", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41bj7ea89xn71nn5h400a", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bdafggbbm8959svkm4n", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41bkee1n81gnfq6bydmm2", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bkee1n81gnfq6bydmm2", "signal": "START"},
+ "targets": [
+ {"block": "block_01kht41bgzfjcrkwrvrrjkqs0f", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bmnfdbvfr4d08brj19k", "signal": "GO"},
+ "targets": [
+ {"block": "block_01kht41bgzfjcrkwrvrrjkqs0f", "hook": "FADE_OUT"},
+ {"block": "block_01kht41bj7ea89xn71nn5h400a", "hook": "FADE_OUT"},
+ {"block": "block_01kht41br4eajaj99tmey3eph0", "hook": "START"},
+ {"block": "block_01kht41bfsfhkvtvze4rh7k7z6", "hook": "FADE_OUT"},
+ {"block": "block_01kht41bkee1n81gnfq6bydmm2", "hook": "END"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bgzfjcrkwrvrrjkqs0f", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41bnxfme95cspptf87j9b", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bj7ea89xn71nn5h400a", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41bq2ekwswsf51b0h1bd2", "hook": "START"}
+ ]
+ },
+ {
+ "source": {"block": "block_01kht41bfsfhkvtvze4rh7k7z6", "signal": "END"},
+ "targets": [
+ {"block": "block_01kht41bs5fxrr0a7mznd026v4", "hook": "START"}
+ ]
+ }
+ ]
+}
diff --git a/cmd/qrunproxy/timeline.go b/cmd/qrunproxy/timeline.go
new file mode 100644
index 0000000..c764cbf
--- /dev/null
+++ b/cmd/qrunproxy/timeline.go
@@ -0,0 +1,828 @@
+package main
+
+import (
+ "fmt"
+ "sort"
+)
+
+const (
+ cueTrackID = "_cue"
+ intMax = int(^uint(0) >> 1)
+)
+
+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 Timeline struct {
+ Tracks []Track `json:"tracks"`
+ Blocks map[string]Block `json:"blocks"`
+ Rows []TimelineRow `json:"rows"`
+}
+
+type TimelineRow struct {
+ Cells []TimelineCell `json:"cells"`
+}
+
+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"`
+}
+
+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{}
+}
+
+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])
+ }
+}
+
+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{}{},
+ }
+
+ 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)
+ }
+ for _, block := range show.Blocks {
+ builder.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
+ }
+ }
+
+ if err := builder.buildRows(); err != nil {
+ return Timeline{}, err
+ }
+
+ rows := builder.insertTitleRows()
+ blocks := map[string]Block{}
+ for id, block := range builder.blocks {
+ blocks[id] = block
+ }
+
+ return Timeline{
+ Tracks: builder.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
+ }
+ }
+
+ 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
+}
+
+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 == "" {
+ 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
+}
+
+func (b *timelineBuilder) getTrack(blockID string) string {
+ block, ok := b.blocks[blockID]
+ if !ok {
+ return ""
+ }
+ if block.Type == "cue" {
+ return cueTrackID
+ }
+ return block.Track
+}
+
+func (b *timelineBuilder) setCell(events map[string]TimelineCell, trackID string, cell TimelineCell) {
+ existing, ok := events[trackID]
+ if !ok {
+ events[trackID] = cell
+ return
+ }
+ events[trackID] = mergeCell(existing, cell)
+}
+
+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
+ }
+
+ 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
+ }
+ }
+ 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
+ }
+ }
+ }
+
+ 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 == "" {
+ 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)
+ }
+ }
+ }
+
+ titles := make([]titleInfo, 0, len(ranges))
+ for _, blockID := range order {
+ block := b.blocks[blockID]
+ if block.Type == "cue" {
+ 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 {
+ continue
+ }
+ if hasTrack(group, title.track) {
+ continue
+ }
+
+ candidate := group.pos
+ if candidate < intersectStart || candidate > intersectEnd {
+ candidate = (intersectStart + intersectEnd) / 2
+ }
+ 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 {
+ return true
+ }
+ }
+ return false
+}
+
+func findTitleForTrack(group titleGroup, track string) (titleInfo, bool) {
+ for _, title := range group.titles {
+ if title.track == track {
+ return title, true
+ }
+ }
+ return titleInfo{}, false
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func absInt(v int) int {
+ if v < 0 {
+ return -v
+ }
+ return v
+}
diff --git a/cmd/qrunproxy/timeline_test.go b/cmd/qrunproxy/timeline_test.go
new file mode 100644
index 0000000..62b90b6
--- /dev/null
+++ b/cmd/qrunproxy/timeline_test.go
@@ -0,0 +1,96 @@
+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/lib/show/model.go b/lib/show/model.go
deleted file mode 100644
index a8c61ed..0000000
--- a/lib/show/model.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package show
-
-type StateType string
-
-const (
- Lighting StateType = "lighting"
- Media StateType = "media"
-)
-
-type State struct {
- ID string `json:"id"`
- Type StateType `json:"type"`
- Sequence int `json:"sequence"`
- Layer int `json:"layer"`
-
- LightingParams *LightingParams `json:"lightingParams,omitempty"`
- MediaParams *MediaParams `json:"mediaParams,omitempty"`
-}
-
-type LightingParams struct {
- Fixtures []FixtureSetting `json:"fixtures"`
-}
-
-type FixtureSetting struct {
- ID string `json:"id"`
- Channels map[string]int `json:"channels"`
-}
-
-type MediaParams struct {
- Source string `json:"source"`
- Loop bool `json:"loop"`
-}
-
-type Cue struct {
- ID string `json:"id"`
- Sequence int `json:"sequence"`
- Name string `json:"name"`
-}
diff --git a/screenshot.sh b/screenshot.sh
index 613da29..3f29098 100755
--- a/screenshot.sh
+++ b/screenshot.sh
@@ -1,2 +1,2 @@
#!/bin/bash
-exec go run ./cmd/qrunweb/ "--run-and-exit=shot-scraper http://localhost:8080/ -o ${2:-/tmp/timeline.png} --width 1000 --height ${1:-1200}"
+exec go run ./cmd/qrunproxy/ "--run-and-exit=shot-scraper http://localhost:8080/ -o ${2:-/tmp/timeline.png} --width 1000 --height ${1:-1200}"