diff --git a/flax/Timeline.py b/flax/Timeline.py --- a/flax/Timeline.py +++ b/flax/Timeline.py @@ -4,12 +4,86 @@ from time import time from __future__ import division # "I'm sending you back to future!" """ -Quote of the Build (from Ghostbusters II) -Dana: Okay, but after dinner, I don't want you putting any of your old cheap - moves on me. -Peter: Ohhhh no! I've got all NEW cheap moves. +Changelog: +Fri May 16 15:17:34 PDT 2003 + Project started (roughly). + +Mon May 19 17:56:24 PDT 2003 + Timeline is more or less working. Still bugs with skipping + FunctionFrames at random times. + +Timeline idea +============= + time | 0 1 2 3 4 5 6 +---------+----------------------------- +frame | F F F +blenders | \ b / \- b ----/ + +Update: this is more or less what happened. However, there are +TimelineTracks as well. FunctionFrames go on their own track. +LevelFrames must have unique times, FunctionFrames do not. + +Level propagation +================= +Cue1 is a CueNode. CueNodes consist of a timeline with any number +of LevelFrame nodes and LinearBlenders connecting all the frames. +At time 0, Cue1's timeline has LevelFrame1. At time 5, the timeline +has NodeFrame1. NodeFrame1 points to Node1, which has it's own sets +of levels. It could be a Cue, for all Cue1 cares. No matter what, +we always get down to LevelFrames holding the levels at the bottom, +then getting combined by Blenders. + + /------\ + | Cue1 | + |---------------\ + | Timeline: | + | 0 ... 5 | + /--------- LF1 NF1 -------\ + | | \ / | | + | | LinearBlender | | + | \---------------/ | + | points to + /---------------\ a port in Cue1, + | blueleft : 20 | which connects to a Node + | redwash : 12 | + | . | + | : | + | | + \---------------/ + +PS. blueleft and redwash are other nodes at the bottom of the tree. +The include their real channel number for the DimmerNode to process. + +When Cue1 requests levels, the timeline looks at the current position. +If it is directly over a Frame (or Frames), that frame is handled. +If it is LevelFrame, those are the levels that it returns. If there is +a FunctionFrame, that function is activated. Thus, the order of Frames +at a specific time is very significant, since the FunctionFrame could +set the time to 5s in the future. If we are not currently over any +LevelFrames, we call upon a Blender to determine the value between. +Say that we are at 2.3s. We use the LinearBlender with 2.3/5.0s = 0.46% +and it determines that the levels are 1 - 0.46% = 0.54% of LF1 and +0.46% of NF1. NF1 asks Node9 for its levels and this process starts +all over. + +Graph theory issues (node related issues, should be moved elsewhere) +==================================================================== +1. We need to determine dependencies for updating (topological order). +2. We need to do cyclicity tests. + +Guess who wishes they had brought their theory book home? +I think we can do both with augmented DFS. An incremental version of both +would be very nice, though hopefully unnecessary. + """ +class InvalidFrameOperation(Exception): + """You get these when you try to perform some operation on a frame + that doesn't make sense. The interface is advised to tell the user, + and indicate that a Blender or FunctionFramea should be disconnected + or fixed.""" + pass + class MissingBlender(Exception): """Raised when a TimedEvent is missing a blender.""" def __init__(self, timedevent): @@ -22,42 +96,127 @@ class MissingBlender(Exception): FORWARD = 1 BACKWARD = -1 -MISSING = 'missing' +class Frame: + """Frame is an event that happens at a specific time. There are two + types of frames: LevelFrames and FunctionFrames. LevelFrames provide + levels via their get_levels() function. FunctionFrames alter the + timeline (e.g. bouncing, looping, speed changes, etc.). They call + __call__'ed instead.""" + def __init__(self, name): + self.name = name + self.timeline = DummyClass(use_warnings=0, raise_exceptions=0) + def set_timeline(self, timeline): + """Tell the Frame who the controlling Timeline is""" + self.timeline = timeline + def __mul__(self, percent): + """Generate a new Frame by multiplying the 'effect' of this frame by + a percent.""" + raise InvalidFrameOperation, "Can't use multiply this Frame" + def __add__(self, otherframe): + """Combines this frame with another frame, generating a new one.""" + raise InvalidFrameOperation, "Can't use add on this Frame" + +class LevelFrame(Frame): + """LevelFrames provide levels. They can also be combined with other + LevelFrames.""" + def __init__(self, name, levels): + Frame.__init__(self, name) + self.levels = levels + def __mul__(self, percent): + """Returns a new LevelFrame made by multiplying all levels by a + percentage. Percent is a float greater than 0.0""" + newlevels = dict_scale(self.get_levels(), percent) + return LevelFrame('(%s * %f)' % (self.name, percent), newlevels) + def __add__(self, otherframe): + """Combines this LevelFrame with another LevelFrame, generating a new + one. Values are max() together.""" + theselevels, otherlevels = self.get_levels(), otherframe.get_levels() + return LevelFrame('(%s + %s)' % (self.name, otherframe.name), + dict_max(theselevels, otherlevels)) + def get_levels(self): + """This function returns the levels held by this frame.""" + return self.levels + def __repr__(self): + return "<%s %r %r>" % (str(self.__class__), self.name, self.levels) + +class EmptyFrame(LevelFrame): + """An empty LevelFrame, for the purposes of extending the timeline.""" + def __init__(self, name='Empty Frame'): + EmptyFrame.__init__(self, name, {}) + +class NodeFrame(LevelFrame): + """A LevelFrame that gets its levels from another Node. This must be + used from a Timeline that is enclosed in TimelineNode. Node is a string + describing the node requested.""" + def __init__(self, name, node): + LevelFrame.__init__(self, name, {}) + self.node = node + def get_levels(self): + """Ask the node that we point to for its levels""" + node = self.timeline.get_node(self.node) + self.levels = node.get_levels() + return self.levels + +class FunctionFrame(Frame): + def __init__(self, name): + Frame.__init__(self, name) + def __call__(self, timeline, timedevent, node): + """Called when the FunctionFrame is activated. It is given a pointer + to it's master timeline, the TimedEvent containing it, and Node that + the timeline is contained in, if available.""" + pass + +# this is kinda broken +class BounceFunction(FunctionFrame): + def __call__(self, timeline, timedevent, node): + """Reverses the direction of play.""" + timeline.reverse_direction() + print "boing! new dir:", timeline.direction + +# this too +class LoopFunction(FunctionFrame): + def __call__(self, timeline, timedevent, node): + timeline.set_time(0) + print 'looped!' + +class DoubleTimeFunction(FunctionFrame): + def __call__(self, timeline, timedevent, node): + timeline.set_rate(2 * timeline.rate) + +class HalfTimeFunction(FunctionFrame): + def __call__(self, timeline, timedevent, node): + timeline.set_rate(0.5 * timeline.rate) class TimedEvent: """Container for a Frame which includes a time that it occurs at, and which blender occurs after it.""" - def __init__(self, time, frame=MISSING, blender=None, level=1.0): + def __init__(self, time, frame, blender=None): make_attributes_from_args('time', 'frame') self.next_blender = blender - self.level = level def __float__(self): return self.time def __cmp__(self, other): - if other is None: - raise "I can't compare with a None. I am '%s'" % str(self) if type(other) in (float, int): return cmp(self.time, other) else: return cmp(self.time, other.time) def __repr__(self): - return "" % \ - (self.frame, self.level, self.time, self.next_blender) - def get_level(self): - return self.level - def __hash__(self): - return id(self.time) ^ id(self.frame) ^ id(self.next_blender) + return "" % \ + (self.frame, self.time, self.next_blender) + def get_levels(self): + """Return the Frame's levels. Hopefully frame is a LevelFrame or + descendent.""" + return self.frame.get_levels() class Blender: """Blenders are functions that merge the effects of two LevelFrames.""" def __init__(self): pass - def __call__(self, startframe, endframe, blendtime, time_since_startframe): + def __call__(self, startframe, endframe, blendtime): """Return a LevelFrame combining two LevelFrames (startframe and endframe). blendtime is how much of the blend should be performed and will be expressed as a percentage divided by 100, i.e. a float - between 0.0 and 1.0. time_since_startframe is the time since the - startframe was on screen in seconds (float). + between 0.0 and 1.0. Very important note: Blenders will *not* be asked for values at end points (i.e. blendtime=0.0 and blendtime=1.0). @@ -79,41 +238,35 @@ class Blender: 0.25 * endframe. This function is included since many blenders are just functions on the percentage and still combine start and end frames in this way.""" - if startframe.frame == endframe.frame: - level = startframe.level + (blendtime * \ - (endframe.level - startframe.level)) - levels = {startframe.frame : level} - else: - levels = {startframe.frame : (1.0 - blendtime) * startframe.level, - endframe.frame : blendtime * endframe.level} - return levels + return (startframe * (1.0 - blendtime)) + (endframe * blendtime) class InstantEnd(Blender): """Instant change from startframe to endframe at the end. In other words, the value returned will be the startframe all the way until the very end of the blend.""" - def __call__(self, startframe, endframe, blendtime, time_since_startframe): + def __call__(self, startframe, endframe, blendtime): # "What!?" you say, "Why don't you care about blendtime?" # This is because Blenders never be asked for blenders at the endpoints # (after all, they wouldn't be blenders if they were). Please see # 'Very important note' in Blender.__doc__ - return {startframe.frame : startframe.level} + return startframe class InstantStart(Blender): """Instant change from startframe to endframe at the beginning. In other words, the value returned will be the startframe at the very beginning and then be endframe at all times afterwards.""" - def __call__(self, startframe, endframe, blendtime, time_since_startframe): + def __call__(self, startframe, endframe, blendtime): # "What!?" you say, "Why don't you care about blendtime?" # This is because Blenders never be asked for blenders at the endpoints # (after all, they wouldn't be blenders if they were). Please see # 'Very important note' in Blender.__doc__ - return {endframe.frame : endframe.level} + return endframe class LinearBlender(Blender): """Linear fade from one frame to another""" - def __call__(self, startframe, endframe, blendtime, time_since_startframe): + def __call__(self, startframe, endframe, blendtime): return self.linear_blend(startframe, endframe, blendtime) + # return (startframe * (1.0 - blendtime)) + (endframe * blendtime) class ExponentialBlender(Blender): """Exponential fade fron one frame to another. You get to specify @@ -121,7 +274,7 @@ class ExponentialBlender(Blender): as LinearBlender.""" def __init__(self, exponent): self.exponent = exponent - def __call__(self, startframe, endframe, blendtime, time_since_startframe): + def __call__(self, startframe, endframe, blendtime): blendtime = blendtime ** self.exponent return self.linear_blend(startframe, endframe, blendtime) @@ -130,70 +283,19 @@ class ExponentialBlender(Blender): class SmoothBlender(Blender): """Drew's "Smoove" Blender function. Hopefully he'll document and parametrize it.""" - def __call__(self, startframe, endframe, blendtime, time_since_startframe): + def __call__(self, startframe, endframe, blendtime): blendtime = (-1 * blendtime) * blendtime * (blendtime - 1.5) * 2 return self.linear_blend(startframe, endframe, blendtime) -class Strobe(Blender): - "Strobes the frame on the right side between offlevel and onlevel." - def __init__(self, ontime, offtime, onlevel=1, offlevel=0): - "times are in seconds (floats)" - make_attributes_from_args('ontime', 'offtime', 'onlevel', 'offlevel') - self.cycletime = ontime + offtime - def __call__(self, startframe, endframe, blendtime, time_since_startframe): - # time into the current period - period_time = time_since_startframe % self.cycletime - if period_time <= self.ontime: - return {endframe.frame : self.onlevel} - else: - return {endframe.frame : self.offlevel} - class TimelineTrack: """TimelineTrack is a single track in a Timeline. It consists of a list of TimedEvents and a name. Length is automatically the location of the last TimedEvent. To extend the Timeline past that, add an - EmptyTimedEvent (which doesn't exist :-/).""" - def __init__(self, name, *timedevents, **kw): - if kw.get('default_frame'): - self.default_frame = kw['default_frame'] - else: - self.default_frame = None + EmptyTimedEvent.""" + def __init__(self, name, *timedevents): self.name = name - self.set_events(list(timedevents)) - def set_events(self, events): - """This is given a list of TimedEvents. They need not be sorted.""" - self.events = events - self._cleaup_events() - def _cleaup_events(self): - """This makes sure all events are in the right order and have defaults - filled in if they have missing frames.""" + self.events = list(timedevents) self.events.sort() - self.fill_in_missing_frames() - def add_event(self, event): - """Add a TimedEvent object to this TimelineTrack""" - self.events.append(event) - self._cleaup_events(self.events) - def delete_event(self, event): - """Delete event by TimedEvent object""" - self.events.remove(event) - self._cleaup_events(self.events) - def delete_event_by_name(self, name): - """Deletes all events matching a certain name""" - self.events = [e for e in self.events if e.name is not name] - self._cleaup_events(self.events) - def delete_event_by_time(self, starttime, endtime=None): - """Deletes all events within a certain time range, inclusive. endtime - is optional.""" - endtime = endtime or starttime - self.events = [e for e in self.events - if e.time >= starttime and e.time <= endtime] - self._cleaup_events(self.events) - def fill_in_missing_frames(self): - """Runs through all events and sets TimedEvent with missing frames to - the default frame.""" - for event in self.events: - if event.frame == MISSING: - event.frame = self.default_frame def __str__(self): return "" % self.events def has_events(self): @@ -224,8 +326,6 @@ class TimelineTrack: be included. This is because this is used to find FunctionFrames and we assume that any function frames at the start point (which could be i or j) have been processed.""" - return [e for e in self.events if e >= i and e <= j] - if direction == FORWARD: return [e for e in self.events if e > i and e <= j] else: @@ -250,20 +350,20 @@ class TimelineTrack: """Returns a LevelFrame with the levels of this track at that time.""" before, after = self.get_surrounding_frames(time) - if not after or before == after: - return {before.frame : before.level} + if before == after: + return before.frame else: # we have a blended value diff = after.time - before.time elapsed = time - before.time percent = elapsed / diff if not before.next_blender: raise MissingBlender, before - return before.next_blender(before, after, percent, elapsed) + return before.next_blender(before.frame, after.frame, percent) class Timeline: - def __init__(self, name, tracks, rate=1, direction=FORWARD): + def __init__(self, tracks, functions, rate=1, direction=FORWARD): """ - Most/all of this is old: + Most of this is old: You can have multiple FunctionFrames at the same time. Their order is important though, since FunctionFrames will be applied @@ -275,7 +375,10 @@ class Timeline: is bounded by their last frame. You can put an EmptyFrame at some time if you want to extend a Timeline.""" - make_attributes_from_args('name', 'tracks', 'rate', 'direction') + make_attributes_from_args('tracks', 'rate', 'direction') + # the function track is a special track + self.fn_track = TimelineTrack('functions', *functions) + self.current_time = 0 self.last_clock_time = None self.stopped = 1 @@ -283,6 +386,7 @@ class Timeline: """Length of the timeline in pseudoseconds. This is determined by finding the length of the longest track.""" track_lengths = [track.length() for track in self.tracks] + track_lengths.append(self.fn_track.length()) return max(track_lengths) def play(self): """Activates the timeline. Future calls to tick() will advance the @@ -312,15 +416,26 @@ class Timeline: last_clock = clock_time diff = clock_time - last_clock new_time = (self.direction * self.rate * diff) + last_time + new_time = max(new_time, 0) + new_time = min(new_time, self.length()) # update the time self.last_clock_time = clock_time self.current_time = new_time - # now we make sure we're in bounds (we don't do this before, since it - # can cause us to skip events that are at boundaries. - self.current_time = max(self.current_time, 0) - self.current_time = min(self.current_time, self.length()) + # now, find out if we missed any functions + if self.fn_track.has_events(): + lower_time, higher_time = last_time, new_time + if lower_time > higher_time: + lower_time, higher_time = higher_time, lower_time + + events_to_process = self.fn_track.get_range(lower_time, + higher_time, self.direction) + + for event in events_to_process: + # they better be FunctionFrames + event.frame(self, event, None) # the None should be a Node, + # but that part is coming later def reverse_direction(self): """Reverses the direction of play for this node""" self.direction = self.direction * -1 @@ -336,47 +451,42 @@ class Timeline: def get_levels(self): """Return the current levels from this timeline. This is done by adding all the non-functional tracks together.""" - levels = [t.get_levels_at_time(self.current_time) - for t in self.tracks] - return dict_max(*levels) + current_level_frame = LevelFrame('timeline sum', {}) + for t in self.tracks: + current_level_frame += t.get_levels_at_time(self.current_time) + + return current_level_frame.get_levels() if __name__ == '__main__': - def T(*args, **kw): - """This used to be a synonym for TimedEvent: - - T = TimedEvent + scene1 = LevelFrame('scene1', {'red' : 50, 'blue' : 25}) + scene2 = LevelFrame('scene2', {'red' : 10, 'blue' : 5, 'green' : 70}) + scene3 = LevelFrame('scene3', {'yellow' : 10, 'blue' : 80, 'purple' : 70}) - It now acts the same way, except that it will fill in a default - blender if you don't. The default blender is a LinearBlender.""" - linear = LinearBlender() - if 'blender' not in kw: - kw['blender'] = linear + T = TimedEvent - return TimedEvent(*args, **kw) - + linear = LinearBlender() quad = ExponentialBlender(2) invquad = ExponentialBlender(0.5) smoove = SmoothBlender() - track1 = TimelineTrack('red track', - T(0, 'red', level=0), - T(4, 'red', blender=quad, level=0.5), - T(12, 'red', blender=smoove, level=0.7), - T(15, 'red', level=0.0)) # last TimedEvent doesn't need a blender - track2 = TimelineTrack('green track', - T(0, 'green', blender=invquad, level=0.2), - T(5, 'green', blender=smoove, level=1), - T(10, 'green', level=0.8), - T(15, 'green', level=0.6), - T(20, 'green', level=0.0)) # last TimedEvent doesn't need a blender - track3 = TimelineTrack('tableau demo', - T(0, 'blue', level=0.0), - T(2, 'blue', level=1.0, blender=InstantEnd()), - T(18, 'blue', level=1.0), - T(20, 'blue', level=0.0)) + track1 = TimelineTrack('lights', + T(0, scene1, blender=linear), + T(5, scene2, blender=quad), + T(10, scene3, blender=smoove), + T(15, scene2)) # last TimedEvent doesn't need a blender - tl = Timeline('test', [track1, track2, track3]) - + if 1: + # bounce is semiworking + bouncer = BounceFunction('boing') + halver = HalfTimeFunction('1/2x') + doubler = DoubleTimeFunction('2x') + tl = Timeline([track1], [T(0, bouncer), + T(0, halver), + T(15, bouncer), + T(15, doubler)]) + else: + looper = LoopFunction('loop1') + tl = Timeline([track1], [T(14, looper)]) tl.play() import Tix @@ -391,7 +501,7 @@ if __name__ == '__main__': for color in colors: sv = Tix.DoubleVar() scalevars[color] = sv - scale = Tix.Scale(colorscalesframe, from_=1, to_=0, res=0.01, bg=color, + scale = Tix.Scale(colorscalesframe, from_=100, to_=0, bg=color, variable=sv) scale.pack(side=Tix.LEFT) @@ -405,7 +515,7 @@ if __name__ == '__main__': scalevars[color].set(levels.get(color, 0)) colorscalesframe.pack() - time_scale = Tix.Scale(root, from_=0, to_=tl.length(), + time_scale = Tix.Scale(root, from_=0, to_=track1.length(), orient=Tix.HORIZONTAL, res=0.01, command=set_timeline_time) time_scale.pack(side=Tix.BOTTOM, fill=Tix.X, expand=1)