QuickTime Chapters
Tuesday, July 21, 2009 at 9:15AM Introduction
Following on from my article about renaming QuickTime tracks I thought I'd add another QuickTime article, this time about chapters. This is a topic I first looked into because although my Elgato EyeTV creates some chapter points when it exports files I wanted more control over them. The article will cover the basics of adding, renaming and deleting chapters along with a couple of other bonus bits of information.
Due to the length of the code I'm not posting the full source here so please feel free to download the example project.
Frameworks
This project needs the QTKit framework so if you get compilation errors the first thing to check is that it is included as an external framework.
Loading The Movie
As with the code used in the renaming QuickTime tracks article, the movie file is loaded using the initWithAttributes:error: method. Unlike the other article, however, the attributes dictionary contains an additional object:
NSDictionary * initAttributes = [NSDictionary dictionaryWithObjectsAndKeys:
filePath, QTMovieFileNameAttribute,
[NSNumber numberWithBool:NO], QTMovieOpenAsyncOKAttribute,
[NSNumber numberWithBool:YES], QTMovieEditableAttribute,
nil];
The inclusion of an NSNumber representing a boolean value of YES for the key QTMovieEditableAttribute means that the movie will load in an editable state and this means that we can manipulate the chapters within the movie.
One other thing to note is that line 76 of the code removes any existing chapter marks from the video file:
[movieToEdit removeChapters];
Don't panic, however, this is not permanent since this change, like the others in the application and this article, is not written back into the movie file.
Some Theory
Before digging into the code any further it is worth taking a step back and explaining some of the theory and gotchas of chapters.
The QTKit framework exposes chapters as an array of dictionaries and each dictionary has two key elements, a name and a start time. The name is simply a string whose key is QTMovieChapterName and the chapter start time, identified by QTMovieChapterStartTime, is a QTTime structure wrapped in an NSValue.
A QTTime structure is defined as:
typedef struct {
long long timeValue;
long timeScale;
long flags;
}
Only one flag is defined which is kQTTimeIsIndefinite and this basically means that the timeScale cannot be considered accurate thus rendering the QTTime invalid.
Assuming that the QTTime is not invalid, timeValue is a number denoting a point in the movie and timeScale is a number denoting the number of timeValues per second. For example, if you have a timeScale of 600 then there are 600 timeValue units per second so the point five seconds into the movie would have a timeValue of 3000.
Timescales can be completely arbitrary and so you can effectively define your own. Rather than using a timeValue of 3000 I could reach the same point in the movie by declaring a QTTime with a timeValue of 5 and a timeScale of 1.
Heading back to the area of chapters, a minor gotcha is that you cannot simply add and remove chapters from the array QTKit exposes, you have to replace it. More significantly, when you add a chapter to the array of chapters you have to insert it in the right place so that the chapters are ordered properly. It also seems to be pretty much required that there is a chapter point right at the start of the movie.
Creating Some Chapters
So that there are some chapters to play with the first part of the sample project creates three chapters, one at the start of the movie, one a third of the way through and one two-thirds through.
- (IBAction)createChapters:(id)sender;
{
// Get the timescale for the movie so it can be used in the chapter definitions
long movieTimescale = [movieToEdit duration].timeScale;
long movieDuration = [movieToEdit duration].timeValue;
// Create three chapter definitions, one at the start and two equally spaced.
NSMutableArray * chapterList = [[NSMutableArray alloc] init];
QTTime chapterTime;
NSValue * chapterTimeValue;
chapterTime = QTMakeTime(0, movieTimescale);
chapterTimeValue = [NSValue valueWithQTTime:chapterTime];
[chapterList addObject:[NSDictionary dictionaryWithObjectsAndKeys:
@"Chapter 1", QTMovieChapterName,
chapterTimeValue, QTMovieChapterStartTime,
nil]];
chapterTime = QTMakeTime(movieDuration / 3, movieTimescale);
chapterTimeValue = [NSValue valueWithQTTime:chapterTime];
[chapterList addObject:[NSDictionary dictionaryWithObjectsAndKeys:
@"Chapter 2", QTMovieChapterName,
chapterTimeValue, QTMovieChapterStartTime,
nil]];
chapterTime = QTMakeTime((movieDuration / 3) * 2, movieTimescale);
chapterTimeValue = [NSValue valueWithQTTime:chapterTime];
[chapterList addObject:[NSDictionary dictionaryWithObjectsAndKeys:
@"Chapter 3", QTMovieChapterName,
chapterTimeValue, QTMovieChapterStartTime,
nil]];
// Remove any existing chapters from the movie
[movieToEdit removeChapters];
// Update the chapters in the movie
NSError * error = nil;
[movieToEdit addChapters:chapterList withAttributes:nil error:&error];
if (error) {
NSAlert * alert = [NSAlert alertWithMessageText:@"Chapter Creation Problem"
defaultButton:@"OK"
alternateButton:nil
otherButton:nil
informativeTextWithFormat:@"Unable to add the chapters to the movie."];
[alert runModal];
NSLog(@"Error: %@", [error userInfo]);
return;
}
[movieChapterTable reloadData];
}
So that the points one-third and two-thirds of the way through the movie can be calculated we need to know about the movie's duration. This is available via the duration property and it is returned as a QTTime. To make things easier, further on in the code the timeValue and timescale elements are extracted and stored individually.
The three chapters are then defined and stored in a new mutable array. To make the code more readable, the chapter times are defined first and are QTTimes. The timeValue is either zero (for the first chapter) or calculated from the duration's timeValue. The timeScale is simply the same as the one returned by the duration property. These QTTime is then stored in an NSValue via the valueWithQTTime: method. The NSValue is then stored in an NSDictionary along with the chapter name with the dictionary being stored in the mutable array.
Once the array of dictionaries is complete the existing chapters are removed and the addChapters:withAttributes:error: method of the QTMovie object is used to store the chapters array in the movie object.
Inserting a Chapter
The process of adding a single chapter to the array of existing chapters would be very straightforward if it were not for the fact that the chapter needs to be inserted into the correct place in the array to keep the chapters ordered.
- (IBAction)insertChapters:(id)sender;
{
// Get the existing chapter list
NSMutableArray * existingChapters = [[[movieToEdit chapters] mutableCopy] autorelease];
// Define the new chapter which will be half way through the movie
long movieTimescale = [movieToEdit duration].timeScale;
long movieDuration = [movieToEdit duration].timeValue;
QTTime chapterTime = QTMakeTime(movieDuration / 2, movieTimescale);
NSValue * chapterTimeValue = [NSValue valueWithQTTime:chapterTime];
NSDictionary * newChapterDetails = [NSDictionary dictionaryWithObjectsAndKeys:
@"Mid-Point", QTMovieChapterName,
chapterTimeValue, QTMovieChapterStartTime,
nil];
// Place the new chapter into the existing chapter array in the right place
BOOL found = NO;
for (NSInteger index = 0; index < [existingChapters count]; index++) {
QTTime time1 = [[newChapterDetails valueForKey:QTMovieChapterStartTime] QTTimeValue];
QTTime time2 = [[[existingChapters objectAtIndex:index] valueForKey:QTMovieChapterStartTime] QTTimeValue];
if (QTTimeCompare(time1, time2) == NSOrderedAscending) {
[existingChapters insertObject:newChapterDetails atIndex:index];
found = YES;
break;
}
}
// If the chapter was not added then add it to the end
if (!found)
[existingChapters addObject:newChapterDetails];
// Remove any existing chapters from the movie
[movieToEdit removeChapters];
// Update the chapters in the movie
NSError * error = nil;
[movieToEdit addChapters:existingChapters withAttributes:nil error:&error];
if (error) {
NSAlert * alert = [NSAlert alertWithMessageText:@"Chapter Creation Problem" defaultButton:@"OK" alternateButton:nil otherButton:nil informativeTextWithFormat:@"Unable to add the new chapter to the movie."];
[alert runModal];
NSLog(@"Error: %@", [error userInfo]);
return;
}
[movieChapterTable reloadData];
}
The first part of the code should be familiar from creating the chapter points above. The only difference is that rather than creating a new array to store the chapters in we're creating a mutable copy of the existing chapters array.
Once the new chapter point has been defined (and in the example code it will be placed half way through the movie) the chapter details dictionary needs to be added to the chapters array in the right place. To do this the chapters array is enumerated through with the new chapter's QTTime being compared to each existing chapter's QTTime. If the new chapter's time is before the existing chapter's time then the new chapter is inserted into the array. If the new chapter does not fit in before any of the existing chapters then it is simply added to the end of the array.
Once the chapters array has been updated the existing version is removed and replaced by the new one.
Renaming a Chapter
Renaming a chapter is pretty easy.
- (IBAction)renameChapter:(id)sender;
{
// Get the selected chapter and a mutable copy of the chapters array
NSInteger rowIndex = [movieChapterTable selectedRow];
NSMutableArray * chapterArray = [[movieToEdit chapters] mutableCopy];
// Rename the chapter in the mutable copy of the chapters array
NSMutableDictionary * chapterDictionary = [[chapterArray objectAtIndex:rowIndex] mutableCopy];
[chapterDictionary setValue:[chapterName stringValue] forKey:QTMovieChapterName];
[chapterArray replaceObjectAtIndex:rowIndex withObject:chapterDictionary];
[chapterDictionary release], chapterDictionary = nil;
// Remove any existing chapters from the movie
[movieToEdit removeChapters];
// Update the chapters in the movie
NSError * error = nil;
[movieToEdit addChapters:chapterArray withAttributes:nil error:&error];
if (error) {
NSAlert * alert = [NSAlert alertWithMessageText:@"Chapter Renaming Problem" defaultButton:@"OK" alternateButton:nil otherButton:nil informativeTextWithFormat:@"Unable to rename the chapter."];
[alert runModal];
NSLog(@"Error: %@", [error userInfo]);
return;
}
[chapterArray release], chapterArray = nil;
[movieChapterTable reloadData];
}
Core to this is having a way of identifying the chapter you want to rename. Sometimes it will just be the chapter name or start time in which case you would have to enumerate through the array of chapters to find the one you want to edit. In the example code I make life easy for myself because the chapter that is going to be renamed is selected from a table of the chapter names so the index in the chapters array is the same as the index of the selected item in the table.
As before a mutable copy of the chapters array is obtained an then a mutable copy of the chapter's dictionary is created. The value of the QTMovieChapterName is edited and the array updated with this modified object before the usual two steps of removing the existing chapters and applying the array or new chapters are carried out.
Removing a Chapter
This is perhaps the simplest chapter action to perform.
- (IBAction)removeChapter:(id)sender;
{
// Remove the selected chapter from a mutable copy of the chapters array
NSInteger rowIndex = [movieChapterTable selectedRow];
NSMutableArray * newChapterList = [[movieToEdit chapters] mutableCopy];
[newChapterList removeObjectAtIndex:rowIndex];
// Remove any existing chapters from the movie
[movieToEdit removeChapters];
// Update the chapters in the movie if there are any
if ([newChapterList count] > 0) {
NSError * error = nil;
[movieToEdit addChapters:newChapterList withAttributes:nil error:&error];
if (error) {
NSAlert * alert = [NSAlert alertWithMessageText:@"Chapter Deletion Problem" defaultButton:@"OK" alternateButton:nil otherButton:nil informativeTextWithFormat:@"Unable to delete the chapter."];
[alert runModal];
NSLog(@"Error: %@", [error userInfo]);
return;
}
}
[newChapterList release];
// Refresh the table view and select the chapter that was above the one delete
[movieChapterTable reloadData];
if (rowIndex > 0)
[movieChapterTable selectRow:rowIndex - 1 byExtendingSelection:NO];
}
As with editing chapters, life is made simpler by knowing the index of the chapter dictionary in the array because all that we need do is delete it from the usual mutable copy of the chapters array. Then, as usual, the chapters array is removed before the new one is applied.
A Table of Chapters
In the example project a table shows the chapter names. This is partly for convenience in the example code for renaming and deleting chapters and partly so that two other things can be demonstrated.
Jumping to a Chapter
If you have a list of chapters it is useful to be able to jump to that chapter in the movie. Selecting a point in a movie is as simple as setting the currentTime property of a movie to a QTTime.
From the table this is done via one of the NSTableView delegate methods:
- (void)tableViewSelectionDidChange:(NSNotification *)aNotification
{
// If a row in the chapter table is selected set the movie's time to the start of that chapter and
// place the chapter's name in the editing field
if ([[aNotification object] isEqual:movieChapterTable]) {
if ([movieToEdit chapterCount] > 0) {
[[movieView movie] setCurrentTime:[[[[movieToEdit chapters] objectAtIndex:[movieChapterTable selectedRow]] valueForKey:QTMovieChapterStartTime] QTTimeValue]];
[chapterName setStringValue:[[[movieToEdit chapters] objectAtIndex:[movieChapterTable selectedRow]] valueForKey:QTMovieChapterName]];
}
}
}
In line 297 you simply retrieve the QTTimeValue of the QTMovieChapterStartTime of the chapter and convert that back into a QTTime via the QTTimeValue method.
(Line 298 is simply inserting the chapter name into the NSTextField below the table so that it can be edited.)
Selecting the Chapter Being Played
The reverse of this is that when the movie is playing you probably want your chapter list synchronised so that the chapter that is currently being played is automatically highlighted. To do this you need to know the currentTime of the movie as it plays because you can then get the index of the chapter in the chapters array via the chapterIndexForTime: property of the QTMovie.
The problem is that there is no direct way to retrieve the changing currentTime of a movie whilst it is playing so you need to use an NSTimer to retrieve the currentTime at regular intervals:
NSInteger chapterIndex = [[movieView movie] chapterIndexForTime:[[movieView movie] currentTime]]; [movieChapterTable selectRow:chapterIndex byExtendingSelection:NO];
Displaying the Time Code
Another thing you can do with the currentTime of a movie is display the current movie time. In the example application this is done in the window's title.
NSString * qtTimeString = QTStringFromTime([[movieView movie] currentTime]);
NSCharacterSet * dividingCharacters = [NSCharacterSet characterSetWithCharactersInString:@":./"];
NSArray * qtTimeElements = [qtTimeString componentsSeparatedByCharactersInSet:dividingCharacters];
NSString * formattedTimeCode = [NSString stringWithFormat:@"%@h %@m %@s",
[qtTimeElements objectAtIndex:1],
[qtTimeElements objectAtIndex:2],
[qtTimeElements objectAtIndex:3]];
[[movieView window] setTitle:[NSString stringWithFormat:@"Movie Time: %@", formattedTimeCode]];
The only real complexity here is that if you retrieve the currentTime of a movie and convert it to a string via QTStringFromTime you end up with something that would look like 0:00:01:15.48/600.
This is because QTStringFromTime is returning a timecode which displays the movie's time as days : hours : minutes : seconds . frames / timeScale. You probably only want to display the hours minutes and seconds so the above code splits the string at the colon, period and slash characters and stores the results in an array and only the second, third and fourth items are retrieved and displayed.
Saving the Changes
Because I didn't want to be responsible for ruining any of your media files the demo application does not write the changed chapters back into the saved file. If you want to store the chapter changes you need to save the file which would be done with some code like this:
NSError * error = nil;
NSString * saveFilePath = [@"~/Desktop/Chapter-Enhanced File.mov" stringByExpandingTildeInPath];
NSDictionary * saveAttributes = [NSDictionary dictionaryWithObjectsAndKeys:
[NSNumber numberWithBool:YES], QTMovieFlatten,
nil];
if (![movie writeToFile:saveFilePath withAttributes:saveAttributes error:&error]) {
NSLog(@"Problem writing file.");
if (error)
NSLog(@"Error: %@", [error userInfo]);
return 1;
}
Reader Comments