Add face-based continuity planning, clip diagnostics and draft preview
This commit is contained in:
1 parent
0f64561062
commit
2efe6929d1
10 files changed
+900
-35
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
enum ContinuityChecks {
|
||||
static func check(_ value: @autoclosure () -> Bool, _ message: String) throws {
|
||||
if !value() { throw NSError(domain: "ContinuityChecks", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) }
|
||||
}
|
||||
|
||||
static func run() throws {
|
||||
let frontal = FacePose(x: 0.5, y: 0.4, height: 0.2, yaw: 0, roll: 0, eyesVisible: true)
|
||||
var turned = frontal
|
||||
turned.yaw = 0.6
|
||||
let choices = ContinuityPlanner.choose([
|
||||
[SegmentCandidate(start: 0, quality: 1, entry: turned, exit: turned),
|
||||
SegmentCandidate(start: 2, quality: 0.9, entry: frontal, exit: frontal)],
|
||||
[SegmentCandidate(start: 1, quality: 1, entry: frontal, exit: frontal)]
|
||||
])
|
||||
try check(choices.map(\.start) == [2, 1], "Joint planning did not prefer matching neighboring poses")
|
||||
let missing = VisualSample(time: 0, faceCount: 0, pose: nil, color: nil, quality: 0)
|
||||
let analysis = ClipVisualAnalysis(sourceSignature: "test", samples: [missing])
|
||||
try check(!ContinuityPlanner.review(analysis, start: 0, length: 1).messages.isEmpty, "Missing face was not flagged")
|
||||
let bounds = ContinuityPlanner.candidates(analysis: analysis, duration: 1, length: 2, currentStart: 10)
|
||||
try check(bounds.allSatisfy { $0.start == 0 }, "Candidate escaped short-source bounds")
|
||||
try check(analysis.window(start: 100, length: 1).isEmpty, "Sparse analysis pretends to know an unsampled window")
|
||||
|
||||
let color = FaceColor(luminance: 0.3, redToGreen: 1.02, blueToGreen: 0.98)
|
||||
let sample = VisualSample(time: 0, faceCount: 1, pose: frontal, color: color, quality: 1)
|
||||
let reference = FaceColor(luminance: 0.9, redToGreen: 2, blueToGreen: 0.1)
|
||||
let correction = ContinuityPlanner.colorAdjustment(samples: [sample], reference: reference, strength: 1)
|
||||
try check(abs(correction.brightness) <= 0.06 && correction.redGamma <= 1.06 && correction.blueGamma >= 0.94, "Unbounded color change")
|
||||
try check(ContinuityPlanner.colorAdjustment(samples: [sample], reference: reference, strength: 0) == ColorAdjustment(), "Zero strength changes colors")
|
||||
var ambiguous = sample
|
||||
ambiguous.faceCount = 2
|
||||
try check(ContinuityPlanner.colorAdjustment(samples: [ambiguous], reference: reference, strength: 1) == ColorAdjustment(), "Ambiguous face affected colors")
|
||||
try check(ContinuityPlanner.colorAdjustment(samples: [missing], reference: reference, strength: 1) == ColorAdjustment(), "Background used when no face is known")
|
||||
|
||||
let box = CGRect(x: 0.3, y: 0.3, width: 0.4, height: 0.4)
|
||||
let redBackground = try image(background: (1, 0, 0))
|
||||
let blueBackground = try image(background: (0, 0, 1))
|
||||
let redMeasurement = VisualAnalyzer.measureFaceColor(redBackground, box: box)
|
||||
let blueMeasurement = VisualAnalyzer.measureFaceColor(blueBackground, box: box)
|
||||
try check(redMeasurement != nil && redMeasurement == blueMeasurement, "Background color contaminated face measurements")
|
||||
|
||||
func clip(height: Double) -> GrowthLapseProjectClip {
|
||||
var pose = frontal
|
||||
pose.height = height
|
||||
let analysis = ClipVisualAnalysis(sourceSignature: "test", samples: [VisualSample(time: 0,
|
||||
faceCount: 1, pose: pose, color: color, quality: 1)])
|
||||
return GrowthLapseProjectClip(index: 1, sourcePath: "/tmp/source.mp4", displayName: "Test",
|
||||
duration: 4, width: 1920, height: 1080, ageWeeks: nil, segmentStart: 0, segmentLength: 2,
|
||||
normalizedClipPath: "/tmp/cache/clip.mp4", visualAnalysis: analysis, variants: nil)
|
||||
}
|
||||
let profile = ContinuityPlanner.sharedProfile(clips: [clip(height: 0.08), clip(height: 0.35)],
|
||||
dimensions: VideoDimensions(width: 1920, height: 1080), requested: 0.26, eyeY: 0.4)
|
||||
try check(profile.faceHeightRatio > 0.35 && profile.faceHeightRatio <= 0.38, "Different camera distances were not negotiated")
|
||||
let extreme = ContinuityPlanner.sharedProfile(clips: [clip(height: 0.08), clip(height: 0.9)],
|
||||
dimensions: VideoDimensions(width: 1920, height: 1080), requested: 0.26, eyeY: 0.4)
|
||||
try check(extreme.faceHeightRatio <= 0.38, "One extreme close-up forces excessive zoom on all clips")
|
||||
print("PASS joint transition planning, sparse-window handling, face-only bounded colors and camera-distance profile")
|
||||
}
|
||||
|
||||
private static func image(background: (CGFloat, CGFloat, CGFloat)) throws -> CGImage {
|
||||
let context = CGContext(data: nil, width: 100, height: 100, bitsPerComponent: 8, bytesPerRow: 400,
|
||||
space: CGColorSpace(name: CGColorSpace.sRGB)!, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
|
||||
context.setFillColor(CGColor(red: background.0, green: background.1, blue: background.2, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: 100, height: 100))
|
||||
context.setFillColor(CGColor(gray: 0.5, alpha: 1))
|
||||
context.fill(CGRect(x: 30, y: 30, width: 40, height: 40))
|
||||
return context.makeImage()!
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ struct RenderIntegration {
|
||||
|
||||
@MainActor
|
||||
static func main() async throws {
|
||||
try ContinuityChecks.run()
|
||||
let defaults = RenderSettings()
|
||||
try require(defaults.inputFolder == nil && defaults.outputFile == nil
|
||||
&& defaults.ffmpegExecutable == nil && defaults.ffprobeExecutable == nil, "Machine-specific defaults")
|
||||
@@ -124,6 +125,40 @@ struct RenderIntegration {
|
||||
try require(streams.count == 1 && streams[0]["width"] as? Int == 160, "Silent native-size render")
|
||||
print("PASS two-clip render, ownership, dimensions and audio removal")
|
||||
|
||||
let draftSourceState = processor.project!
|
||||
let finalBytesBeforeDraft = try Data(contentsOf: first.outputFileURL)
|
||||
let finalJSONBeforeDraft = try Data(contentsOf: GrowthLapseProject.url(for: first.outputFileURL))
|
||||
var draftSettings = settings
|
||||
draftSettings.outputFormat = .portrait1080x1920
|
||||
processor.startDraft(settings: draftSettings)
|
||||
let draftDeadline = Date().addingTimeInterval(60)
|
||||
while processor.state.isRunning && Date() < draftDeadline { try await Task.sleep(nanoseconds: 20_000_000) }
|
||||
try require(processor.draftOutput != nil && !processor.state.isRunning, "Draft failed: \(processor.state)")
|
||||
try require(processor.project == draftSourceState, "Draft modified the working project")
|
||||
let finalBytesAfterDraft = try Data(contentsOf: first.outputFileURL)
|
||||
let finalJSONAfterDraft = try Data(contentsOf: GrowthLapseProject.url(for: first.outputFileURL))
|
||||
try require(finalBytesBeforeDraft == finalBytesAfterDraft && finalJSONBeforeDraft == finalJSONAfterDraft, "Draft replaced final artifacts")
|
||||
let draftMetadata = try await probe(processor.draftOutput!, tools: service, ffprobe: tools.ffprobe)
|
||||
let draftStreams = draftMetadata["streams"] as! [[String: Any]]
|
||||
let draftVideo = draftStreams.first { $0["codec_type"] as? String == "video" }!
|
||||
try require(draftVideo["width"] as? Int == 360 && draftVideo["height"] as? Int == 640, "Draft dimensions")
|
||||
let draftURL = processor.draftOutput!
|
||||
processor.discardDraft()
|
||||
try require(!manager.fileExists(atPath: draftURL.path), "Draft cleanup failed")
|
||||
print("PASS portrait draft 360x640 preserves final output, project and cache; cleanup removes only draft")
|
||||
|
||||
let analysisOnly = VideoProcessor()
|
||||
var analysisSettings = settings
|
||||
analysisSettings.outputFile = root.appendingPathComponent("Analysis-only.mp4")
|
||||
analysisOnly.analyze(settings: analysisSettings)
|
||||
let analysisDeadline = Date().addingTimeInterval(60)
|
||||
while analysisOnly.state.isRunning && Date() < analysisDeadline { try await Task.sleep(nanoseconds: 20_000_000) }
|
||||
try require(analysisOnly.state == .idle && analysisOnly.project?.clips.count == 2, "Analysis-only failed")
|
||||
try require(!manager.fileExists(atPath: analysisSettings.outputFile!.path), "Analysis created a final video")
|
||||
try require(analysisOnly.project!.clips.allSatisfy { !($0.review?.messages.isEmpty ?? true) }, "Missing-face warnings absent")
|
||||
print("PASS pre-render analysis and missing-face diagnostics without producing final video")
|
||||
|
||||
|
||||
let alias = root.appendingPathComponent("GrowthLapse-alias.growthlapse-cache")
|
||||
try manager.createSymbolicLink(at: alias, withDestinationURL: first.cacheDirectoryURL)
|
||||
try require(!RenderWorkspace.isOwned(alias, by: first.outputFileURL), "Symlink cache accepted")
|
||||
|
||||
Reference in new issue
Block a user