-
Notifications
You must be signed in to change notification settings - Fork 3
FCPXML Generation Best Practices.md
This document outlines the critical rules and patterns for generating valid FCPXML files that import successfully into Final Cut Pro.
NEVER EVER generate XML from hardcoded string templates with %s placeholders, use structs
// CRITICAL VIOLATIONS - NEVER DO THESE:
xml := "<video ref=\"" + videoRef + "\">" + content + "</video>"
fmt.Sprintf("<asset-clip ref=\"%s\" name=\"%s\"/>", ref, name)
spine.Content = fmt.Sprintf("<asset-clip ref=\"%s\" offset=\"%s\"/>", assetID, offset)
return fmt.Sprintf("<resources>%s</resources>", content)
xmlContent := "<fcpxml>" + resourcesXML + libraryXML + "</fcpxml>"
builder.WriteString(fmt.Sprintf("<param name=\"%s\" value=\"%s\"/>", key, value))
template := "<title ref=\"%s\">%s</title>"; xml := fmt.Sprintf(template, ref, text)
var xmlParts []string; xmlParts = append(xmlParts, fmt.Sprintf(...))
xmlBuffer.WriteString("<spine>" + generateClips() + "</spine>")
// CORRECT APPROACH:
xml.MarshalIndent(&fcp.Video{Ref: videoRef, Name: name}, "", " ")
spine.AssetClips = append(spine.AssetClips, fcp.AssetClip{Ref: assetID, Offset: offset})
resources.Assets = append(resources.Assets, asset)
title.Params = append(title.Params, fcp.Param{Name: key, Value: value})
fcpxml := &fcp.FCPXML{Resources: resources, Library: library}
sequence.Spine.Videos = append(sequence.Spine.Videos, video)
- XML Escaping Issues: Special characters aren't properly escaped
- Namespace Problems: XML namespaces get corrupted
- Attribute Ordering: XML parsers expect specific attribute orders
- Validation Failures: DTD validation fails on malformed XML
- Encoding Issues: Character encoding gets mixed up
- Parsing Errors: Final Cut Pro rejects malformed XML
NEVER EVER only change problem xml in an xml file, always change the code that generates it too
- Generate FCPXML with code
- Manually edit the XML file to fix issues
- Use the manually edited XML
- Generate FCPXML with code
- Identify the Go code that generates the problematic XML
- Fix the struct generation logic
- Regenerate the XML using the fixed code
- Validate the fix with proper tests
ALWAYS run these tests before using generated FCPXML:
-
FCP Package Tests:
cd fcp && go test
- MUST pass -
XML Validation:
xmllint output.fcpxml --noout
- MUST pass - FCP Import Test: Import into actual Final Cut Pro
ALWAYS follow this pattern (from working tests):
func GenerateMyFeature(inputFile, outputFile string) error {
// 1. Use existing infrastructure
fcpxml, err := fcp.GenerateEmpty("")
if err != nil {
return fmt.Errorf("failed to create base FCPXML: %v", err)
}
// 2. Use proper resource management
registry := fcp.NewResourceRegistry(fcpxml)
tx := fcp.NewTransaction(registry)
defer tx.Rollback()
// 3. Add content using existing functions
if err := fcp.AddImage(fcpxml, imagePath, duration); err != nil {
return err
}
// 4. Apply animations (simple transforms only for images)
imageVideo := &fcpxml.Library.Events[0].Projects[0].Sequences[0].Spine.Videos[0]
imageVideo.AdjustTransform = createAnimation(duration, startTime)
// 5. Commit and write
if err := tx.Commit(); err != nil {
return err
}
return fcp.WriteToFile(fcpxml, outputFile)
}
NEVER manually generate IDs:
❌ BAD: assetID := "r1" // Hardcoded, causes collisions
❌ BAD: id := fmt.Sprintf("asset_%d", randomInt) // Non-sequential
❌ BAD: id := "r" + uuid.New().String() // UUIDs don't work
❌ BAD: id := fmt.Sprintf("r%d", time.Now().Unix()) // Time-based IDs
✅ GOOD: Use ResourceRegistry pattern
registry := fcp.NewResourceRegistry(fcpxml)
tx := fcp.NewTransaction(registry)
ids := tx.ReserveIDs(3)
assetID := ids[0] // "r2"
formatID := ids[1] // "r3"
effectID := ids[2] // "r4"
Same media file used multiple times MUST reuse same asset:
❌ BAD: Create new asset for each use (causes UID collisions)
// Multiple assets with same UID = FCP import crash
asset1 := Asset{ID: "r2", UID: "ABC-123", Src: "file.mp4"}
asset2 := Asset{ID: "r5", UID: "ABC-123", Src: "file.mp4"} // Same UID!
✅ GOOD: Reuse asset, create multiple timeline references
createdAssets := make(map[string]string) // filepath -> assetID
if existingID, exists := createdAssets[filepath]; exists {
assetID = existingID // Reuse existing asset
} else {
assetID = tx.ReserveIDs(1)[0]
tx.CreateAsset(assetID, filepath, ...)
createdAssets[filepath] = assetID // Remember for reuse
}
// Multiple timeline elements can reference same asset:
// <asset-clip ref="r2"... /> and <asset-clip ref="r2"... />
FCP caches media file UIDs in its library database:
// ✅ GOOD: Consistent UIDs (same file = same UID always)
func generateUID(filename string) string {
basename := filepath.Base(filename) // Use filename, not full path
hash := sha256.Sum256([]byte(basename))
return fmt.Sprintf("%X", hash[:8])
}
// ❌ BAD: Path-based UIDs cause "cannot be imported again" errors
func generateUID(fullPath string) string {
hash := sha256.Sum256([]byte(fullPath)) // Path changes = different UIDs
return fmt.Sprintf("%X", hash[:8])
}
All durations MUST use fcp.ConvertSecondsToFCPDuration()
:
// ✅ GOOD: Frame-aligned duration
duration := fcp.ConvertSecondsToFCPDuration(5.5) // "132132/24000s"
// ❌ BAD Duration Patterns:
duration := fmt.Sprintf("%fs", seconds) // Decimal seconds cause drift
duration := fmt.Sprintf("%d/1000s", milliseconds) // Wrong timebase
duration := "3.5s" // Decimal seconds cause drift
offset := fmt.Sprintf("%d/30000s", frames) // Wrong denominator
duration := fmt.Sprintf("%d/24000s", randomNumerator) // Not frame-aligned
FCP uses a rational number system based on 24000/1001 timebase:
- Frame Rate: 23.976023976... fps (not exactly 24fps)
- Frame Duration: 1001/24000 seconds per frame
- Timebase: 24000 (denominator)
- Frame Increment: 1001 (numerator increment per frame)
const (
FCPTimebase = 24000
FCPFrameDuration = 1001
FCPFrameRate = 23.976023976023976
)
func ConvertSecondsToFCPDuration(seconds float64) string {
if seconds == 0 {
return "0s"
}
// Calculate exact frame count
frames := int(math.Round(seconds * FCPFrameRate))
// Convert to FCP's rational format
numerator := frames * FCPFrameDuration
return fmt.Sprintf("%d/%ds", numerator, FCPTimebase)
}
ALWAYS detect actual video properties instead of hardcoding:
❌ BAD: Hardcoded properties cause import failures
asset.HasAudio = "1" // Video might not have audio!
format.Width = "1920" // Video might be 1080×1920 portrait!
format.FrameDuration = "1001/30000s" // Video might be different fps!
✅ GOOD: Use CreateVideoAssetWithDetection() for proper detection
tx.CreateVideoAssetWithDetection(assetID, videoPath, baseName, duration, formatID)
// Automatically detects: width, height, frame rate, audio presence
// Matches samples: portrait videos get correct 1080×1920 dimensions
// Audio-only if file actually has audio tracks
// Frame rate validation: Rejects bogus rates >120fps, maps to standard FCP rates
Final Cut Pro requires absolute file paths:
❌ BAD: Relative paths cause "missing media" errors
MediaRep{
Src: "file://./assets/video.mp4", // Relative path
}
✅ GOOD: Always use absolute paths
absPath, err := filepath.Abs(videoPath)
MediaRep{
Src: "file://" + absPath, // Absolute path
}
ALWAYS use transaction methods to create resources:
❌ BAD: Direct manipulation bypasses transaction
effectID := tx.ReserveIDs(1)[0]
effect := Effect{ID: effectID, Name: "Blur", UID: "FFGaussianBlur"}
fcpxml.Resources.Effects = append(fcpxml.Resources.Effects, effect)
// Result: "Effect ID is invalid" - resource never committed!
✅ GOOD: Use transaction creation methods
effectID := tx.ReserveIDs(1)[0]
tx.CreateEffect(effectID, "Gaussian Blur", "FFGaussianBlur")
// Resource properly managed and committed with tx.Commit()
Why Direct Append Fails:
- Reserved IDs don't automatically create resources
- Transaction manages resource lifecycle
- Only tx.Commit() adds resources to final FCPXML
- Direct append bypasses validation and registration
ONLY use verified effect UIDs from samples/ directory:
-
Gaussian Blur:
FFGaussianBlur
-
Motion Blur:
FFMotionBlur
-
Color Correction:
FFColorCorrection
-
Saturation:
FFSaturation
-
Text Title:
.../Titles.localized/Basic Text.localized/Text.localized/Text.moti
-
Shape Mask:
FFSuperEllipseMask
❌ BAD: uid := "com.example.customeffect"
❌ BAD: uid := ".../Effects/MyCustomEffect.motn"
❌ BAD: uid := "user.defined.blur"
❌ BAD: uid := "/Library/Effects/CustomBlur.plugin"
❌ BAD: uid := "CustomEffect_" + generateUID()
// Spatial transformations - always safe
video.AdjustTransform = &fcp.AdjustTransform{
Position: "100 50",
Scale: "1.5 1.5",
Params: []fcp.Param{
{Name: "rotation", Value: "45"},
{Name: "anchor", Value: "0.5 0.5"},
},
}
// Cropping - always safe
assetClip.AdjustCrop = &fcp.AdjustCrop{
Mode: "trim",
TrimRect: &fcp.TrimRect{
Left: "0.1",
Right: "0.9",
Top: "0.1",
Bottom: "0.9",
},
}
Different parameters support different keyframe attributes:
<param name="position">
<keyframe time="86399313/24000s" value="0 0"/> <!-- NO interp/curve -->
</param>
<param name="scale">
<keyframe time="86399313/24000s" value="1 1" curve="linear"/> <!-- Only curve -->
</param>
<param name="opacity">
<keyframe time="0s" value="1" interp="linear" curve="smooth"/>
</param>
Adding unsupported attributes causes "param element was ignored" warnings.
Always validate your FCPXML before using:
func validateFCPXML(fcpxml *fcp.FCPXML) error {
// 1. Validate resource references
if err := validateResourceReferences(fcpxml); err != nil {
return fmt.Errorf("resource validation failed: %v", err)
}
// 2. Validate frame alignment
if err := validateFrameAlignment(fcpxml); err != nil {
return fmt.Errorf("timing validation failed: %v", err)
}
// 3. Validate media types
if err := validateMediaTypes(fcpxml); err != nil {
return fmt.Errorf("media type validation failed: %v", err)
}
return nil
}
-
Resource Reference Validation: Every
ref
attribute must point to an existing resource - Frame Alignment Validation: All durations must be frame-aligned
- Media Type Consistency: Images use Video elements, videos use AssetClip elements
- UID Uniqueness: No duplicate UIDs within the same FCPXML
- Lane Validation: Lane numbers must be consecutive and within reasonable limits
Before writing FCPXML code, review the logic in fcp/*_test.go
files:
-
fcp/generate_test.go
- Shows correct resource management patterns -
fcp/generator_*_test.go
- Shows working animation/effect patterns - These tests contain proven patterns that prevent crashes
- ID collisions - Use proper ResourceRegistry/Transaction pattern
-
Missing resources - Every
ref=
needs matchingid=
- Wrong element types - Images use Video, videos use AssetClip
- Fictional effect UIDs - Only use verified UIDs from samples/
- Non-frame-aligned durations - Use ConvertSecondsToFCPDuration()
- Path issues - Use absolute paths for media files
- UID inconsistency - Same file must have same UID always
// ✅ GOOD: Batch ID reservation
ids := tx.ReserveIDs(totalNeeded)
for i, mediaFile := range mediaFiles {
asset := createAsset(mediaFile, ids[i])
// Process asset...
}
// ❌ BAD: Individual ID requests
for _, mediaFile := range mediaFiles {
id := tx.ReserveIDs(1)[0] // Inefficient
asset := createAsset(mediaFile, id)
}
// ✅ GOOD: Reuse slices
spine.AssetClips = make([]fcp.AssetClip, 0, expectedCount)
for _, clip := range clips {
spine.AssetClips = append(spine.AssetClips, clip)
}
// ❌ BAD: Repeated allocations
for _, clip := range clips {
spine.AssetClips = append(spine.AssetClips, clip) // Grows slice repeatedly
}
Key Principle: Follow existing patterns in fcp/ package. If FCPXML generation requires more than 1 iteration to work, you're doing it wrong.
The most critical rules are:
- NO XML string templates - Use structs only
- Change code, not XML - Fix generation logic, not output
- Proper resource management - Use ResourceRegistry and transactions
- Frame alignment - Use ConvertSecondsToFCPDuration()
- Media type consistency - Images use Video, videos use AssetClip
- Verified effects only - Don't create fictional UIDs
- Comprehensive testing - Validate before using