Assign Narration-Timed Animation Cues to Tracks with a Heap and Report Misses
Your narration says "the queue fills" from 1.5s to 3.0s, three animations belong to that phrase, and another one is still running from the previous phrase. You need to give each cue a start time and a track, keep cues on the same track from overlapping, and find out which cues don't fit.
The model
Each cue gets a window and a duration. The window runs from the start of its first phrase to the end of its last phrase, so show_arrows, which spans two phrases, gets 0.0s to 3.0s. A track can play one cue at a time, but different tracks can overlap, which is why fade_title and show_arrows both start at 0.0s.
The scheduler does four things:
- Sort cues by opening time, then closing time, then name.
- Pop the track that becomes free first from a heap of
(free_at, track)pairs. - Start the cue at
max(cue.opens, free_at). - If the cue ends after its window closes, report it and restore the track's old free time. Otherwise, place it and push the track back with the cue's end time.
import heapq
from typing import NamedTuple
# Narration phrases: (text, start_ms, end_ms)
NARRATION = [
("requests arrive", 0, 1500),
("the queue fills", 1500, 3000),
("workers drain it", 3000, 4500),
("latency drops", 4500, 5500),
]
# Animation cues: (name, first_phrase, last_phrase, duration_ms)
CUE_SPECS = [
("fade_title", 0, 0, 1200),
("show_arrows", 0, 1, 2000),
("fill_queue", 1, 1, 1200),
("flash_badge", 1, 1, 800),
("pulse_queue", 1, 1, 1000),
("spawn_workers", 2, 2, 1000),
("move_jobs", 2, 3, 2000),
("draw_graph", 3, 3, 900),
("label_graph", 3, 3, 500),
]
TRACK_COUNT = 2
class Cue(NamedTuple):
name: str
opens: int
closes: int
duration: int
def build_cues(narration, specs):
return [
Cue(name, narration[first][1], narration[last][2], duration)
for name, first, last, duration in specs
]
def schedule(cues, track_count):
free = [(0, track) for track in range(track_count)]
heapq.heapify(free)
placed, missed = [], []
for cue in sorted(cues, key=lambda c: (c.opens, c.closes, c.name)):
free_at, track = heapq.heappop(free)
start = max(cue.opens, free_at)
end = start + cue.duration
# The earliest-free track gives the earliest start, so a miss here
# means no other track could fit this cue either.
if end > cue.closes:
missed.append((cue, end - cue.closes))
heapq.heappush(free, (free_at, track))
else:
placed.append((track, start, end, cue))
heapq.heappush(free, (end, track))
return placed, missed
def sec(ms):
return f"{ms / 1000:.1f}s"
cues = build_cues(NARRATION, CUE_SPECS)
placed, missed = schedule(cues, TRACK_COUNT)
for track in range(TRACK_COUNT):
rows = sorted(row for row in placed if row[0] == track)
for (_, _, prev_end, _), (_, next_start, _, _) in zip(rows, rows[1:]):
assert prev_end <= next_start, "overlap on a track"
print(f"Track {track}:")
for _, start, end, cue in rows:
window = f"window {sec(cue.opens)}-{sec(cue.closes)}"
print(f" {sec(start)}-{sec(end)} {cue.name:<14}{window}")
assert all(c.opens <= s and e <= c.closes for _, s, e, c in placed)
assert len(placed) + len(missed) == len(cues)
print(f"Placed {len(placed)} of {len(cues)} cues on {TRACK_COUNT} tracks")
for cue, overrun in missed:
print(f"Cannot fit: {cue.name} overruns {sec(cue.closes)} by {sec(overrun)}")
Why the heap works
A cue's start time depends only on its opening time and its track's free time. The smallest free_at therefore gives the earliest possible start, and the earliest start gives the earliest end. If the cue misses on that track, it would also miss on every other track in their current state.
That doesn't mean that no schedule could fit the cue, only that the earlier placements leave no room for it.
The result
Track 0:
0.0s-1.2s fade_title window 0.0s-1.5s
1.5s-2.7s fill_queue window 1.5s-3.0s
3.0s-4.0s spawn_workers window 3.0s-4.5s
4.5s-5.4s draw_graph window 4.5s-5.5s
Track 1:
0.0s-2.0s show_arrows window 0.0s-3.0s
2.0s-2.8s flash_badge window 1.5s-3.0s
3.0s-5.0s move_jobs window 3.0s-5.5s
5.0s-5.5s label_graph window 4.5s-5.5s
Placed 8 of 9 cues on 2 tracks
Cannot fit: pulse_queue overruns 3.0s by 0.7s
Eight cues fit. pulse_queue reached the heap when track 0 was free at 2.7s, so it would have ended at 3.7s, 0.7s past its 3.0s close.
For this data, I can show by hand that at least one miss is unavoidable. The program doesn't check this:
fill_queue,flash_badgeandpulse_queuecan only run between 1.5s and 3.0s, and together they take 3.0s of track time.show_arrowslasts 2.0s, opens at 0.0s and closes at 3.0s, so at least 0.5s of it falls between 1.5s and 3.0s.- That makes 3.5s of demand, but two tracks offer only 3.0s in that interval, so these four cues can't all fit.
What the checks cover
The program makes three assertions:
- No overlap: consecutive cues on each track must not overlap. A bug in the heap updates could trip this.
- Inside the window: every placed cue must start and end within its window. This could catch a broken guard or start calculation.
- Nothing lost: placed and missed cues must add up to the total. This is the weakest check, because the current branch sends each cue to exactly one list. It could only catch a later edit that breaks that structure.
None of them checks whether the schedule is the best possible, or whether a miss was unavoidable.
Where it bites
Pushing the wrong time back. A missed cue must not occupy its track. If you pushed its end time on every miss, pulse_queue would hold its track until 3.7s. move_jobs would then start at 3.7s and end at 5.7s, past its 5.5s close, and label_graph would miss as well.
Treating overrun as the trim you need. The 0.7s overrun applies only to this placement. Another arrangement needs less: run pulse_queue from 2.0s to 3.0s on the show_arrows track, and shorten flash_badge by 0.5s to fit after fill_queue.
Letting names choose the victim. fill_queue, flash_badge and pulse_queue have identical windows, so the alphabetical tie-break decides their order. pulse_queue missed because it came last, not because it matters least. If it sorted first, fill_queue would miss instead.
Confusing fit with sync. label_graph is inside its window but starts at 5.0s, 0.5s after its phrase begins. The scheduler accepts any placement inside the window, so tighten the window if a cue must land on a specific word.
Where this stops being enough
This greedy pass never backtracks, so it can report misses that a better order would avoid. Take one track, cue A with window 0s to 10s and duration 5s, and cue B with window 1s to 3s and duration 2s. A sorts first and runs until 5s, so B misses, yet B at 1s to 3s followed by A at 3s to 8s fits both.
It also has no concept of priority or dependencies, so nothing guarantees that spawn_workers finishes before move_jobs starts. Both start at 3.0s here. Once you need those rules, or the fewest possible misses, you need a different scheduler, such as a constraint search. Until then, this pass gives you a non-overlapping, in-window placement for every cue it accepts and a named report for every cue it rejects.
Disclosure: I'm the founder of Homingo; Homingo sells this skill, and this article was written with AI assistance, with the code run before publishing. This is one small idea behind what we package in Isometric Explainers, our Claude Code skill: Claude Code draws how-it-works machines for your docs, blogs and landing pages, with live readouts and built-in layout, motion and geometry checks, and one command turns the same figure into a 1080x1920 explainer reel. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds 3 more machine reel projects, the finished demo reel, 4 video lessons and a written tutorial), one-time, prices in INR, international cards accepted; it needs Claude access with Claude Code and Node.js 20.10+, Claude plans not included: Isometric Explainers. Everything above works without it.
Comments
Post a Comment