This weekend I was building a DVD to share with my family. I wanted to make a little movie for the DVD menu that consisted of a sequence of photo images flying in towards and past the camera from far away, in a nice pleasing random motion.
Naturally, I used Panda to do this. The following script took me about two hours to write, and I’m pleased with the results. It loads up all of the images and builds one monster Interval that, once started, flies all of the images towards the camera, one after the other. Then I used base.movie() to capture the animation as a series of numbered video frame images, which I imported into my favorite video maker to make my DVD menu. (I could just as easily have used FRAPS or some such, but base.movie() has the advantage of guaranteeing a smooth capture with no dropped frames.)
It then occurred to me that I might as well post the code I used, so here it is. Enjoy!
from direct.directbase.DirectStart import * from pandac.PandaModules import * from direct.interval.IntervalGlobal import * import math import random import os class PhotoCard: """ A single card with a photo image on it. """ cornerRadius = 32 def __init__(self, filename): self.filename = filename image = PNMImage(filename) self.valid = (image.getXSize() > 0) if not self.valid: return self.roundCorners(image) # Scale the image to 256x256 for the purposes of putting it on # a texture. square = PNMImage(256, 256, 4) square.quickFilterFrom(image) self.tex = Texture(filename.getBasename()) self.tex.load(square) self.tex.setMinfilter(Texture.FTLinearMipmapLinear) self.tex.setWrapU(Texture.WMClamp) self.tex.setWrapV(Texture.WMClamp) # Now create a card that matches the original aspect ratio of # the image. x = image.getXSize() y = image.getYSize() dim = float(max(x, y)) cm = CardMaker(filename.getBasename()) cm.setFrame(-x / dim, x / dim, -y / dim, y / dim) self.card = NodePath(cm.generate()) self.card.setTexture(self.tex) self.card.setTransparency(TransparencyAttrib.MAlpha) def roundCorners(self, image): """ Chop off a round alpha corner from the indicated image, to make the outline a little more pleasing. """ image.addAlpha() image.alphaFill(1) x = image.getXSize() y = image.getYSize() r = self.cornerRadius for yi in range(r): for xi in range(r): rv = math.sqrt(xi * xi + yi * yi) alpha = r - rv - 0.5 if alpha < 0.0: alpha = 0.0 if alpha > 1.0: alpha = 1.0 image.setAlpha(r - 1 - xi, r - 1 - yi, alpha) image.setAlpha(x - r + xi, r - 1 - yi, alpha) image.setAlpha(r - 1 - xi, y - r + yi, alpha) image.setAlpha(x - r + xi, y - r + yi, alpha) class FlyPath: """ A randomly chosen path from far away to just off camera, for an image to fly in on from the horizon. """ far = 100 near = 1 distExp = 2 radius = 3 rExp = 4 steps = 20 wanderFactor = 7 time = 10 def __init__(self): # Compute a number of sample points, representing the curve # the image will follow from the horizon to the camera. startAngle = random.uniform(0, math.pi * 2.0) noise = PerlinNoise2(self.steps, 1) verts =  for vi in range(self.steps): t = float(vi) / float(self.steps - 1) # The object moves from the center to the outside, but not # linearly--we want it to move slowly at first, and then # suddenly move quickly to the outside as it comes closer. # Thus, the rExp exponent. r = math.pow(t, self.rExp) * self.radius # The object moves randomly around an arc angle = noise.noise(vi, 0) * self.wanderFactor + startAngle x = r * math.cos(angle) z = r * math.sin(angle) # The object moves smoothly from far to near, but not # linearly--we want it to move really fast when it's far # away, but slow down as it comes closer. Thus, the # distExp exponent. y = math.pow(1.0 - t, self.distExp) y = y * (self.far - self.near) + self.near verts.append(Point3(x, y, z)) # Now use a NurbsCurveEvaluator to smoothly interpolate a # curve along those sample points. self.curve = NurbsCurveEvaluator() self.curve.reset(len(verts)) self.curve.setOrder(3) for i in range(len(verts)): self.curve.setVertex(i, verts[i]) self.ncr = self.curve.evaluate() def getFlyInterval(self, object): """ Returns an interval that, when played, will fly the indicated object along the path. """ fly = LerpFunctionInterval(self.__flyObject, duration = self.time, fromData = self.ncr.getStartT(), toData = self.ncr.getEndT(), extraArgs = [object]) return Sequence(Func(self.__startFlyObject, object), fly, Func(self.__endFlyObject, object)) def __startFlyObject(self, object): object.reparentTo(render) def __flyObject(self, t, object): pt = Point3() self.ncr.evalPoint(t, pt) object.setPos(pt) def __endFlyObject(self, object): object.detachNode() class FlyGroup: """ Creates a group of images flying in from the horizon, based on all of the image files in a given directory. """ repeatBuffer = 4 photoDelay = 3 def __init__(self, dirname): self.images = self.getImages(dirname) self.shuffled = None def getInterval(self, duration): """ Returns an interval of no more than duration seconds (maybe a few seconds less) that represents the images in the directory flying forward, in random order. If there are not enough images in the directory to fill out duration seconds, they will start to repeat as necessary. """ self.shuffleImages() si = 0 s = Track() time = 0.0 while time + FlyPath.time < duration: if si >= len(self.shuffled): self.shuffleImages() si = 0 pc = PhotoCard(self.shuffled[si]) si += 1 if pc.valid: path = FlyPath() fly = path.getFlyInterval(pc.card) s.append((time, fly)) time += self.photoDelay return s def getImages(self, dirname): images =  filelist = os.listdir(dirname) for filename in filelist: f = Filename.fromOsSpecific(os.path.join(dirname, filename)) images.append(f) return images def shuffleImages(self): """Sorts the image list into random order for display, storing the result in self.shuffled. The last repeatBuffer images from the previous shuffle will not appear in the front repeatBuffer images of the new list (unless there are not enough images to guarantee this). """ origShuffled = self.shuffled newShuffled = self.images[:] random.shuffle(newShuffled) self.lastShuffled = origShuffled self.shuffled = newShuffled if not origShuffled: # There wasn't a previous list, so just do the new one. return # Try to guarantee a minimal buffer of non-repeating images. buffer = min(self.repeatBuffer, len(self.images) - self.repeatBuffer) if buffer <= 0: return initial = newShuffled[:buffer] tail = origShuffled[-buffer:] for filename in tail: if filename in initial: newShuffled.remove(filename) newShuffled.append(filename) def doVideo(): # Attach a card to the camera to represent the background. cm = CardMaker('bg') # We want the card to exactly fill the screen, so extrude a point # from the lens to figure out what the upper-right corner of the # screens corresponds to in 3-d space. Then make the card exactly # that big. np = Point3() fp = Point3() base.camLens.extrude(Point2(1, 1), np, fp) # Any point between the near plane and the far plane would be # equally good. We pick the exact midpoint. p = (np + fp) / 2 cm.setFrame(-p, p, -p, p) bgCard = camera.attachNewNode(cm.generate()) bgCard.setY(p) bgCard.setBin('background', 0) bgCard.setDepthWrite(0) bgTex = loader.loadTexture('movie_background.png') bgCard.setTexture(bgTex) # Put another card (with lots of transparency) to cover render2d, # to represent the foreground labels and stuff. cm = CardMaker('fg') cm.setFrame(-1, 1, -1, 1) fgTex = loader.loadTexture('movie_foreground.png') fgCard = render2d.attachNewNode(cm.generate()) fgCard.setTexture(fgTex) fgCard.setTransparency(TransparencyAttrib.MAlpha) duration = 100 group = FlyGroup('movie_photo_dir') ival = group.getInterval(duration) ival.start() # Now capture those frames in a movie. base.movie(duration = duration) doVideo() run()