2026-02-20 20:10:43 -07:00
|
|
|
package main
|
|
|
|
|
|
2026-02-20 20:23:34 -07:00
|
|
|
import "fmt"
|
2026-02-20 20:10:43 -07:00
|
|
|
|
|
|
|
|
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"`
|
2026-02-23 22:42:21 -08:00
|
|
|
|
|
|
|
|
weight int
|
2026-02-20 20:10:43 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Trigger struct {
|
|
|
|
|
Source TriggerSource `json:"source"`
|
|
|
|
|
Targets []TriggerTarget `json:"targets"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 19:14:14 -08:00
|
|
|
func (t *Trigger) String() string {
|
|
|
|
|
s := fmt.Sprintf("%s/%s ->", t.Source.Block, t.Source.Signal)
|
|
|
|
|
for _, target := range t.Targets {
|
|
|
|
|
s += fmt.Sprintf(" %s/%s", target.Block, target.Hook)
|
|
|
|
|
}
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 20:10:43 -07:00
|
|
|
type TriggerSource struct {
|
|
|
|
|
Block string `json:"block"`
|
|
|
|
|
Signal string `json:"signal"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TriggerTarget struct {
|
|
|
|
|
Block string `json:"block"`
|
|
|
|
|
Hook string `json:"hook"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 21:02:18 -07:00
|
|
|
func (block *Block) hasDefinedTiming() bool {
|
|
|
|
|
if block.Type == "cue" || block.Type == "delay" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
if block.Type == "media" && !block.Loop {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 20:10:43 -07:00
|
|
|
func isValidEventForBlock(block *Block, event string) bool {
|
|
|
|
|
if block.Type == "cue" {
|
|
|
|
|
return event == "GO"
|
|
|
|
|
}
|
|
|
|
|
switch event {
|
|
|
|
|
case "START", "FADE_OUT", "END":
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 20:20:25 -07:00
|
|
|
func (show *Show) Validate() error {
|
2026-02-20 20:10:43 -07:00
|
|
|
if show == nil {
|
|
|
|
|
return fmt.Errorf("show is nil")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
trackIDs := map[string]bool{}
|
|
|
|
|
for _, track := range show.Tracks {
|
|
|
|
|
if trackIDs[track.ID] {
|
|
|
|
|
return fmt.Errorf("duplicate track id %q", track.ID)
|
|
|
|
|
}
|
|
|
|
|
trackIDs[track.ID] = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocksByID := map[string]*Block{}
|
|
|
|
|
for _, block := range show.Blocks {
|
|
|
|
|
if blocksByID[block.ID] != nil {
|
|
|
|
|
return fmt.Errorf("duplicate block id %q", block.ID)
|
|
|
|
|
}
|
|
|
|
|
blocksByID[block.ID] = block
|
|
|
|
|
if block.Type == "cue" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !trackIDs[block.Track] {
|
|
|
|
|
return fmt.Errorf("block %q uses unknown track %q", block.ID, block.Track)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 21:02:18 -07:00
|
|
|
type blockEvent struct {
|
|
|
|
|
block string
|
|
|
|
|
event string
|
|
|
|
|
}
|
|
|
|
|
hookTargeted := map[blockEvent]bool{}
|
2026-02-20 20:10:43 -07:00
|
|
|
startTargeted := map[string]bool{}
|
2026-02-20 22:31:04 -07:00
|
|
|
sourceUsed := map[blockEvent]bool{}
|
2026-02-21 19:14:14 -08:00
|
|
|
signalTargetedBy := map[blockEvent]*Trigger{}
|
|
|
|
|
|
|
|
|
|
for _, trigger := range show.Triggers {
|
|
|
|
|
for _, target := range trigger.Targets {
|
|
|
|
|
signalTargetedBy[blockEvent{target.Block, target.Hook}] = trigger
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 20:10:43 -07:00
|
|
|
for _, trigger := range show.Triggers {
|
|
|
|
|
sourceBlock := blocksByID[trigger.Source.Block]
|
|
|
|
|
if sourceBlock == nil {
|
|
|
|
|
return fmt.Errorf("trigger source block %q not found", trigger.Source.Block)
|
|
|
|
|
}
|
2026-02-21 19:14:14 -08:00
|
|
|
|
|
|
|
|
targetedTracks := map[string]string{}
|
|
|
|
|
for _, target := range trigger.Targets {
|
|
|
|
|
targetBlock := blocksByID[target.Block]
|
|
|
|
|
if prev, ok := targetedTracks[targetBlock.Track]; ok {
|
|
|
|
|
return fmt.Errorf("trigger conflict: %s targets multiple blocks on track %q (%q and %q)",
|
|
|
|
|
trigger, targetBlock.Track, prev, target.Block)
|
|
|
|
|
}
|
|
|
|
|
targetedTracks[targetBlock.Track] = target.Block
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if t, ok := signalTargetedBy[blockEvent{trigger.Source.Block, trigger.Source.Signal}]; ok {
|
2026-02-21 22:13:12 -08:00
|
|
|
sameTrackSingle := len(trigger.Targets) == 1 && blocksByID[trigger.Targets[0].Block].Track == sourceBlock.Track
|
|
|
|
|
if !sameTrackSingle {
|
|
|
|
|
return fmt.Errorf("trigger conflict: %s vs %s", t, trigger)
|
|
|
|
|
}
|
2026-02-21 19:14:14 -08:00
|
|
|
}
|
2026-02-20 20:10:43 -07:00
|
|
|
if !isValidEventForBlock(sourceBlock, trigger.Source.Signal) {
|
|
|
|
|
return fmt.Errorf("trigger source signal %q is invalid for block %q", trigger.Source.Signal, trigger.Source.Block)
|
|
|
|
|
}
|
2026-02-20 22:31:04 -07:00
|
|
|
src := blockEvent{trigger.Source.Block, trigger.Source.Signal}
|
|
|
|
|
if sourceUsed[src] {
|
|
|
|
|
return fmt.Errorf("duplicate trigger source: block %q signal %q", trigger.Source.Block, trigger.Source.Signal)
|
|
|
|
|
}
|
|
|
|
|
sourceUsed[src] = true
|
2026-02-20 20:10:43 -07:00
|
|
|
|
|
|
|
|
for _, target := range trigger.Targets {
|
|
|
|
|
targetBlock := blocksByID[target.Block]
|
|
|
|
|
if targetBlock == nil {
|
|
|
|
|
return fmt.Errorf("trigger target block %q not found", target.Block)
|
|
|
|
|
}
|
|
|
|
|
if !isValidEventForBlock(targetBlock, target.Hook) {
|
|
|
|
|
return fmt.Errorf("trigger target hook %q is invalid for block %q", target.Hook, target.Block)
|
|
|
|
|
}
|
2026-02-20 21:02:18 -07:00
|
|
|
hookTargeted[blockEvent{target.Block, target.Hook}] = true
|
2026-02-20 20:10:43 -07:00
|
|
|
if target.Hook == "START" {
|
|
|
|
|
startTargeted[target.Block] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, block := range show.Blocks {
|
|
|
|
|
if block.Type == "cue" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !startTargeted[block.ID] {
|
|
|
|
|
return fmt.Errorf("block %q has no trigger for its START", block.ID)
|
|
|
|
|
}
|
2026-02-20 22:31:04 -07:00
|
|
|
if !block.hasDefinedTiming() && !hookTargeted[blockEvent{block.ID, "FADE_OUT"}] && !hookTargeted[blockEvent{block.ID, "END"}] {
|
|
|
|
|
return fmt.Errorf("block %q has no defined timing and nothing triggers its FADE_OUT or END", block.ID)
|
|
|
|
|
}
|
2026-02-20 20:10:43 -07:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 21:02:18 -07:00
|
|
|
for _, trigger := range show.Triggers {
|
|
|
|
|
sourceBlock := blocksByID[trigger.Source.Block]
|
|
|
|
|
for _, target := range trigger.Targets {
|
|
|
|
|
targetBlock := blocksByID[target.Block]
|
|
|
|
|
if sourceBlock.Type != "cue" && targetBlock.Type != "cue" && sourceBlock.Track == targetBlock.Track && target.Hook == "START" && trigger.Source.Signal != "END" {
|
|
|
|
|
return fmt.Errorf("same-track START trigger from %q to %q must use END signal, not %s", sourceBlock.ID, targetBlock.ID, trigger.Source.Signal)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if sourceBlock.hasDefinedTiming() {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
signal := trigger.Source.Signal
|
|
|
|
|
if signal != "FADE_OUT" && signal != "END" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if signal == "END" && hookTargeted[blockEvent{sourceBlock.ID, "FADE_OUT"}] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if !hookTargeted[blockEvent{sourceBlock.ID, signal}] {
|
|
|
|
|
return fmt.Errorf("block %q has no defined timing and nothing triggers its %s, so its %s signal will never fire", sourceBlock.ID, signal, signal)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 20:10:43 -07:00
|
|
|
return nil
|
|
|
|
|
}
|