Realistic mock show generator, validation for same-track chains and untimed signals, loop icon, assignRows convergence limit
This commit is contained in:
@@ -28,7 +28,7 @@ func main() {
|
|||||||
runAndExit = strings.Fields(*runAndExitStr)
|
runAndExit = strings.Fields(*runAndExitStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
show := GenerateMockShow(7, 100, 1000)
|
show := GenerateMockShow(5, 100, 1000)
|
||||||
if err := show.Validate(); err != nil {
|
if err := show.Validate(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error validating show: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error validating show: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -1,70 +1,144 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trackNamePool = []string{
|
||||||
|
"Lighting", "Fill Light", "Spots", "Video", "Video OVL",
|
||||||
|
"Audio", "SFX", "Ambience", "Pyro", "Fog", "Motors",
|
||||||
|
"Follow Spot", "Haze", "Projector", "LED Wall",
|
||||||
|
}
|
||||||
|
|
||||||
|
var lightNamePool = []string{
|
||||||
|
"Wash", "Focus", "Spot", "Amber", "Blue", "Cool", "Warm",
|
||||||
|
"Flood", "Strobe", "Blackout", "Dim", "Bright", "Sunrise",
|
||||||
|
}
|
||||||
|
|
||||||
|
var mediaNamePool = []string{
|
||||||
|
"Loop", "Projection", "Background", "Overlay", "Flash",
|
||||||
|
"Ambience", "Underscore", "Sting", "Bumper", "Transition",
|
||||||
|
}
|
||||||
|
|
||||||
|
var delayNamePool = []string{
|
||||||
|
"1s Delay", "2s Delay", "3s Delay", "5s Delay", "Hold",
|
||||||
|
}
|
||||||
|
|
||||||
func GenerateMockShow(numTracks, numCues, numBlocks int) *Show {
|
func GenerateMockShow(numTracks, numCues, numBlocks int) *Show {
|
||||||
show := &Show{}
|
rng := rand.New(rand.NewPCG(42, 0))
|
||||||
|
|
||||||
|
show := &Show{}
|
||||||
|
blockIdx := 0
|
||||||
|
nextBlockID := func() string {
|
||||||
|
id := fmt.Sprintf("b%d", blockIdx)
|
||||||
|
blockIdx++
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, len(trackNamePool))
|
||||||
|
copy(names, trackNamePool)
|
||||||
|
rng.Shuffle(len(names), func(i, j int) {
|
||||||
|
names[i], names[j] = names[j], names[i]
|
||||||
|
})
|
||||||
for i := range numTracks {
|
for i := range numTracks {
|
||||||
|
name := names[i%len(names)]
|
||||||
|
if i >= len(names) {
|
||||||
|
name = fmt.Sprintf("%s %d", name, i/len(names)+1)
|
||||||
|
}
|
||||||
show.Tracks = append(show.Tracks, &Track{
|
show.Tracks = append(show.Tracks, &Track{
|
||||||
ID: fmt.Sprintf("track_%d", i),
|
ID: fmt.Sprintf("track_%d", i),
|
||||||
Name: fmt.Sprintf("Track %d", i),
|
Name: name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range numCues {
|
randBlock := func(trackIdx int) *Block {
|
||||||
show.Blocks = append(show.Blocks, &Block{
|
r := rng.Float64()
|
||||||
ID: fmt.Sprintf("cue_%d", i),
|
var typ, name string
|
||||||
|
var loop bool
|
||||||
|
switch {
|
||||||
|
case r < 0.30:
|
||||||
|
typ, name = "light", lightNamePool[rng.IntN(len(lightNamePool))]
|
||||||
|
case r < 0.55:
|
||||||
|
typ, name = "media", mediaNamePool[rng.IntN(len(mediaNamePool))]
|
||||||
|
case r < 0.70:
|
||||||
|
typ, name, loop = "media", mediaNamePool[rng.IntN(len(mediaNamePool))], true
|
||||||
|
case r < 0.80:
|
||||||
|
typ, name = "delay", delayNamePool[rng.IntN(len(delayNamePool))]
|
||||||
|
default:
|
||||||
|
typ, name = "light", lightNamePool[rng.IntN(len(lightNamePool))]
|
||||||
|
}
|
||||||
|
return &Block{
|
||||||
|
ID: nextBlockID(),
|
||||||
|
Type: typ,
|
||||||
|
Track: fmt.Sprintf("track_%d", trackIdx),
|
||||||
|
Name: name,
|
||||||
|
Loop: loop,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
placed := 0
|
||||||
|
cueIdx := 0
|
||||||
|
|
||||||
|
for placed < numBlocks && cueIdx < numCues {
|
||||||
|
cue := &Block{
|
||||||
|
ID: fmt.Sprintf("q%d", cueIdx*10),
|
||||||
Type: "cue",
|
Type: "cue",
|
||||||
Name: fmt.Sprintf("Cue %d", i),
|
Name: fmt.Sprintf("Q%d", cueIdx*10),
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
blocksByTrack := make([][]*Block, numTracks)
|
|
||||||
for i := range numBlocks {
|
|
||||||
trackIdx := i % numTracks
|
|
||||||
trackID := fmt.Sprintf("track_%d", trackIdx)
|
|
||||||
block := &Block{
|
|
||||||
ID: fmt.Sprintf("block_%d_%d", trackIdx, len(blocksByTrack[trackIdx])),
|
|
||||||
Type: "media",
|
|
||||||
Track: trackID,
|
|
||||||
Name: fmt.Sprintf("Block %d-%d", trackIdx, len(blocksByTrack[trackIdx])),
|
|
||||||
}
|
}
|
||||||
show.Blocks = append(show.Blocks, block)
|
show.Blocks = append(show.Blocks, cue)
|
||||||
blocksByTrack[trackIdx] = append(blocksByTrack[trackIdx], block)
|
cueIdx++
|
||||||
}
|
|
||||||
|
|
||||||
for trackIdx := range numTracks {
|
tracksThisCue := numTracks - rng.IntN(2)
|
||||||
blocks := blocksByTrack[trackIdx]
|
perm := rng.Perm(numTracks)
|
||||||
for i := 1; i < len(blocks); i++ {
|
|
||||||
show.Triggers = append(show.Triggers, &Trigger{
|
|
||||||
Source: TriggerSource{Block: blocks[i-1].ID, Signal: "END"},
|
|
||||||
Targets: []TriggerTarget{{Block: blocks[i].ID, Hook: "START"}},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
headPerTrack := make([]int, numTracks)
|
cueTargets := []TriggerTarget{}
|
||||||
for i := range numCues {
|
for _, trackIdx := range perm[:tracksThisCue] {
|
||||||
cue := show.Blocks[i]
|
if placed >= numBlocks {
|
||||||
targets := []TriggerTarget{}
|
break
|
||||||
for trackIdx := range numTracks {
|
}
|
||||||
if headPerTrack[trackIdx] >= len(blocksByTrack[trackIdx]) {
|
block := randBlock(trackIdx)
|
||||||
continue
|
show.Blocks = append(show.Blocks, block)
|
||||||
|
cueTargets = append(cueTargets, TriggerTarget{Block: block.ID, Hook: "START"})
|
||||||
|
placed++
|
||||||
|
|
||||||
|
prev := block
|
||||||
|
chainLen := rng.IntN(3)
|
||||||
|
for range chainLen {
|
||||||
|
if placed >= numBlocks {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !prev.hasDefinedTiming() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next := randBlock(trackIdx)
|
||||||
|
show.Blocks = append(show.Blocks, next)
|
||||||
|
show.Triggers = append(show.Triggers, &Trigger{
|
||||||
|
Source: TriggerSource{Block: prev.ID, Signal: "END"},
|
||||||
|
Targets: []TriggerTarget{{Block: next.ID, Hook: "START"}},
|
||||||
|
})
|
||||||
|
prev = next
|
||||||
|
placed++
|
||||||
}
|
}
|
||||||
block := blocksByTrack[trackIdx][headPerTrack[trackIdx]]
|
|
||||||
targets = append(targets, TriggerTarget{Block: block.ID, Hook: "START"})
|
|
||||||
depth := len(blocksByTrack[trackIdx]) - headPerTrack[trackIdx]
|
|
||||||
advance := max(depth/(numCues-i), 1)
|
|
||||||
headPerTrack[trackIdx] += advance
|
|
||||||
}
|
}
|
||||||
if len(targets) > 0 {
|
|
||||||
|
if len(cueTargets) > 0 {
|
||||||
show.Triggers = append(show.Triggers, &Trigger{
|
show.Triggers = append(show.Triggers, &Trigger{
|
||||||
Source: TriggerSource{Block: cue.ID, Signal: "GO"},
|
Source: TriggerSource{Block: cue.ID, Signal: "GO"},
|
||||||
Targets: targets,
|
Targets: cueTargets,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for cueIdx < numCues {
|
||||||
|
cue := &Block{
|
||||||
|
ID: fmt.Sprintf("q%d", cueIdx*10),
|
||||||
|
Type: "cue",
|
||||||
|
Name: fmt.Sprintf("Q%d", cueIdx*10),
|
||||||
|
}
|
||||||
|
show.Blocks = append(show.Blocks, cue)
|
||||||
|
cueIdx++
|
||||||
|
}
|
||||||
|
|
||||||
return show
|
return show
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ type TriggerTarget struct {
|
|||||||
Hook string `json:"hook"`
|
Hook string `json:"hook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (block *Block) hasDefinedTiming() bool {
|
||||||
|
if block.Type == "cue" || block.Type == "delay" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if block.Type == "media" && !block.Loop {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func isValidEventForBlock(block *Block, event string) bool {
|
func isValidEventForBlock(block *Block, event string) bool {
|
||||||
if block.Type == "cue" {
|
if block.Type == "cue" {
|
||||||
return event == "GO"
|
return event == "GO"
|
||||||
@@ -75,6 +85,11 @@ func (show *Show) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type blockEvent struct {
|
||||||
|
block string
|
||||||
|
event string
|
||||||
|
}
|
||||||
|
hookTargeted := map[blockEvent]bool{}
|
||||||
startTargeted := map[string]bool{}
|
startTargeted := map[string]bool{}
|
||||||
for _, trigger := range show.Triggers {
|
for _, trigger := range show.Triggers {
|
||||||
sourceBlock := blocksByID[trigger.Source.Block]
|
sourceBlock := blocksByID[trigger.Source.Block]
|
||||||
@@ -93,6 +108,7 @@ func (show *Show) Validate() error {
|
|||||||
if !isValidEventForBlock(targetBlock, target.Hook) {
|
if !isValidEventForBlock(targetBlock, target.Hook) {
|
||||||
return fmt.Errorf("trigger target hook %q is invalid for block %q", target.Hook, target.Block)
|
return fmt.Errorf("trigger target hook %q is invalid for block %q", target.Hook, target.Block)
|
||||||
}
|
}
|
||||||
|
hookTargeted[blockEvent{target.Block, target.Hook}] = true
|
||||||
if target.Hook == "START" {
|
if target.Hook == "START" {
|
||||||
startTargeted[target.Block] = true
|
startTargeted[target.Block] = true
|
||||||
}
|
}
|
||||||
@@ -108,5 +124,28 @@ func (show *Show) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ function render(data) {
|
|||||||
div.className = 'cell' + rowCls;
|
div.className = 'cell' + rowCls;
|
||||||
if (c.is_title) {
|
if (c.is_title) {
|
||||||
const block = data.blocks[c.block_id] || {};
|
const block = data.blocks[c.block_id] || {};
|
||||||
div.innerHTML = `<div class="block block-mid ${block.type || ''}"><div class="title">${block.name || ''}</div></div>`;
|
const loop = block.loop ? ' \u21A9' : '';
|
||||||
|
div.innerHTML = `<div class="block block-mid ${block.type || ''}"><div class="title">${block.name || ''}${loop}</div></div>`;
|
||||||
} else if (c.block_id) {
|
} else if (c.block_id) {
|
||||||
const block = data.blocks[c.block_id] || {};
|
const block = data.blocks[c.block_id] || {};
|
||||||
const isInfinity = r === numRows - 1 && !c.is_end && !c.is_title;
|
const isInfinity = r === numRows - 1 && !c.is_end && !c.is_title;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
const cueTrackID = "_cue"
|
const cueTrackID = "_cue"
|
||||||
|
|
||||||
type TimelineTrack struct {
|
type TimelineTrack struct {
|
||||||
@@ -84,7 +86,9 @@ func BuildTimeline(show *Show) (Timeline, error) {
|
|||||||
|
|
||||||
tl.buildCells(endChains)
|
tl.buildCells(endChains)
|
||||||
tl.buildConstraints()
|
tl.buildConstraints()
|
||||||
tl.assignRows()
|
if err := tl.assignRows(); err != nil {
|
||||||
|
return Timeline{}, err
|
||||||
|
}
|
||||||
|
|
||||||
return tl, nil
|
return tl, nil
|
||||||
}
|
}
|
||||||
@@ -174,16 +178,29 @@ func (tl *Timeline) buildConstraints() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tl *Timeline) assignRows() {
|
func (tl *Timeline) assignRows() error {
|
||||||
for {
|
for range 1000000 {
|
||||||
if tl.enforceConstraints() {
|
if tl.enforceConstraints() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tl.enforceExclusives() {
|
if tl.enforceExclusives() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
break
|
return nil
|
||||||
}
|
}
|
||||||
|
for _, c := range tl.constraints {
|
||||||
|
switch c.kind {
|
||||||
|
case "same_row":
|
||||||
|
if c.a.row != c.b.row {
|
||||||
|
return fmt.Errorf("assignRows: unsatisfied %s constraint: %s/%s (row %d) vs %s/%s (row %d)", c.kind, c.a.BlockID, c.a.Event, c.a.row, c.b.BlockID, c.b.Event, c.b.row)
|
||||||
|
}
|
||||||
|
case "next_row":
|
||||||
|
if c.b.row <= c.a.row {
|
||||||
|
return fmt.Errorf("assignRows: unsatisfied %s constraint: %s/%s (row %d) must follow %s/%s (row %d)", c.kind, c.b.BlockID, c.b.Event, c.b.row, c.a.BlockID, c.a.Event, c.a.row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("assignRows: did not converge")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tl *Timeline) enforceConstraints() bool {
|
func (tl *Timeline) enforceConstraints() bool {
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBuildTimelineFromMockShow(t *testing.T) {
|
func TestBuildTimelineFromMockShow(t *testing.T) {
|
||||||
show := GenerateMockShow(7, 100, 1000)
|
t0 := time.Now()
|
||||||
|
show := GenerateMockShow(5, 100, 1000)
|
||||||
|
t.Logf("GenerateMockShow: %v (%d blocks, %d triggers)", time.Since(t0), len(show.Blocks), len(show.Triggers))
|
||||||
|
|
||||||
|
t1 := time.Now()
|
||||||
if err := show.Validate(); err != nil {
|
if err := show.Validate(); err != nil {
|
||||||
t.Fatalf("generated show failed validation: %v", err)
|
t.Fatalf("generated show failed validation: %v", err)
|
||||||
}
|
}
|
||||||
|
t.Logf("Validate: %v", time.Since(t1))
|
||||||
|
|
||||||
|
t2 := time.Now()
|
||||||
tl, err := BuildTimeline(show)
|
tl, err := BuildTimeline(show)
|
||||||
|
t.Logf("BuildTimeline: %v", time.Since(t2))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("BuildTimeline failed: %v", err)
|
t.Fatalf("BuildTimeline failed: %v", err)
|
||||||
}
|
}
|
||||||
if len(tl.Tracks) != 8 {
|
t.Logf("tracks=%d blocks=%d", len(tl.Tracks), len(tl.Blocks))
|
||||||
t.Errorf("expected 8 tracks (7 + cue), got %d", len(tl.Tracks))
|
|
||||||
}
|
|
||||||
if len(tl.Blocks) != 1100 {
|
|
||||||
t.Errorf("expected 1100 blocks (100 cues + 1000), got %d", len(tl.Blocks))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkBuildTimeline(b *testing.B) {
|
func BenchmarkBuildTimeline(b *testing.B) {
|
||||||
show := GenerateMockShow(7, 100, 1000)
|
show := GenerateMockShow(5, 100, 1000)
|
||||||
if err := show.Validate(); err != nil {
|
if err := show.Validate(); err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user