961 lines
34 KiB
C#
961 lines
34 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Unity.Cinemachine;
|
|
using UnityEditor;
|
|
using UnityEngine;
|
|
using UnityEngine.Playables;
|
|
using UnityEngine.Timeline;
|
|
|
|
namespace Streamingle.Editor
|
|
{
|
|
/// <summary>
|
|
/// Explicitly snapshots the difference between generated cut boundaries and
|
|
/// the currently edited AI preview Timeline. It never mutates or saves Unity
|
|
/// assets, scenes, or Timeline clips.
|
|
/// </summary>
|
|
public static class AICameraCutCorrectionRecorder
|
|
{
|
|
internal const string CurrentSchemaVersion =
|
|
"ai-camera-cut-correction-snapshot-v1";
|
|
internal const int RequiredSampleRate = 60;
|
|
internal const int MoveMatchWindowFrames = 180;
|
|
|
|
private const double FrameAlignmentTolerance = 0.25d;
|
|
private const double CostEpsilon = 0.000000001d;
|
|
private const string EditorStyleHashPrefix =
|
|
"editor-style-sha256:";
|
|
|
|
/// <summary>
|
|
/// Captures one correction snapshot for the active AI preview belonging
|
|
/// to <paramref name="sourceDirector"/>. The returned value is the full
|
|
/// path of the newly written JSON file.
|
|
/// </summary>
|
|
public static string CaptureForCli(
|
|
PlayableDirector sourceDirector,
|
|
string generatedDirectory,
|
|
string editorStyleId,
|
|
string outputDirectory)
|
|
{
|
|
if (EditorApplication.isPlayingOrWillChangePlaymode)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Cut correction capture is available only in Edit Mode.");
|
|
}
|
|
|
|
if (sourceDirector == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(sourceDirector));
|
|
}
|
|
|
|
if (!AICameraTimelinePreviewImporter
|
|
.TryGetPreviewTimelineForEditor(
|
|
sourceDirector,
|
|
out var timeline))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The selected source Timeline has no AI camera preview.");
|
|
}
|
|
|
|
if (!AICameraTimelinePreviewImporter
|
|
.TryGetPreviewGenerationProvenanceForEditor(
|
|
sourceDirector,
|
|
out var provenance))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"The AI preview has no generation provenance. Re-import " +
|
|
"the generated camera before recording corrections.");
|
|
}
|
|
|
|
var fullGeneratedDirectory = RequireDirectoryPath(
|
|
generatedDirectory,
|
|
nameof(generatedDirectory));
|
|
|
|
// Fail closed if any generated payload no longer matches metadata.
|
|
AICameraTimelinePreviewImporter.ValidateGeneratedDirectoryForCli(
|
|
fullGeneratedDirectory);
|
|
|
|
return CaptureTimeline(
|
|
timeline,
|
|
fullGeneratedDirectory,
|
|
editorStyleId,
|
|
outputDirectory,
|
|
DateTime.UtcNow,
|
|
provenance.MetadataSha256);
|
|
}
|
|
|
|
internal static string CaptureTimelineForTests(
|
|
TimelineAsset timeline,
|
|
string generatedDirectory,
|
|
string editorStyleId,
|
|
string outputDirectory,
|
|
DateTime capturedUtc,
|
|
string expectedMetadataSha256 = null)
|
|
{
|
|
return CaptureTimeline(
|
|
timeline,
|
|
RequireDirectoryPath(
|
|
generatedDirectory,
|
|
nameof(generatedDirectory)),
|
|
editorStyleId,
|
|
outputDirectory,
|
|
capturedUtc,
|
|
expectedMetadataSha256);
|
|
}
|
|
|
|
private static string CaptureTimeline(
|
|
TimelineAsset timeline,
|
|
string generatedDirectory,
|
|
string editorStyleId,
|
|
string outputDirectory,
|
|
DateTime capturedUtc,
|
|
string expectedMetadataSha256)
|
|
{
|
|
if (timeline == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(timeline));
|
|
}
|
|
|
|
var input = ReadGenerationInput(generatedDirectory);
|
|
if (!string.IsNullOrWhiteSpace(expectedMetadataSha256))
|
|
{
|
|
var expected = NormalizeSha256(
|
|
expectedMetadataSha256,
|
|
"preview provenance metadata SHA-256");
|
|
if (!string.Equals(
|
|
expected,
|
|
input.MetadataSha256,
|
|
StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidDataException(
|
|
"The selected AI preview belongs to a different " +
|
|
"generation. Its metadata SHA-256 does not match.");
|
|
}
|
|
}
|
|
|
|
var finalBoundaries = ExtractFinalBoundaryFrames(
|
|
timeline,
|
|
input.Metadata.frameCount);
|
|
var corrections = BuildCorrections(
|
|
input.OriginalBoundaryFrames,
|
|
finalBoundaries);
|
|
var anonymizedEditorStyleId = AnonymizeEditorStyleId(
|
|
editorStyleId);
|
|
|
|
var timelineAssetPath = AssetDatabase.GetAssetPath(timeline);
|
|
var snapshot = new CorrectionSnapshot
|
|
{
|
|
schemaVersion = CurrentSchemaVersion,
|
|
capturedUtc = capturedUtc
|
|
.ToUniversalTime()
|
|
.ToString(
|
|
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
|
|
CultureInfo.InvariantCulture),
|
|
sampleRate = RequiredSampleRate,
|
|
songName = input.Metadata.songName,
|
|
songId = input.Metadata.songId,
|
|
datasetId = input.Metadata.datasetId,
|
|
generationHashSha256 = input.MetadataSha256,
|
|
generationOutputSha256 = new OutputSha256
|
|
{
|
|
worldCamera = NormalizeSha256(
|
|
input.Metadata.outputSha256.worldCamera,
|
|
"metadata outputSha256.worldCamera"),
|
|
time = NormalizeSha256(
|
|
input.Metadata.outputSha256.time,
|
|
"metadata outputSha256.time"),
|
|
shots = NormalizeSha256(
|
|
input.Metadata.outputSha256.shots,
|
|
"metadata outputSha256.shots")
|
|
},
|
|
editorStyleId = anonymizedEditorStyleId,
|
|
previewTimelineGuid = string.IsNullOrWhiteSpace(
|
|
timelineAssetPath)
|
|
? string.Empty
|
|
: AssetDatabase.AssetPathToGUID(timelineAssetPath),
|
|
matchingPolicy = "ordered-dp-v1",
|
|
moveMatchWindowFrames = MoveMatchWindowFrames,
|
|
ordinalBase = 0,
|
|
originalBoundaryFrames = input.OriginalBoundaryFrames,
|
|
finalBoundaryFrames = finalBoundaries,
|
|
originalBoundarySha256 = ComputeBoundarySha256(
|
|
input.OriginalBoundaryFrames),
|
|
finalBoundarySha256 = ComputeBoundarySha256(
|
|
finalBoundaries),
|
|
originalBoundaryCount = input.OriginalBoundaryFrames.Length,
|
|
finalBoundaryCount = finalBoundaries.Length,
|
|
corrections = corrections
|
|
};
|
|
|
|
foreach (var correction in corrections)
|
|
{
|
|
switch (correction.action)
|
|
{
|
|
case "keep":
|
|
snapshot.keepCount++;
|
|
break;
|
|
case "move":
|
|
snapshot.moveCount++;
|
|
break;
|
|
case "add":
|
|
snapshot.addCount++;
|
|
break;
|
|
case "delete":
|
|
snapshot.deleteCount++;
|
|
break;
|
|
}
|
|
}
|
|
|
|
var json = JsonUtility.ToJson(snapshot, true) + Environment.NewLine;
|
|
return WriteSnapshotAtomically(
|
|
outputDirectory,
|
|
snapshot,
|
|
json);
|
|
}
|
|
|
|
private static GenerationInput ReadGenerationInput(
|
|
string generatedDirectory)
|
|
{
|
|
var metadataPath = Path.Combine(
|
|
generatedDirectory,
|
|
"metadata.json");
|
|
if (!File.Exists(metadataPath))
|
|
{
|
|
throw new FileNotFoundException(
|
|
"Generated camera metadata was not found.",
|
|
metadataPath);
|
|
}
|
|
|
|
var metadataBytes = File.ReadAllBytes(metadataPath);
|
|
var metadataJson = Encoding.UTF8.GetString(metadataBytes);
|
|
if (metadataJson.Length > 0 && metadataJson[0] == '\uFEFF')
|
|
{
|
|
metadataJson = metadataJson.Substring(1);
|
|
}
|
|
|
|
var metadata = JsonUtility.FromJson<GeneratedMetadata>(
|
|
metadataJson);
|
|
if (metadata == null)
|
|
{
|
|
throw new InvalidDataException(
|
|
"Generated camera metadata is invalid JSON.");
|
|
}
|
|
|
|
ValidateMetadata(metadata);
|
|
var shotsPath = ResolveSafePayloadPath(
|
|
generatedDirectory,
|
|
metadata.shotsFile,
|
|
"shotsFile");
|
|
if (!File.Exists(shotsPath))
|
|
{
|
|
throw new FileNotFoundException(
|
|
"Generated camera shot definitions were not found.",
|
|
shotsPath);
|
|
}
|
|
|
|
var shotFile = JsonUtility.FromJson<GeneratedShotFile>(
|
|
File.ReadAllText(shotsPath));
|
|
if (shotFile?.shots == null || shotFile.shots.Count == 0)
|
|
{
|
|
throw new InvalidDataException(
|
|
"Generated shot definitions contain no shots.");
|
|
}
|
|
|
|
var cursor = 0;
|
|
for (var index = 0; index < shotFile.shots.Count; index++)
|
|
{
|
|
var shot = shotFile.shots[index];
|
|
if (shot == null || shot.startFrame != cursor ||
|
|
shot.endFrameExclusive <= shot.startFrame ||
|
|
shot.endFrameExclusive > metadata.frameCount)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Generated shot {index} is not a contiguous, " +
|
|
"positive frame range.");
|
|
}
|
|
|
|
cursor = shot.endFrameExclusive;
|
|
}
|
|
|
|
if (cursor != metadata.frameCount)
|
|
{
|
|
throw new InvalidDataException(
|
|
"Generated shots do not cover the complete song.");
|
|
}
|
|
|
|
var boundaries = shotFile.shots
|
|
.Take(shotFile.shots.Count - 1)
|
|
.Select(shot => shot.endFrameExclusive)
|
|
.ToArray();
|
|
ValidateBoundarySequence(
|
|
boundaries,
|
|
metadata.frameCount,
|
|
"generated");
|
|
|
|
return new GenerationInput
|
|
{
|
|
Metadata = metadata,
|
|
MetadataSha256 = ComputeSha256(metadataBytes),
|
|
OriginalBoundaryFrames = boundaries
|
|
};
|
|
}
|
|
|
|
private static void ValidateMetadata(GeneratedMetadata metadata)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(metadata.songName))
|
|
{
|
|
throw new InvalidDataException(
|
|
"metadata.songName is required.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(metadata.songId))
|
|
{
|
|
throw new InvalidDataException(
|
|
"metadata.songId is required.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(metadata.datasetId))
|
|
{
|
|
throw new InvalidDataException(
|
|
"metadata.datasetId is required.");
|
|
}
|
|
|
|
if (metadata.sampleRate != RequiredSampleRate)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Cut correction capture requires exactly " +
|
|
$"{RequiredSampleRate} FPS, but metadata declares " +
|
|
$"{metadata.sampleRate} FPS.");
|
|
}
|
|
|
|
if (metadata.frameCount <= 0)
|
|
{
|
|
throw new InvalidDataException(
|
|
"metadata.frameCount must be positive.");
|
|
}
|
|
|
|
if (metadata.outputSha256 == null)
|
|
{
|
|
throw new InvalidDataException(
|
|
"metadata.outputSha256 is required.");
|
|
}
|
|
|
|
NormalizeSha256(
|
|
metadata.outputSha256.worldCamera,
|
|
"metadata outputSha256.worldCamera");
|
|
NormalizeSha256(
|
|
metadata.outputSha256.time,
|
|
"metadata outputSha256.time");
|
|
NormalizeSha256(
|
|
metadata.outputSha256.shots,
|
|
"metadata outputSha256.shots");
|
|
}
|
|
|
|
private static int[] ExtractFinalBoundaryFrames(
|
|
TimelineAsset timeline,
|
|
int frameCount)
|
|
{
|
|
var matchingTracks = timeline.GetOutputTracks()
|
|
.OfType<CinemachineTrack>()
|
|
.Where(track =>
|
|
track.name == AICameraTimelinePreviewImporter
|
|
.GeneratedCinemachineTrackName)
|
|
.ToArray();
|
|
if (matchingTracks.Length != 1)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"The preview Timeline must contain exactly one " +
|
|
$"'{AICameraTimelinePreviewImporter.GeneratedCinemachineTrackName}' " +
|
|
"track.");
|
|
}
|
|
|
|
var clips = matchingTracks[0]
|
|
.GetClips()
|
|
.OrderBy(clip => clip.start)
|
|
.ThenBy(clip => clip.duration)
|
|
.ToArray();
|
|
if (clips.Length == 0)
|
|
{
|
|
throw new InvalidDataException(
|
|
"The generated Cinemachine track contains no clips.");
|
|
}
|
|
|
|
var starts = new int[clips.Length];
|
|
var ends = new int[clips.Length];
|
|
for (var index = 0; index < clips.Length; index++)
|
|
{
|
|
var clip = clips[index];
|
|
if (clip.easeInDuration > 0.0000001d ||
|
|
clip.easeOutDuration > 0.0000001d)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Cinemachine clip {index} has an ease/blend. " +
|
|
"Cut learning accepts hard cuts only.");
|
|
}
|
|
|
|
starts[index] = ToAlignedFrame(
|
|
clip.start,
|
|
$"Cinemachine clip {index} start");
|
|
ends[index] = ToAlignedFrame(
|
|
clip.end,
|
|
$"Cinemachine clip {index} end");
|
|
if (ends[index] <= starts[index])
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Cinemachine clip {index} has no positive duration.");
|
|
}
|
|
}
|
|
|
|
if (starts[0] != 0)
|
|
{
|
|
throw new InvalidDataException(
|
|
"The generated Cinemachine track must begin at frame 0.");
|
|
}
|
|
|
|
for (var index = 1; index < clips.Length; index++)
|
|
{
|
|
if (starts[index] != ends[index - 1])
|
|
{
|
|
throw new InvalidDataException(
|
|
$"Cut boundary before clip {index} has a gap or " +
|
|
"overlap. Align both clip edges to the same frame " +
|
|
"before recording corrections.");
|
|
}
|
|
}
|
|
|
|
if (ends[ends.Length - 1] != frameCount)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"The generated Cinemachine track must end at frame " +
|
|
$"{frameCount}.");
|
|
}
|
|
|
|
var boundaries = starts.Skip(1).ToArray();
|
|
ValidateBoundarySequence(boundaries, frameCount, "edited");
|
|
return boundaries;
|
|
}
|
|
|
|
private static int ToAlignedFrame(double seconds, string label)
|
|
{
|
|
if (double.IsNaN(seconds) || double.IsInfinity(seconds))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"{label} is not a finite Timeline time.");
|
|
}
|
|
|
|
var frameValue = seconds * RequiredSampleRate;
|
|
var rounded = Math.Round(
|
|
frameValue,
|
|
MidpointRounding.AwayFromZero);
|
|
if (Math.Abs(frameValue - rounded) > FrameAlignmentTolerance)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"{label} is not aligned to a {RequiredSampleRate} FPS " +
|
|
"frame boundary.");
|
|
}
|
|
|
|
if (rounded < int.MinValue || rounded > int.MaxValue)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"{label} is outside the supported frame range.");
|
|
}
|
|
|
|
return (int)rounded;
|
|
}
|
|
|
|
private static CorrectionRecord[] BuildCorrections(
|
|
int[] originalBoundaries,
|
|
int[] finalBoundaries)
|
|
{
|
|
var originalCount = originalBoundaries.Length;
|
|
var finalCount = finalBoundaries.Length;
|
|
var costs = new double[originalCount + 1, finalCount + 1];
|
|
var steps = new DiffStep[originalCount + 1, finalCount + 1];
|
|
|
|
for (var originalIndex = 0;
|
|
originalIndex <= originalCount;
|
|
originalIndex++)
|
|
{
|
|
for (var finalIndex = 0;
|
|
finalIndex <= finalCount;
|
|
finalIndex++)
|
|
{
|
|
costs[originalIndex, finalIndex] = double.PositiveInfinity;
|
|
}
|
|
}
|
|
|
|
costs[0, 0] = 0d;
|
|
for (var originalIndex = 0;
|
|
originalIndex <= originalCount;
|
|
originalIndex++)
|
|
{
|
|
for (var finalIndex = 0;
|
|
finalIndex <= finalCount;
|
|
finalIndex++)
|
|
{
|
|
if (originalIndex == 0 && finalIndex == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var bestCost = double.PositiveInfinity;
|
|
var bestPriority = int.MaxValue;
|
|
var bestStep = DiffStep.None;
|
|
|
|
if (originalIndex > 0 && finalIndex > 0)
|
|
{
|
|
var delta = Math.Abs(
|
|
originalBoundaries[originalIndex - 1] -
|
|
finalBoundaries[finalIndex - 1]);
|
|
if (delta <= MoveMatchWindowFrames)
|
|
{
|
|
var matchCost = delta == 0
|
|
? 0d
|
|
: 0.25d +
|
|
1.5d * delta / MoveMatchWindowFrames;
|
|
SelectCandidate(
|
|
costs[originalIndex - 1, finalIndex - 1] +
|
|
matchCost,
|
|
0,
|
|
DiffStep.Match,
|
|
ref bestCost,
|
|
ref bestPriority,
|
|
ref bestStep);
|
|
}
|
|
}
|
|
|
|
if (originalIndex > 0)
|
|
{
|
|
SelectCandidate(
|
|
costs[originalIndex - 1, finalIndex] + 1d,
|
|
1,
|
|
DiffStep.Delete,
|
|
ref bestCost,
|
|
ref bestPriority,
|
|
ref bestStep);
|
|
}
|
|
|
|
if (finalIndex > 0)
|
|
{
|
|
SelectCandidate(
|
|
costs[originalIndex, finalIndex - 1] + 1d,
|
|
2,
|
|
DiffStep.Add,
|
|
ref bestCost,
|
|
ref bestPriority,
|
|
ref bestStep);
|
|
}
|
|
|
|
costs[originalIndex, finalIndex] = bestCost;
|
|
steps[originalIndex, finalIndex] = bestStep;
|
|
}
|
|
}
|
|
|
|
var reverse = new List<CorrectionRecord>(
|
|
originalCount + finalCount);
|
|
var originalCursor = originalCount;
|
|
var finalCursor = finalCount;
|
|
while (originalCursor > 0 || finalCursor > 0)
|
|
{
|
|
switch (steps[originalCursor, finalCursor])
|
|
{
|
|
case DiffStep.Match:
|
|
{
|
|
var originalFrame =
|
|
originalBoundaries[originalCursor - 1];
|
|
var finalFrame = finalBoundaries[finalCursor - 1];
|
|
reverse.Add(new CorrectionRecord
|
|
{
|
|
action = originalFrame == finalFrame
|
|
? "keep"
|
|
: "move",
|
|
hasOriginal = true,
|
|
originalFrame = originalFrame,
|
|
originalOrdinal = originalCursor - 1,
|
|
hasFinal = true,
|
|
finalFrame = finalFrame,
|
|
finalOrdinal = finalCursor - 1,
|
|
deltaFrames = finalFrame - originalFrame
|
|
});
|
|
originalCursor--;
|
|
finalCursor--;
|
|
break;
|
|
}
|
|
case DiffStep.Delete:
|
|
reverse.Add(new CorrectionRecord
|
|
{
|
|
action = "delete",
|
|
hasOriginal = true,
|
|
originalFrame =
|
|
originalBoundaries[originalCursor - 1],
|
|
originalOrdinal = originalCursor - 1,
|
|
hasFinal = false,
|
|
finalFrame = -1,
|
|
finalOrdinal = -1,
|
|
deltaFrames = 0
|
|
});
|
|
originalCursor--;
|
|
break;
|
|
case DiffStep.Add:
|
|
reverse.Add(new CorrectionRecord
|
|
{
|
|
action = "add",
|
|
hasOriginal = false,
|
|
originalFrame = -1,
|
|
originalOrdinal = -1,
|
|
hasFinal = true,
|
|
finalFrame = finalBoundaries[finalCursor - 1],
|
|
finalOrdinal = finalCursor - 1,
|
|
deltaFrames = 0
|
|
});
|
|
finalCursor--;
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException(
|
|
"Unable to construct a deterministic cut diff.");
|
|
}
|
|
}
|
|
|
|
reverse.Reverse();
|
|
return reverse.ToArray();
|
|
}
|
|
|
|
private static void SelectCandidate(
|
|
double candidateCost,
|
|
int candidatePriority,
|
|
DiffStep candidateStep,
|
|
ref double bestCost,
|
|
ref int bestPriority,
|
|
ref DiffStep bestStep)
|
|
{
|
|
if (double.IsPositiveInfinity(candidateCost))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (candidateCost < bestCost - CostEpsilon ||
|
|
(Math.Abs(candidateCost - bestCost) <= CostEpsilon &&
|
|
candidatePriority < bestPriority))
|
|
{
|
|
bestCost = candidateCost;
|
|
bestPriority = candidatePriority;
|
|
bestStep = candidateStep;
|
|
}
|
|
}
|
|
|
|
private static void ValidateBoundarySequence(
|
|
int[] boundaries,
|
|
int frameCount,
|
|
string label)
|
|
{
|
|
var previous = 0;
|
|
for (var index = 0; index < boundaries.Length; index++)
|
|
{
|
|
var frame = boundaries[index];
|
|
if (frame <= previous || frame >= frameCount)
|
|
{
|
|
throw new InvalidDataException(
|
|
$"The {label} cut boundary at index {index} is " +
|
|
"outside the song or not strictly increasing.");
|
|
}
|
|
|
|
previous = frame;
|
|
}
|
|
}
|
|
|
|
private static string AnonymizeEditorStyleId(string editorStyleId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(editorStyleId))
|
|
{
|
|
throw new ArgumentException(
|
|
"An editor style ID is required. Use a stable nickname " +
|
|
"that does not contain personal information.",
|
|
nameof(editorStyleId));
|
|
}
|
|
|
|
var normalized = editorStyleId.Trim();
|
|
if (normalized.StartsWith(
|
|
EditorStyleHashPrefix,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var existingHash = NormalizeSha256(
|
|
normalized.Substring(EditorStyleHashPrefix.Length),
|
|
"editor style ID");
|
|
return EditorStyleHashPrefix + existingHash;
|
|
}
|
|
|
|
return EditorStyleHashPrefix + ComputeSha256(
|
|
Encoding.UTF8.GetBytes(normalized));
|
|
}
|
|
|
|
private static string ComputeBoundarySha256(int[] boundaries)
|
|
{
|
|
var canonical = string.Join(
|
|
",",
|
|
boundaries.Select(frame =>
|
|
frame.ToString(CultureInfo.InvariantCulture)));
|
|
return ComputeSha256(Encoding.UTF8.GetBytes(canonical));
|
|
}
|
|
|
|
private static string ComputeSha256(byte[] bytes)
|
|
{
|
|
using var algorithm = SHA256.Create();
|
|
var hash = algorithm.ComputeHash(bytes);
|
|
var builder = new StringBuilder(hash.Length * 2);
|
|
foreach (var value in hash)
|
|
{
|
|
builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static string NormalizeSha256(string value, string label)
|
|
{
|
|
var normalized = value?.Trim().ToLowerInvariant();
|
|
if (normalized == null || normalized.Length != 64 ||
|
|
normalized.Any(character =>
|
|
!(character >= '0' && character <= '9') &&
|
|
!(character >= 'a' && character <= 'f')))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"{label} must be a 64-character SHA-256 value.");
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
private static string ResolveSafePayloadPath(
|
|
string rootDirectory,
|
|
string relativePath,
|
|
string label)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(relativePath) ||
|
|
Path.IsPathRooted(relativePath))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"metadata.{label} must be a relative path.");
|
|
}
|
|
|
|
var segments = relativePath.Split(
|
|
new[] { '/', '\\' },
|
|
StringSplitOptions.None);
|
|
if (segments.Any(segment =>
|
|
string.IsNullOrWhiteSpace(segment) ||
|
|
segment == "." ||
|
|
segment == ".."))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"metadata.{label} contains an unsafe path segment.");
|
|
}
|
|
|
|
var root = Path.GetFullPath(rootDirectory);
|
|
var candidate = Path.GetFullPath(
|
|
Path.Combine(root, relativePath));
|
|
var rootPrefix = root.TrimEnd(
|
|
Path.DirectorySeparatorChar,
|
|
Path.AltDirectorySeparatorChar) +
|
|
Path.DirectorySeparatorChar;
|
|
if (!candidate.StartsWith(
|
|
rootPrefix,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
throw new InvalidDataException(
|
|
$"metadata.{label} escapes the generated directory.");
|
|
}
|
|
|
|
return candidate;
|
|
}
|
|
|
|
private static string RequireDirectoryPath(
|
|
string value,
|
|
string parameterName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value))
|
|
{
|
|
throw new ArgumentException(
|
|
"A directory path is required.",
|
|
parameterName);
|
|
}
|
|
|
|
var path = Path.GetFullPath(value);
|
|
if (!Directory.Exists(path))
|
|
{
|
|
throw new DirectoryNotFoundException(
|
|
$"Directory was not found: {path}");
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
private static string WriteSnapshotAtomically(
|
|
string outputDirectory,
|
|
CorrectionSnapshot snapshot,
|
|
string json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(outputDirectory))
|
|
{
|
|
throw new ArgumentException(
|
|
"A correction log output directory is required.",
|
|
nameof(outputDirectory));
|
|
}
|
|
|
|
var outputRoot = Path.GetFullPath(outputDirectory);
|
|
Directory.CreateDirectory(outputRoot);
|
|
var songToken = SanitizeFileToken(snapshot.songId);
|
|
var timestampToken = DateTime.Parse(
|
|
snapshot.capturedUtc,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AssumeUniversal |
|
|
DateTimeStyles.AdjustToUniversal)
|
|
.ToString(
|
|
"yyyyMMdd'T'HHmmssfff'Z'",
|
|
CultureInfo.InvariantCulture);
|
|
var baseName =
|
|
$"cut_correction_{songToken}_{timestampToken}_" +
|
|
$"{snapshot.generationHashSha256.Substring(0, 8)}_" +
|
|
$"{snapshot.finalBoundarySha256.Substring(0, 8)}";
|
|
var outputPath = Path.Combine(outputRoot, baseName + ".json");
|
|
for (var suffix = 2; File.Exists(outputPath); suffix++)
|
|
{
|
|
outputPath = Path.Combine(
|
|
outputRoot,
|
|
$"{baseName}_{suffix:D2}.json");
|
|
}
|
|
|
|
var temporaryPath =
|
|
outputPath + $".tmp-{Guid.NewGuid():N}";
|
|
try
|
|
{
|
|
File.WriteAllText(
|
|
temporaryPath,
|
|
json,
|
|
new UTF8Encoding(false));
|
|
File.Move(temporaryPath, outputPath);
|
|
}
|
|
finally
|
|
{
|
|
if (File.Exists(temporaryPath))
|
|
{
|
|
File.Delete(temporaryPath);
|
|
}
|
|
}
|
|
|
|
return outputPath;
|
|
}
|
|
|
|
private static string SanitizeFileToken(string value)
|
|
{
|
|
var builder = new StringBuilder();
|
|
foreach (var character in value ?? string.Empty)
|
|
{
|
|
builder.Append(
|
|
char.IsLetterOrDigit(character) ||
|
|
character == '-' || character == '_'
|
|
? character
|
|
: '_');
|
|
if (builder.Length >= 48)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
return builder.Length == 0 ? "song" : builder.ToString();
|
|
}
|
|
|
|
private enum DiffStep
|
|
{
|
|
None,
|
|
Match,
|
|
Delete,
|
|
Add
|
|
}
|
|
|
|
private sealed class GenerationInput
|
|
{
|
|
internal GeneratedMetadata Metadata;
|
|
internal string MetadataSha256;
|
|
internal int[] OriginalBoundaryFrames;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class GeneratedMetadata
|
|
{
|
|
public string songName;
|
|
public string songId;
|
|
public string datasetId;
|
|
public int sampleRate;
|
|
public int frameCount;
|
|
public string shotsFile;
|
|
public OutputSha256 outputSha256;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class GeneratedShotFile
|
|
{
|
|
public List<GeneratedShot> shots;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class GeneratedShot
|
|
{
|
|
public int startFrame;
|
|
public int endFrameExclusive;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class CorrectionSnapshot
|
|
{
|
|
public string schemaVersion;
|
|
public string capturedUtc;
|
|
public int sampleRate;
|
|
public string songName;
|
|
public string songId;
|
|
public string datasetId;
|
|
public string generationHashSha256;
|
|
public OutputSha256 generationOutputSha256;
|
|
public string editorStyleId;
|
|
public string previewTimelineGuid;
|
|
public string matchingPolicy;
|
|
public int moveMatchWindowFrames;
|
|
public int ordinalBase;
|
|
public int[] originalBoundaryFrames;
|
|
public int[] finalBoundaryFrames;
|
|
public string originalBoundarySha256;
|
|
public string finalBoundarySha256;
|
|
public int originalBoundaryCount;
|
|
public int finalBoundaryCount;
|
|
public int keepCount;
|
|
public int moveCount;
|
|
public int addCount;
|
|
public int deleteCount;
|
|
public CorrectionRecord[] corrections;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class OutputSha256
|
|
{
|
|
public string worldCamera;
|
|
public string time;
|
|
public string shots;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class CorrectionRecord
|
|
{
|
|
public string action;
|
|
public bool hasOriginal;
|
|
public int originalFrame;
|
|
public int originalOrdinal;
|
|
public bool hasFinal;
|
|
public int finalFrame;
|
|
public int finalOrdinal;
|
|
public int deltaFrames;
|
|
}
|
|
}
|
|
}
|