From 0778ffa6f888ccbf90daf6005dde8ed7f397f384 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Thu, 19 Feb 2026 20:55:28 -0700 Subject: [PATCH] checkpoint: scaffold qrunproxy timeline API --- cmd/qrunproxy/main.go | 105 ++++ cmd/qrunproxy/static/index.html | 216 +++++++++ cmd/qrunproxy/static/show.json | 159 ++++++ cmd/qrunproxy/timeline.go | 828 ++++++++++++++++++++++++++++++++ cmd/qrunproxy/timeline_test.go | 96 ++++ lib/show/model.go | 38 -- screenshot.sh | 2 +- 7 files changed, 1405 insertions(+), 39 deletions(-) create mode 100644 cmd/qrunproxy/main.go create mode 100644 cmd/qrunproxy/static/index.html create mode 100644 cmd/qrunproxy/static/show.json create mode 100644 cmd/qrunproxy/timeline.go create mode 100644 cmd/qrunproxy/timeline_test.go delete mode 100644 lib/show/model.go 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 + + + +
+
+

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}"