diff --git a/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md b/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md
index f4f1e61101..53129bd40f 100644
--- a/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md
+++ b/docs/specs/001-agent-editing-toolkit/contracts/mcp-tools.md
@@ -97,7 +97,7 @@ A `session` may be **file-opened** (headless server) or **live** (in-app host bo
### `open_project`
- **Input**: `{ "path": string }` (read — unrestricted).
-- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize } }`.
+- **Output**: `{ "session": string, "source": "File", "summary": { scenes, elements, duration, frameSize }, "warnings": string[], "recoveryIncidents": [{ "sceneId": string, "sceneName": string, "elementFile": string, "reason": "TypeNotFound" | "DeserializationFailed", "typeName": string | null, "message": string | null }] }`. `warnings` remains the presentation-oriented recovery summary; `recoveryIncidents` exposes the same incidents as stable structured data. `sceneId` and `sceneName` identify the containing scene when different scenes contain the same relative element path. `elementFile` is a forward-slash scene-relative path when available, otherwise the element name. `typeName` is the original serialized discriminator when available, otherwise `null`; `message` is the unmodified nullable deserialization error rather than a presentation fallback.
- **Errors**: `media_not_found`, `schema_version_mismatch` (project written by an incompatible schema — surfaced, not silently dropped, per FR-031/FR-013).
### `attach_active_editor` *(in-app host only)*
diff --git a/src/Beutl.AgentToolkit/Common/PathBoundary.cs b/src/Beutl.AgentToolkit/Common/PathBoundary.cs
deleted file mode 100644
index 6ed5a9bee4..0000000000
--- a/src/Beutl.AgentToolkit/Common/PathBoundary.cs
+++ /dev/null
@@ -1,147 +0,0 @@
-namespace Beutl.AgentToolkit.Common;
-
-// Symlink-aware path resolution shared by the workspace guard and the installer so a textual
-// boundary check cannot be bypassed by a symlink/junction under the allowed root.
-public static class PathBoundary
-{
- // The deepest existing ancestor of with symlinks resolved to their
- // final target, then the not-yet-existing remainder re-appended. A boundary check on the result
- // therefore sees through a symlinked parent instead of trusting the textual path.
- public static string ResolveDeepestExistingTarget(string absolute)
- {
- string? current = absolute;
- var remainder = new Stack();
-
- // Stop at the deepest node that exists, including a broken symlink (missing target ⇒
- // File/Directory.Exists both false); skipping it would boundary-check the textual path and
- // let a write follow the link outside the root.
- while (!string.IsNullOrEmpty(current) && !PathEntryExists(current))
- {
- string? name = Path.GetFileName(current);
- if (!string.IsNullOrEmpty(name))
- {
- remainder.Push(name);
- }
-
- current = Path.GetDirectoryName(current);
- }
-
- if (string.IsNullOrEmpty(current))
- {
- return absolute;
- }
-
- string resolved = ResolveExistingPath(current);
- while (remainder.Count > 0)
- {
- resolved = Path.Combine(resolved, remainder.Pop());
- }
-
- return Path.GetFullPath(resolved);
- }
-
- public static string ResolveExistingPath(string path)
- {
- if (!Path.IsPathRooted(path))
- {
- path = Path.GetFullPath(path);
- }
-
- // Resolve every existing component, not just the leaf: an intermediate symlinked directory
- // (e.g. /workspace/link -> /outside) whose leaf exists resolves to the textual in-root path
- // under a leaf-only check, and the boundary check would accept it while a write follows the
- // link outside the root. Walk component by component, following each symlink to its final
- // target, so the result is the real path the filesystem would write to.
- string root = Path.GetPathRoot(path) ?? path;
- var components = new Stack();
- string? current = path;
- while (current is not null && current.Length >= root.Length && !string.Equals(current, root, PathComparison.ForCurrentPlatform))
- {
- string name = Path.GetFileName(current);
- if (!string.IsNullOrEmpty(name))
- {
- components.Push(name);
- }
-
- current = Path.GetDirectoryName(current);
- if (current is null)
- {
- break;
- }
- }
-
- string resolved = root;
- while (components.Count > 0)
- {
- string name = components.Pop();
- string candidate = Path.Combine(resolved, name);
- if (TryResolveLinkTarget(candidate, out string? target) && target is not null)
- {
- // Symlink targets may be relative to the link's own directory; resolve them there
- // rather than against the process working directory.
- resolved = Path.IsPathRooted(target)
- ? Path.GetFullPath(target)
- : Path.GetFullPath(Path.Combine(resolved, target));
- }
- else
- {
- resolved = Path.GetFullPath(candidate);
- }
- }
-
- return resolved;
- }
-
- // Path.Exists follows symlinks; a broken link is missing to it. Query the link entry directly so
- // a dangling symlink still counts as an existing node.
- private static bool PathEntryExists(string path)
- => Path.Exists(path) || new FileInfo(path).LinkTarget is not null;
-
- private static FileSystemInfo? TryResolveLinkTarget(FileSystemInfo info)
- {
- try
- {
- return info.ResolveLinkTarget(returnFinalTarget: true);
- }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException)
- {
- // Reaching here means info IS a link (a non-link returns null without throwing) whose
- // final target could not be followed — broken, permission-denied, or unsupported. Never
- // propagate (this runs in workspace-boundary/installer checks) and never fall back to the
- // link's own in-root path, which would fail OPEN and hide a symlink escape. Instead read
- // the immediate link-target metadata (no filesystem walk) and boundary-check the REAL
- // destination, resolving a relative target against the link's own directory.
- try
- {
- string? immediateTarget = info.LinkTarget;
- string? baseDirectory = Path.GetDirectoryName(info.FullName);
- if (immediateTarget is null || baseDirectory is null)
- {
- return null;
- }
-
- string absoluteTarget = Path.IsPathRooted(immediateTarget)
- ? Path.GetFullPath(immediateTarget)
- : Path.GetFullPath(Path.Combine(baseDirectory, immediateTarget));
- return Directory.Exists(absoluteTarget)
- ? new DirectoryInfo(absoluteTarget)
- : new FileInfo(absoluteTarget);
- }
- catch (Exception fallbackEx) when (fallbackEx is IOException or UnauthorizedAccessException or NotSupportedException or ArgumentException)
- {
- // The fallback itself must never propagate (this runs in boundary/installer checks);
- // if the link metadata cannot be read, treat it as a non-link and let the caller
- // boundary-check the textual path.
- return null;
- }
- }
- }
-
- private static bool TryResolveLinkTarget(string path, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? target)
- {
- FileSystemInfo info = Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path);
- FileSystemInfo? resolved = TryResolveLinkTarget(info);
- target = resolved?.FullName;
- return target is not null;
- }
-}
diff --git a/src/Beutl.AgentToolkit/Common/PathComparison.cs b/src/Beutl.AgentToolkit/Common/PathComparison.cs
deleted file mode 100644
index b50dec21e8..0000000000
--- a/src/Beutl.AgentToolkit/Common/PathComparison.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Beutl.AgentToolkit.Common;
-
-public static class PathComparison
-{
- // Linux file systems are case-sensitive; Windows and the default macOS volume are not.
- public static StringComparison ForCurrentPlatform =>
- OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase;
-}
diff --git a/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs b/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs
index 487ad7f528..a8ee7a6758 100644
--- a/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs
+++ b/src/Beutl.AgentToolkit/Documents/DeclarativeDocumentApplier.cs
@@ -220,7 +220,11 @@ private void ApplyKeyFrame(IKeyFrame keyFrame, JsonObject desired)
if (desired.TryGetPropertyValue(nameof(KeyFrame.Easing), out JsonNode? easingNode) && easingNode is not null)
{
- keyFrame.Easing = DeserializeEasing(easingNode);
+ Easing desiredEasing = DeserializeEasing(easingNode);
+ if (!AreEquivalentEasings(keyFrame.Easing, desiredEasing))
+ {
+ keyFrame.Easing = desiredEasing;
+ }
}
if (desired.TryGetPropertyValue(nameof(KeyFrame.KeyTime), out JsonNode? keyTimeNode) && keyTimeNode is not null)
@@ -823,6 +827,21 @@ private static Easing DeserializeEasing(JsonNode node)
"Easing must be a type string or spline object."));
}
+ private static bool AreEquivalentEasings(Easing current, Easing desired)
+ {
+ if (current.GetType() != desired.GetType())
+ {
+ return false;
+ }
+
+ return current is not SplineEasing currentSpline
+ || desired is SplineEasing desiredSpline
+ && currentSpline.X1 == desiredSpline.X1
+ && currentSpline.Y1 == desiredSpline.Y1
+ && currentSpline.X2 == desiredSpline.X2
+ && currentSpline.Y2 == desiredSpline.Y2;
+ }
+
internal static string? ValidateEasingNode(JsonNode? node)
{
if (node is JsonValue value && value.TryGetValue(out string? typeName))
diff --git a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs
index 23c895ea05..4692e10e95 100644
--- a/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs
+++ b/src/Beutl.AgentToolkit/Reconciliation/Reconciler.cs
@@ -330,6 +330,7 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I
{
JsonObject desiredDocument = PrepareDesired(session, desired);
ReconcilePlan plan = PlanPrepared(session, desiredDocument, knownNewIds);
+ Element[] affectedSuppressedElements = GetAffectedSuppressedElements(session.Root, plan);
session.History.ExecuteInTransaction(
() =>
{
@@ -338,6 +339,20 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I
{
ProjectOperations.NormalizeSidecarUrisWithinProject(scene);
}
+
+ foreach (Element element in affectedSuppressedElements)
+ {
+ if (Scene.TryResumeElementPersistence(element) is { } suppression)
+ {
+ session.History.Record(
+ () => element.SuppressedStorageSource = null,
+ () =>
+ {
+ suppression.WasReinstated = true;
+ element.SuppressedStorageSource = suppression;
+ });
+ }
+ }
},
"Agent edit");
@@ -349,6 +364,49 @@ private ReconcileResult ApplyCore(IEditingSession session, JsonObject desired, I
return new ReconcileResult(plan, session.Documents.Read(session.Root));
}
+ private static Element[] GetAffectedSuppressedElements(CoreObject root, ReconcilePlan plan)
+ {
+ if (plan.Changes.Count == 0)
+ {
+ return [];
+ }
+
+ if (root is Element { SuppressedStorageSource: not null } element)
+ {
+ return [element];
+ }
+
+ if (root is not Scene scene)
+ {
+ return [];
+ }
+
+ var affectedIds = new HashSet();
+ const string pathPrefix = "$/Elements[Id=";
+ foreach (var change in plan.Changes)
+ {
+ if (Guid.TryParse(change.TargetId, out Guid targetId))
+ {
+ affectedIds.Add(targetId);
+ }
+
+ if (change.Path.StartsWith(pathPrefix, StringComparison.Ordinal))
+ {
+ int end = change.Path.IndexOf(']', pathPrefix.Length);
+ if (end >= 0
+ && Guid.TryParse(change.Path.AsSpan(pathPrefix.Length, end - pathPrefix.Length), out Guid pathId))
+ {
+ affectedIds.Add(pathId);
+ }
+ }
+ }
+
+ return scene.Children
+ .Where(static child => child.SuppressedStorageSource is not null)
+ .Where(child => affectedIds.Contains(child.Id))
+ .ToArray();
+ }
+
// Build the plan on the editor's dispatcher: PlanPrepared reads session.Documents/Root, so off
// the MCP request thread it would race the live scene the editor mutates on the UI thread.
public ReconcilePlan PlanFromCurrent(
@@ -643,8 +701,8 @@ private static void ValidateSceneInvariants(CoreObject sandboxRoot, List existingFallbackIds = CollectFallbackIds(session.Root);
- if (FindFirstNewFallback(sandboxRoot, "$", existingFallbackIds) is { } occurrence)
+ Dictionary existingFallbacks = CollectFallbackIdentities(session.Root);
+ if (FindFirstNewFallback(sandboxRoot, "$", existingFallbacks) is { } occurrence)
{
string typeDetail = string.IsNullOrWhiteSpace(occurrence.FallbackTypeName)
? "unknown serialized type"
@@ -678,139 +736,61 @@ private static CoreObject CloneCurrentRoot(IEditingSession session, JsonObject c
return clone;
}
- private static HashSet CollectFallbackIds(CoreObject root)
+ private static Dictionary CollectFallbackIdentities(CoreObject root)
{
- var ids = new HashSet();
- if (root is IHierarchical hierarchical)
+ var identities = new Dictionary();
+ SerializedGraphTraversal.Visit(root, "$", (node, _) =>
{
- foreach (IFallback fallback in hierarchical.EnumerateAllChildren())
+ if (node is IFallback fallback)
{
- if (fallback is CoreObject coreObject)
- {
- ids.Add(coreObject.Id);
- }
+ FallbackIdentity identity = CreateFallbackIdentity(fallback);
+ identities[identity] = identities.GetValueOrDefault(identity) + 1;
}
- }
- if (root is IFallback rootFallback)
- {
- ids.Add(((CoreObject)rootFallback).Id);
- }
+ return false;
+ });
- return ids;
+ return identities;
}
private static FallbackOccurrence? FindFirstNewFallback(
CoreObject root,
string path,
- HashSet existingFallbackIds)
- {
- var visited = new HashSet();
- return FindFirstNewFallbackCore(root, path, existingFallbackIds, visited);
- }
-
- private static FallbackOccurrence? FindFirstNewFallbackCore(
- CoreObject node,
- string path,
- HashSet existingFallbackIds,
- HashSet visited)
+ Dictionary existingFallbacks)
{
- if (!visited.Add(node.Id))
+ FallbackOccurrence? result = null;
+ SerializedGraphTraversal.Visit(root, path, (node, nodePath) =>
{
- return null;
- }
+ if (node is not IFallback fallback)
+ {
+ return false;
+ }
+
+ FallbackIdentity identity = CreateFallbackIdentity(fallback);
+ if (existingFallbacks.TryGetValue(identity, out int remaining) && remaining > 0)
+ {
+ existingFallbacks[identity] = remaining - 1;
+ return false;
+ }
- if (node is IFallback fallback && !existingFallbackIds.Contains(node.Id))
- {
fallback.TryGetTypeName(out string? fallbackTypeName);
- return new FallbackOccurrence(
- path,
- node.Id,
+ result = new FallbackOccurrence(
+ nodePath,
fallbackTypeName,
fallback.Reason.ToString(),
fallback.ErrorMessage);
- }
-
- switch (node)
- {
- case Scene scene:
- for (int i = 0; i < scene.Children.Count; i++)
- {
- if (FindFirstNewFallbackCore(
- scene.Children[i],
- $"{path}/Elements[{i}]",
- existingFallbackIds,
- visited) is { } occurrence)
- {
- return occurrence;
- }
- }
- break;
-
- case Element element:
- for (int i = 0; i < element.Objects.Count; i++)
- {
- if (FindFirstNewFallbackCore(
- element.Objects[i],
- $"{path}/Objects[{i}]",
- existingFallbackIds,
- visited) is { } occurrence)
- {
- return occurrence;
- }
- }
- break;
-
- case EngineObject engineObject:
- foreach (IProperty property in engineObject.Properties)
- {
- if (FindFirstNewFallbackInValue(
- property.CurrentValue,
- $"{path}/{property.Name}",
- existingFallbackIds,
- visited) is { } occurrence)
- {
- return occurrence;
- }
- }
- break;
- }
-
- return null;
+ return true;
+ });
+ return result;
}
- private static FallbackOccurrence? FindFirstNewFallbackInValue(
- object? value,
- string path,
- HashSet existingFallbackIds,
- HashSet visited)
+ private static FallbackIdentity CreateFallbackIdentity(IFallback fallback)
{
- switch (value)
- {
- case CoreObject coreObject:
- return FindFirstNewFallbackCore(coreObject, path, existingFallbackIds, visited);
- case IEnumerable enumerable when value is not string:
- {
- int index = 0;
- foreach (object? item in enumerable)
- {
- if (FindFirstNewFallbackInValue(
- item,
- $"{path}[{index}]",
- existingFallbackIds,
- visited) is { } occurrence)
- {
- return occurrence;
- }
-
- index++;
- }
-
- break;
- }
- }
-
- return null;
+ return fallback is CoreObject { Id: var id } && id != Guid.Empty
+ ? new FallbackIdentity(id, null)
+ : new FallbackIdentity(
+ null,
+ $"{fallback.GetType().AssemblyQualifiedName}|{fallback.Json?.ToJsonString()}");
}
private static string CreateFallbackHint(FallbackOccurrence occurrence)
@@ -1242,9 +1222,10 @@ private static bool JsonEquals(JsonNode? left, JsonNode? right)
return left.ToJsonString() == right.ToJsonString();
}
+ private readonly record struct FallbackIdentity(Guid? Id, string? Signature);
+
private sealed record FallbackOccurrence(
string Path,
- Guid Id,
string? FallbackTypeName,
string Reason,
string? Message);
diff --git a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs
index ae34c11e19..bce05ce0d5 100644
--- a/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs
+++ b/src/Beutl.AgentToolkit/Sessions/FileEditingSession.cs
@@ -140,7 +140,7 @@ private void SetProjectPathCore(string projectPath)
// directory unique to the new project (its file name); regenerating from the scene name
// alone would collide with — and overwrite — the source project's .scene/.belm files when
// both projects live in the same folder.
- var usedDirs = new HashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform));
+ var usedDirs = new HashSet(StringComparer.FromComparison(PathBoundary.Comparison));
int index = 1;
foreach (Scene scene in Project.Items.OfType())
{
@@ -153,10 +153,39 @@ private void SetProjectPathCore(string projectPath)
Path.Combine(projectDirectory, projectName),
sceneName,
usedDirs);
+ string? previousSceneDirectory = scene.Uri is { IsFile: true } previousSceneUri
+ ? Path.GetDirectoryName(previousSceneUri.LocalPath)
+ : null;
scene.Uri = new Uri(scenePath);
+ string sceneDirectory = Path.GetDirectoryName(scenePath)!;
+ var assignedElementPaths = new HashSet(
+ StringComparer.FromComparison(PathBoundary.Comparison));
foreach (Element element in scene.Children)
{
- element.Uri = null;
+ // Keep each sidecar's relative path across Save As: a recovered element's stable
+ // fallback identity is derived from its scene-relative path, so a regenerated
+ // path would change the element's Id when the copy is reopened.
+ if (element.Uri is { IsFile: true } previousUri)
+ {
+ string relativePath = previousSceneDirectory != null
+ ? Path.GetRelativePath(previousSceneDirectory, previousUri.LocalPath)
+ : Path.GetFileName(previousUri.LocalPath);
+ string sceneRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sceneDirectory));
+ string resolvedPath = Path.GetFullPath(Path.Combine(sceneRoot, relativePath));
+ if (!resolvedPath.StartsWith(
+ sceneRoot + Path.DirectorySeparatorChar,
+ PathBoundary.Comparison))
+ {
+ resolvedPath = Path.Combine(sceneRoot, Path.GetFileName(previousUri.LocalPath));
+ }
+
+ resolvedPath = ReserveUniqueElementPath(resolvedPath, assignedElementPaths);
+ element.Uri = new Uri(resolvedPath);
+ }
+ else
+ {
+ element.Uri = null;
+ }
}
index++;
@@ -165,6 +194,20 @@ private void SetProjectPathCore(string projectPath)
AcceptExternalStamp();
}
+ private static string ReserveUniqueElementPath(string path, ISet assignedPaths)
+ {
+ string candidate = path;
+ string directory = Path.GetDirectoryName(path)!;
+ string name = Path.GetFileNameWithoutExtension(path);
+ string extension = Path.GetExtension(path);
+ for (int suffix = 2; !assignedPaths.Add(candidate); suffix++)
+ {
+ candidate = Path.Combine(directory, $"{name}-{suffix}{extension}");
+ }
+
+ return candidate;
+ }
+
// Save As rehomes the project/scene/element URIs and then writes; run capture → rehome → save —
// and the restore on failure — as one critical section so a concurrent Invoke cannot mutate the
// graph between the steps or observe a half-captured/half-restored URI set.
@@ -195,16 +238,38 @@ internal void SaveAs(string projectPath, bool skipConflictCheck)
private UriState CaptureUriState()
{
- return new UriState(ProjectOperations.CaptureUriState(Project), _projectLastWriteUtc);
+ var suppressions = Project.Items.OfType()
+ .SelectMany(static scene => scene.Children)
+ .Where(static element => element.SuppressedStorageSource is not null)
+ .Select(static element => (
+ Element: element,
+ Source: element.SuppressedStorageSource!,
+ element.SuppressedStorageSource!.WasReinstated))
+ .ToArray();
+ return new UriState(
+ ProjectOperations.CaptureUriState(Project),
+ suppressions,
+ _projectLastWriteUtc);
}
private void RestoreUriState(UriState state)
{
ProjectOperations.RestoreUriState(Project, state.Uris);
+ foreach ((Element element, SuppressedStorageSource source, bool wasReinstated) in state.Suppressions)
+ {
+ if (ReferenceEquals(element.SuppressedStorageSource, source))
+ {
+ source.WasReinstated = wasReinstated;
+ }
+ }
+
_projectLastWriteUtc = state.Stamp;
}
- private sealed record UriState(ProjectUriState Uris, DateTime Stamp);
+ private sealed record UriState(
+ ProjectUriState Uris,
+ IReadOnlyList<(Element Element, SuppressedStorageSource Source, bool WasReinstated)> Suppressions,
+ DateTime Stamp);
public void MarkDirty()
{
diff --git a/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs b/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs
index 4b1c462ada..e1d8826225 100644
--- a/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs
+++ b/src/Beutl.AgentToolkit/Sessions/ProjectOperations.cs
@@ -41,7 +41,7 @@ public static Project CreateProject(ProjectCreateOptions options)
string scenePath = ReserveUniqueScenePath(
projectDirectory,
projectName,
- new HashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform)));
+ new HashSet(StringComparer.FromComparison(PathBoundary.Comparison)));
var scene = new Scene(options.Width, options.Height, projectName)
{
@@ -161,7 +161,7 @@ public static void Save(Project project)
private static void NullDuplicateSidecarUris(Project project)
{
- var used = new HashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform));
+ var used = new HashSet(StringComparer.FromComparison(PathBoundary.Comparison));
foreach (Scene scene in project.Items.OfType())
{
if (scene.Uri is not null && !used.Add(ResolveSidecarPath(scene.Uri)))
@@ -184,21 +184,53 @@ private static string ResolveSidecarPath(Uri uri)
return PathBoundary.ResolveDeepestExistingTarget(Path.GetFullPath(uri.LocalPath));
}
- // A project loaded from disk can carry scene/element sidecar URIs pointing outside the project
- // directory (hand-edited or malicious). StoreToUri writes each referenced sidecar to its own Uri,
- // so drop any that escape the project tree and let the Ensure* helpers regenerate them inside it.
private static void RehomeSidecarsOutsideProject(Project project, string projectDirectory, Scene scene)
{
+ string? previousSceneDirectory = scene.Uri is { IsFile: true } previousSceneUri
+ ? Path.GetDirectoryName(previousSceneUri.LocalPath)
+ : null;
if (scene.Uri is not null && !IsInsideDirectory(projectDirectory, scene.Uri.LocalPath))
{
scene.Uri = null;
+ EnsureSceneUri(project, scene);
}
+ if (scene.Uri is null)
+ {
+ return;
+ }
+
+ string sceneDirectory = Path.GetDirectoryName(scene.Uri.LocalPath)
+ ?? throw new InvalidOperationException("Scene Uri must have a directory.");
+ var usedPaths = scene.Children
+ .Where(element => element.Uri is not null
+ && IsInsideDirectory(projectDirectory, element.Uri.LocalPath))
+ .Select(element => ResolveSidecarPath(element.Uri!))
+ .ToHashSet(StringComparer.FromComparison(PathBoundary.Comparison));
foreach (Element element in scene.Children)
{
if (element.Uri is not null && !IsInsideDirectory(projectDirectory, element.Uri.LocalPath))
{
- element.Uri = null;
+ string relativePath = previousSceneDirectory is not null
+ ? Path.GetRelativePath(previousSceneDirectory, element.Uri.LocalPath)
+ : Path.GetFileName(element.Uri.LocalPath);
+ string candidate = Path.GetFullPath(Path.Combine(sceneDirectory, relativePath));
+ if (!IsInsideDirectory(sceneDirectory, candidate))
+ {
+ candidate = Path.Combine(sceneDirectory, Path.GetFileName(element.Uri.LocalPath));
+ }
+
+ string uniqueCandidate = candidate;
+ string name = Path.GetFileNameWithoutExtension(candidate);
+ string extension = Path.GetExtension(candidate);
+ for (int suffix = 2; !usedPaths.Add(ResolveSidecarPath(new Uri(uniqueCandidate))); suffix++)
+ {
+ uniqueCandidate = Path.Combine(
+ Path.GetDirectoryName(candidate)!,
+ $"{name}-{suffix}{extension}");
+ }
+
+ element.Uri = CreateFileUri(uniqueCandidate);
}
}
}
@@ -211,7 +243,7 @@ private static bool IsInsideDirectory(string directory, string candidate)
string root = Path.TrimEndingDirectorySeparator(
PathBoundary.ResolveDeepestExistingTarget(Path.GetFullPath(directory)));
string full = PathBoundary.ResolveDeepestExistingTarget(Path.GetFullPath(candidate));
- StringComparison comparison = PathComparison.ForCurrentPlatform;
+ StringComparison comparison = PathBoundary.Comparison;
return full.StartsWith(root + Path.DirectorySeparatorChar, comparison)
|| string.Equals(full, root, comparison);
}
@@ -293,7 +325,7 @@ private static Uri DeriveUniqueSceneUri(Project project, string projectDirectory
.Select(item => Path.GetDirectoryName(item.Uri!.LocalPath))
.Where(dir => dir is not null)
.Select(dir => Path.GetFullPath(dir!))
- .ToHashSet(StringComparer.FromComparison(PathComparison.ForCurrentPlatform));
+ .ToHashSet(StringComparer.FromComparison(PathBoundary.Comparison));
return CreateFileUri(ReserveUniqueScenePath(projectDirectory, sceneName, used));
}
diff --git a/src/Beutl.AgentToolkit/Tools/SessionTools.cs b/src/Beutl.AgentToolkit/Tools/SessionTools.cs
index 76762f4af6..a1c9fe20f4 100644
--- a/src/Beutl.AgentToolkit/Tools/SessionTools.cs
+++ b/src/Beutl.AgentToolkit/Tools/SessionTools.cs
@@ -1,12 +1,16 @@
-using System.ComponentModel;
+using System.Collections;
+using System.ComponentModel;
using System.Globalization;
using Beutl.AgentToolkit.Common;
using Beutl.AgentToolkit.Reconciliation;
using Beutl.AgentToolkit.Rendering;
using Beutl.AgentToolkit.Sessions;
using Beutl.AgentToolkit.Workspace;
+using Beutl.Animation;
using Beutl.Editor;
+using Beutl.Engine;
using Beutl.ProjectSystem;
+using Beutl.Serialization;
using ModelContextProtocol.Server;
namespace Beutl.AgentToolkit.Tools;
@@ -15,7 +19,20 @@ public sealed record SceneSummary(string SceneId, string Name, int Width, int He
public sealed record SessionSummary(IReadOnlyList Scenes);
-public sealed record OpenProjectResponse(string Session, string Source, SessionSummary Summary);
+public sealed record RecoveryIncident(
+ string SceneId,
+ string SceneName,
+ string ElementFile,
+ string Reason,
+ string? TypeName,
+ string? Message);
+
+public sealed record OpenProjectResponse(string Session, string Source, SessionSummary Summary)
+{
+ public IReadOnlyList Warnings { get; init; } = [];
+
+ public IReadOnlyList RecoveryIncidents { get; init; } = [];
+}
public sealed record CreateProjectResponse(string Session, string SavedPath, SessionSummary Summary);
@@ -68,13 +85,95 @@ public ValueTask> OpenProject(string path, Cance
}
ProjectSessionResult result = await projects.OpenProjectAsync(fullPath, cancellationToken).ConfigureAwait(false);
+ DeserializationWarningCollection recovery = result.Session.ReadOnSession(
+ () => CollectDeserializationWarnings(result.Project));
return new OpenProjectResponse(
result.Session.SessionId,
result.Session.Source.ToString(),
- CreateSummary(result.Session, result.Project));
+ CreateSummary(result.Session, result.Project))
+ {
+ Warnings = recovery.Warnings,
+ RecoveryIncidents = recovery.RecoveryIncidents,
+ };
});
}
+ private static DeserializationWarningCollection CollectDeserializationWarnings(Project project)
+ {
+ var warnings = new List();
+ var incidents = new List();
+ foreach (Scene scene in project.Items.OfType())
+ {
+ foreach (Element element in scene.Children)
+ {
+ IFallback[] fallbacks = SerializedGraphTraversal.Enumerate(element)
+ .OfType()
+ .ToArray();
+
+ string elementFile = element.Uri is { IsFile: true } uri
+ && scene.Uri is { IsFile: true } sceneUri
+ ? Path.GetRelativePath(
+ Path.GetDirectoryName(sceneUri.LocalPath)!,
+ uri.LocalPath).Replace('\\', '/')
+ : element.Name;
+ foreach (IFallback fallback in fallbacks)
+ {
+ fallback.TryGetTypeName(out string? typeName);
+ if (string.Equals(
+ typeName,
+ IdentityHelper.WriteDiscriminator(fallback.GetType()),
+ StringComparison.Ordinal))
+ {
+ typeName = null;
+ }
+
+ incidents.Add(new RecoveryIncident(
+ scene.Id.ToString(),
+ scene.Name,
+ elementFile,
+ fallback.Reason.ToString(),
+ typeName,
+ fallback.ErrorMessage));
+ string error = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
+ ? fallback.Reason.ToString()
+ : fallback.ErrorMessage;
+ warnings.Add(
+ $"Element file '{elementFile}' contains content that could not be deserialized: {error}");
+ }
+
+ if (element.SuppressedStorageSource is { HasNonFallbackIncidents: true } source)
+ {
+ SuppressedRecoveryIncident[] recoveryIncidents
+ = source.RecoveryIncidents is { Length: > 0 } details
+ ? details
+ : [new SuppressedRecoveryIncident(
+ nameof(FallbackReason.DeserializationFailed),
+ null,
+ "A value was replaced during load, and the original element file is preserved.")];
+ foreach (SuppressedRecoveryIncident recoveryIncident in recoveryIncidents)
+ {
+ incidents.Add(new RecoveryIncident(
+ scene.Id.ToString(),
+ scene.Name,
+ elementFile,
+ recoveryIncident.Reason,
+ recoveryIncident.TypeName,
+ recoveryIncident.Message));
+ string message = recoveryIncident.Message ?? recoveryIncident.Reason;
+ warnings.Add(
+ $"Element file '{elementFile}' had a value replaced during load: {message}");
+ }
+ }
+ }
+ }
+
+ return new DeserializationWarningCollection(warnings, incidents);
+ }
+
+ private sealed record DeserializationWarningCollection(
+ IReadOnlyList Warnings,
+ IReadOnlyList RecoveryIncidents);
+
[McpServerTool(Name = "create_project")]
[Description("Creates and saves a new Beutl .bep project with one scene, then makes it the active editing session. In the in-app host the project opens in the Beutl editor (single open project, LiveEditor session); in the stdio host it becomes a file-backed session. Paths without an extension are saved as .bep; .beutl is reserved for project packages. The output path is restricted to BEUTL_WORKSPACE.")]
public ValueTask> CreateProject(
@@ -178,7 +277,7 @@ public ToolResult SaveProject(
{
string writePath = NormalizeProjectPath(workspace, path, nameof(path));
string currentPath = fileSession.Project.Uri?.LocalPath ?? string.Empty;
- if (!string.Equals(Path.GetFullPath(currentPath), Path.GetFullPath(writePath), PathComparison.ForCurrentPlatform))
+ if (!string.Equals(Path.GetFullPath(currentPath), Path.GetFullPath(writePath), PathBoundary.Comparison))
{
destructiveGuard.EnsureOverwriteAllowed(writePath, confirmOverwrite);
fileSession.SaveAs(writePath, skipConflictCheck: confirmOverwrite);
diff --git a/src/Beutl.Core/CoreObject.cs b/src/Beutl.Core/CoreObject.cs
index 3818b1637b..679b2feb10 100644
--- a/src/Beutl.Core/CoreObject.cs
+++ b/src/Beutl.Core/CoreObject.cs
@@ -74,6 +74,10 @@ public string Name
public Uri? Uri { get; set; }
+ // Non-null while this object stands in for a file the serializer must not regenerate:
+ // StoreToUri skips the source location and copies the raw text verbatim to any new one.
+ internal SuppressedStorageSource? SuppressedStorageSource { get; set; }
+
private Dictionary Values => _values ??= [];
private Dictionary Errors => _errors ??= [];
@@ -184,6 +188,26 @@ private void ValidateProperty(
}
public void SetValue(CoreProperty property, TValue? value)
+ {
+ SetValueCore(property, value, forceReferenceReplacement: false);
+ }
+
+ internal void ReplaceValue(CoreProperty property, TValue? value)
+ {
+ SetValueCore(property, value, forceReferenceReplacement: true);
+ }
+
+ internal void ReplaceValue(CoreProperty property, object? value)
+ {
+ ArgumentNullException.ThrowIfNull(property);
+
+ property.RouteReplaceValue(this, value);
+ }
+
+ private void SetValueCore(
+ CoreProperty property,
+ TValue? value,
+ bool forceReferenceReplacement)
{
if (value != null && !value.GetType().IsAssignableTo(property.PropertyType))
{
@@ -211,7 +235,7 @@ public void SetValue(CoreProperty property, TValue? value)
oldEntry is Entry entryT)
{
TValue? oldValue = entryT.Value;
- if (!EqualityComparer.Default.Equals(oldValue, value))
+ if (ValueReplacement.RequiresReplacement(oldValue, value, forceReferenceReplacement))
{
entryT.Value = value;
RaisePropertyChanged(property, metadata, value, oldValue);
@@ -219,7 +243,7 @@ public void SetValue(CoreProperty property, TValue? value)
}
else
{
- if (!EqualityComparer.Default.Equals(metadata.DefaultValue, value))
+ if (ValueReplacement.RequiresReplacement(metadata.DefaultValue, value, forceReferenceReplacement))
{
entryT = new Entry { Value = value, };
Values[property.Id] = entryT;
@@ -270,11 +294,20 @@ protected virtual void OnPropertyChanged(PropertyChangedEventArgs args)
}
protected bool SetAndRaise(CoreProperty property, ref T field, T value)
+ {
+ return SetAndRaise(property, ref field, value, forceReferenceReplacement: false);
+ }
+
+ protected bool SetAndRaise(
+ CoreProperty property,
+ ref T field,
+ T value,
+ bool forceReferenceReplacement)
{
CorePropertyMetadata? metadata = property.GetMetadata>(GetType());
ValidateProperty(metadata, property, ref value!);
- bool result = !EqualityComparer.Default.Equals(field, value);
+ bool result = ValueReplacement.RequiresReplacement(field, value, forceReferenceReplacement);
if (result)
{
T old = field;
diff --git a/src/Beutl.Core/CoreProperty.cs b/src/Beutl.Core/CoreProperty.cs
index ea6eca54a4..4eb9d69246 100644
--- a/src/Beutl.Core/CoreProperty.cs
+++ b/src/Beutl.Core/CoreProperty.cs
@@ -70,6 +70,8 @@ protected CoreProperty(
internal abstract void RouteSetValue(ICoreObject o, object? value);
+ internal abstract void RouteReplaceValue(CoreObject o, object? value);
+
internal abstract object? RouteGetValue(ICoreObject o);
internal abstract void NotifyChanged(CorePropertyChangedEventArgs e);
@@ -252,6 +254,18 @@ internal override void RouteSetValue(ICoreObject o, object? value)
}
}
+ internal override void RouteReplaceValue(CoreObject o, object? value)
+ {
+ if (value is T typed)
+ {
+ o.ReplaceValue(this, typed);
+ }
+ else
+ {
+ o.ReplaceValue(this, default);
+ }
+ }
+
internal override object? RouteGetValue(ICoreObject o)
{
return o.GetValue(this);
diff --git a/src/Beutl.Core/OptionalJsonConverter.cs b/src/Beutl.Core/OptionalJsonConverter.cs
index 725dd974a5..2a4e884940 100644
--- a/src/Beutl.Core/OptionalJsonConverter.cs
+++ b/src/Beutl.Core/OptionalJsonConverter.cs
@@ -33,7 +33,9 @@ public override bool CanConvert(Type typeToConvert)
goto Return;
}
- instance = JsonSerializer.Deserialize(jsonNode, valueType, options);
+ instance = typeof(ICoreSerializable).IsAssignableFrom(valueType)
+ ? CoreSerializer.DeserializeFromJsonNode(jsonNode, valueType)
+ : JsonSerializer.Deserialize(jsonNode, valueType, options);
Return:
var o = (IOptional?)Activator.CreateInstance(typeToConvert, instance);
diff --git a/src/Beutl.Core/Properties/AssemblyInfo.cs b/src/Beutl.Core/Properties/AssemblyInfo.cs
index ee38697b06..0891fc21df 100644
--- a/src/Beutl.Core/Properties/AssemblyInfo.cs
+++ b/src/Beutl.Core/Properties/AssemblyInfo.cs
@@ -13,3 +13,5 @@
[assembly: InternalsVisibleTo("Beutl.FFmpegWorker")]
[assembly: InternalsVisibleTo("Beutl.FFmpegWorker.Tests")]
[assembly: InternalsVisibleTo("Beutl.AgentToolkit.Mcp")]
+[assembly: InternalsVisibleTo("Beutl.AgentToolkit")]
+[assembly: InternalsVisibleTo("Beutl.AgentToolkit.Tests")]
diff --git a/src/Beutl.Core/ReferenceRewriting.cs b/src/Beutl.Core/ReferenceRewriting.cs
new file mode 100644
index 0000000000..66a91f1954
--- /dev/null
+++ b/src/Beutl.Core/ReferenceRewriting.cs
@@ -0,0 +1,36 @@
+namespace Beutl;
+
+///
+/// Provides recursive reference rewriting for values owned by an
+/// implementation.
+///
+public interface IReferenceRewriteContext
+{
+ ///
+ /// Rewrites references contained in while preserving its declared type.
+ ///
+ T Rewrite(T value);
+}
+
+///
+/// Represents a value that can rebuild itself after its contained references are rewritten.
+///
+///
+/// Implementations are responsible for preserving all non-reference state. The rewrite target must
+/// have the same runtime type as the source. It is memoized before population so aliases and cycles
+/// resolve to the same replacement instance.
+///
+public interface IReferenceRewritable
+{
+ ///
+ /// Creates the target that receives rewritten references. Implementations that mutate in place
+ /// may return ; rebuilding implementations return a shallow target whose
+ /// reference-bearing members can be populated by .
+ ///
+ IReferenceRewritable CreateReferenceRewriteTarget();
+
+ ///
+ /// Rewrites this target's reference-bearing members through .
+ ///
+ void RewriteReferences(IReferenceRewriteContext context);
+}
diff --git a/src/Beutl.Core/Serialization/CoreSerializer.cs b/src/Beutl.Core/Serialization/CoreSerializer.cs
index 961f3c66af..aea7948c4e 100644
--- a/src/Beutl.Core/Serialization/CoreSerializer.cs
+++ b/src/Beutl.Core/Serialization/CoreSerializer.cs
@@ -59,6 +59,9 @@ public static string ConvertToJsonString(JsonObject jsonNode)
public static object DeserializeFromJsonObject(JsonObject json, Type baseType, CoreSerializerOptions? options = null)
{
+ // A sealed baseType deliberately ignores any present discriminator: sealed wrapper types
+ // (e.g. Optional) legitimately carry the wrapped payload's $type on their own node and
+ // interpret it themselves during Deserialize.
Type? actualType = baseType.IsSealed ? baseType : json.GetDiscriminator(baseType);
if (actualType == null)
{
@@ -67,6 +70,12 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C
try
{
+ if (!baseType.IsAssignableFrom(actualType))
+ {
+ throw new InvalidCastException(
+ $"Discriminator type '{actualType}' is not assignable to the expected type '{baseType}'.");
+ }
+
var obj = Activator.CreateInstance(actualType) as ICoreSerializable
?? throw new InvalidOperationException($"Could not create instance of type {actualType.FullName}.");
@@ -83,6 +92,7 @@ public static object DeserializeFromJsonObject(JsonObject json, Type baseType, C
if (obj is IFallback fallbackObj)
{
fallbackObj.Reason = FallbackReason.TypeNotFound;
+ DeserializationIncidents.RecordFallback(fallbackObj);
}
return obj;
@@ -158,7 +168,10 @@ public static object RestoreFromUri(Uri uri, Type type)
// 互換性処理
// 1.x で作成されたファイルでは一部のオブジェクトに $type が付与されないため、
// 期待される型に基づいてディスクリミネータを補完する。
- if (!node.TryGetDiscriminator(out Type? _))
+ // Presence is checked on the property key alone: a present-but-unparsable or non-string
+ // discriminator must fail as an unknown type, not silently deserialize as the legacy
+ // default and overwrite the original data on the next save.
+ if (!jsonObject.ContainsKey("$type") && !jsonObject.ContainsKey("@type"))
{
if (type == typeof(ProjectItem))
{
@@ -170,12 +183,51 @@ public static object RestoreFromUri(Uri uri, Type type)
}
}
- Type? actualType = type.IsSealed ? type : jsonObject.GetDiscriminator(type);
+ bool hasDiscriminator = jsonObject.ContainsKey("$type") || jsonObject.ContainsKey("@type");
+ Type? actualType = hasDiscriminator
+ ? jsonObject.GetDiscriminator()
+ : type.IsSealed ? type : jsonObject.GetDiscriminator(type);
+ if (hasDiscriminator
+ && actualType == null
+ && FallbackDeserializationHelper.TryCreateFallback(type, null, jsonObject) is { } unknownTypeFallback)
+ {
+ ((IFallback)unknownTypeFallback).Reason = FallbackReason.TypeNotFound;
+ if (unknownTypeFallback is CoreObject coreObject)
+ {
+ coreObject.Uri = uri;
+ }
+
+ return unknownTypeFallback;
+ }
+
if (actualType == null)
{
throw new InvalidOperationException("Discriminator not found in JSON object.");
}
+ if (!type.IsAssignableFrom(actualType))
+ {
+ // Reject before instantiating: deserializing the declared type first would run its own
+ // load side effects (e.g. a Scene declared in a .belm globs and reopens element files).
+ var exception = new InvalidCastException(
+ $"Discriminator type '{actualType}' is not assignable to the expected type '{type}'.");
+ if (FallbackDeserializationHelper.TryCreateFallback(
+ type,
+ actualType,
+ jsonObject,
+ exception) is { } incompatibleTypeFallback)
+ {
+ if (incompatibleTypeFallback is CoreObject coreObject)
+ {
+ coreObject.Uri = uri;
+ }
+
+ return incompatibleTypeFallback;
+ }
+
+ throw exception;
+ }
+
try
{
var obj = Activator.CreateInstance(actualType) as ICoreSerializable
@@ -192,6 +244,7 @@ public static object RestoreFromUri(Uri uri, Type type)
if (obj is IFallback fallbackObj)
{
fallbackObj.Reason = FallbackReason.TypeNotFound;
+ DeserializationIncidents.RecordFallback(fallbackObj);
}
return obj;
@@ -227,6 +280,133 @@ public static void PopulateFromUri(ICoreSerializable obj, Type type, Uri uri)
public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = null)
where T : ICoreSerializable
{
+ StoreToUriCore(obj, uri, mode, authorizedRootPath: null);
+ }
+
+ internal static void StoreToUri(
+ T obj,
+ Uri uri,
+ string authorizedRootPath,
+ CoreSerializationMode? mode = null)
+ where T : ICoreSerializable
+ {
+ StoreToUriCore(obj, uri, mode, authorizedRootPath);
+ }
+
+ private static void StoreToUriCore(
+ T obj,
+ Uri uri,
+ CoreSerializationMode? mode,
+ string? authorizedRootPath)
+ where T : ICoreSerializable
+ {
+ if (obj is CoreObject { SuppressedStorageSource: { } suppressed } suppressedObj)
+ {
+ if (uri == suppressed.SourceUri)
+ {
+ // The source location is skip-protected only while the on-disk bytes still match
+ // the retained recovery bytes. A repair that was undone re-establishes the
+ // suppression record through history with WasReinstated set, and the retained
+ // bytes must be restored verbatim so the next open sees the same recovery state
+ // the undo recorded. A continuously held record treats a mismatch as an external
+ // repair of the sidecar and leaves the changed file alone — clobbering it would
+ // destroy the user's repair.
+ string sourcePath = uri.LocalPath;
+ RestoreReinstatedBytes(suppressed, sourcePath);
+ return;
+ }
+
+ if (suppressed.WasReinstated && uri == suppressedObj.Uri)
+ {
+ RestoreReinstatedBytes(suppressed, uri.LocalPath);
+ return;
+ }
+
+ if (uri.Scheme != "file")
+ {
+ throw new JsonException();
+ }
+
+ // Rehomed (save-as): the retained bytes move verbatim so the new project copy keeps the
+ // element. SourceUri stays unchanged so the source location remains skip-protected if a
+ // failed multi-file save rolls Uri back afterwards.
+ string rehomedPath = uri.LocalPath;
+ if (File.Exists(rehomedPath))
+ {
+ try
+ {
+ EnsureExistingBytesMatch(rehomedPath, suppressed.RawBytes);
+ }
+ catch
+ {
+ suppressedObj.Uri = suppressed.SourceUri;
+ throw;
+ }
+
+ suppressed.WasReinstated = false;
+ CopyReferencedStorageSources(suppressed, uri, authorizedRootPath);
+ suppressedObj.Uri = uri;
+ return;
+ }
+
+ CopyReferencedStorageSources(suppressed, uri, authorizedRootPath);
+
+ string? rehomedDirectory = Path.GetDirectoryName(rehomedPath);
+ if (rehomedDirectory != null)
+ {
+ Directory.CreateDirectory(rehomedDirectory);
+ }
+
+ string tempPath = $"{rehomedPath}.{Guid.NewGuid():N}.tmp";
+ try
+ {
+ using (var stream = new FileStream(
+ tempPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None))
+ {
+ stream.Write(suppressed.RawBytes);
+ stream.Flush(flushToDisk: true);
+ }
+
+ try
+ {
+ File.Move(tempPath, rehomedPath, overwrite: false);
+ }
+ catch (IOException) when (File.Exists(rehomedPath))
+ {
+ try
+ {
+ EnsureExistingBytesMatch(rehomedPath, suppressed.RawBytes);
+ }
+ catch
+ {
+ suppressedObj.Uri = suppressed.SourceUri;
+ throw;
+ }
+
+ suppressed.WasReinstated = false;
+ suppressedObj.Uri = uri;
+ return;
+ }
+ }
+ finally
+ {
+ try
+ {
+ File.Delete(tempPath);
+ }
+ catch
+ {
+ }
+ }
+
+ suppressed.WasReinstated = false;
+ suppressedObj.Uri = uri;
+ return;
+ }
+
if (uri.Scheme == "file")
{
if (obj is CoreObject coreObj)
@@ -279,4 +459,166 @@ public static void StoreToUri(T obj, Uri uri, CoreSerializationMode? mode = n
throw new JsonException();
}
}
+
+ private static void CopyReferencedStorageSources(
+ SuppressedStorageSource suppressed,
+ Uri rehomedUri,
+ string? authorizedRootPath)
+ {
+ if (suppressed.ReferencedStorageSources is not { Length: > 0 } referencedSources)
+ {
+ return;
+ }
+
+ if (suppressed.SourceRootPath is null)
+ {
+ throw new JsonException("Retained sidecars have no authorized source root.");
+ }
+
+ string destinationRoot = Path.TrimEndingDirectorySeparator(
+ PathBoundary.ResolveDeepestExistingTarget(
+ authorizedRootPath
+ ?? Path.GetDirectoryName(rehomedUri.LocalPath)
+ ?? throw new JsonException("Rehomed element has no destination directory.")));
+ var copies = new List<(SuppressedReferencedStorageSource Source, string Destination)>();
+ foreach (SuppressedReferencedStorageSource source in referencedSources)
+ {
+ string relativePath = authorizedRootPath is null
+ ? source.ElementRelativePath
+ : source.RelativePath;
+ if (Path.IsPathRooted(relativePath))
+ {
+ throw new JsonException($"Invalid retained sidecar path: {relativePath}");
+ }
+
+ string destination = Path.GetFullPath(Path.Combine(destinationRoot, relativePath));
+ string resolvedDestination = PathBoundary.ResolveDeepestExistingTarget(destination);
+ if (!PathBoundary.IsPathInsideRoot(destinationRoot, resolvedDestination))
+ {
+ throw new JsonException($"Retained sidecar escapes the Save As root: {relativePath}");
+ }
+
+ copies.Add((source, destination));
+ }
+
+ foreach ((SuppressedReferencedStorageSource source, string destination) in copies)
+ {
+ if (File.Exists(destination))
+ {
+ EnsureExistingBytesMatch(destination, source.RawBytes);
+ }
+ }
+
+ foreach ((SuppressedReferencedStorageSource source, string destination) in copies)
+ {
+ WriteBytesAtomicallyIfMatchingOrMissing(destination, source.RawBytes);
+ }
+ }
+
+ private static void WriteBytesAtomicallyIfMatchingOrMissing(string path, byte[] bytes)
+ {
+ if (File.Exists(path))
+ {
+ EnsureExistingBytesMatch(path, bytes);
+ return;
+ }
+
+ string? directory = Path.GetDirectoryName(path);
+ if (directory != null)
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
+ try
+ {
+ using (var stream = new FileStream(
+ tempPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None))
+ {
+ stream.Write(bytes);
+ stream.Flush(flushToDisk: true);
+ }
+
+ try
+ {
+ File.Move(tempPath, path, overwrite: false);
+ }
+ catch (IOException) when (File.Exists(path))
+ {
+ EnsureExistingBytesMatch(path, bytes);
+ }
+ }
+ finally
+ {
+ try
+ {
+ File.Delete(tempPath);
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private static void EnsureExistingBytesMatch(string path, byte[] expectedBytes)
+ {
+ if (!File.ReadAllBytes(path).AsSpan().SequenceEqual(expectedBytes))
+ {
+ throw new IOException($"The retained sidecar destination already contains different data: '{path}'.");
+ }
+ }
+
+ private static void RestoreReinstatedBytes(SuppressedStorageSource suppressed, string path)
+ {
+ if (!suppressed.WasReinstated && File.Exists(path))
+ {
+ return;
+ }
+
+ if (!File.Exists(path)
+ || !File.ReadAllBytes(path).AsSpan().SequenceEqual(suppressed.RawBytes))
+ {
+ string? directory = Path.GetDirectoryName(path);
+ if (directory != null)
+ {
+ Directory.CreateDirectory(directory);
+ }
+
+ WriteBytesAtomically(path, suppressed.RawBytes);
+ }
+
+ suppressed.WasReinstated = false;
+ }
+
+ private static void WriteBytesAtomically(string path, byte[] bytes)
+ {
+ string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
+ try
+ {
+ using (var stream = new FileStream(
+ tempPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None))
+ {
+ stream.Write(bytes);
+ stream.Flush(flushToDisk: true);
+ }
+
+ File.Move(tempPath, path, overwrite: true);
+ }
+ finally
+ {
+ try
+ {
+ File.Delete(tempPath);
+ }
+ catch
+ {
+ }
+ }
+ }
}
diff --git a/src/Beutl.Core/Serialization/DeserializationIncidents.cs b/src/Beutl.Core/Serialization/DeserializationIncidents.cs
new file mode 100644
index 0000000000..064d18f284
--- /dev/null
+++ b/src/Beutl.Core/Serialization/DeserializationIncidents.cs
@@ -0,0 +1,86 @@
+namespace Beutl.Serialization;
+
+///
+/// Thread-local tally of fallback substitutions, letting a caller detect fallbacks created in
+/// positions a hierarchical traversal of the deserialized result cannot reach (e.g. plain
+/// property values such as keyframe values).
+///
+internal static class DeserializationIncidents
+{
+ [ThreadStatic]
+ private static int t_fallbackCount;
+
+ [ThreadStatic]
+ private static Capture? t_capture;
+
+ internal static int FallbackCount => t_fallbackCount;
+
+ internal static Capture BeginCapture() => new(t_capture);
+
+ internal static void RecordFallback(IFallback? fallback = null)
+ {
+ Record(new DeserializationIncident(fallback, null, null, null));
+ }
+
+ internal static void RecordFallback(
+ FallbackReason reason,
+ string? typeName,
+ string? message)
+ {
+ Record(new DeserializationIncident(null, reason, typeName, message));
+ }
+
+ private static void Record(DeserializationIncident incident)
+ {
+ t_fallbackCount++;
+ for (Capture? capture = t_capture; capture != null; capture = capture.Parent)
+ {
+ capture.Record(incident);
+ }
+ }
+
+ internal sealed class Capture : IDisposable
+ {
+ private readonly int _initialCount;
+ private List? _incidents;
+ private bool _disposed;
+
+ internal Capture(Capture? parent)
+ {
+ Parent = parent;
+ _initialCount = t_fallbackCount;
+ t_capture = this;
+ }
+
+ internal Capture? Parent { get; }
+
+ internal int Count => t_fallbackCount - _initialCount;
+
+ internal IReadOnlyList Incidents => _incidents ?? [];
+
+ internal void Record(DeserializationIncident incident)
+ => (_incidents ??= []).Add(incident);
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (!ReferenceEquals(t_capture, this))
+ {
+ throw new InvalidOperationException("Deserialization incident captures must be disposed in stack order.");
+ }
+
+ t_capture = Parent;
+ _disposed = true;
+ }
+ }
+
+ internal sealed record DeserializationIncident(
+ IFallback? Fallback,
+ FallbackReason? Reason,
+ string? TypeName,
+ string? Message);
+}
diff --git a/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs b/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs
index 1bf111f1b2..485bec0e11 100644
--- a/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs
+++ b/src/Beutl.Core/Serialization/FallbackDeserializationHelper.cs
@@ -29,6 +29,7 @@ internal static class FallbackDeserializationHelper
fallback.Reason = FallbackReason.DeserializationFailed;
fallback.ErrorMessage = exception?.Message;
+ DeserializationIncidents.RecordFallback(fallback);
return fallback;
}
}
diff --git a/src/Beutl.Core/Serialization/FilePathBoundary.cs b/src/Beutl.Core/Serialization/FilePathBoundary.cs
new file mode 100644
index 0000000000..91fae4d192
--- /dev/null
+++ b/src/Beutl.Core/Serialization/FilePathBoundary.cs
@@ -0,0 +1,142 @@
+namespace Beutl;
+
+internal static class PathBoundary
+{
+ private static readonly StringComparison s_comparison = OperatingSystem.IsWindows()
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+
+ public static StringComparison Comparison => s_comparison;
+
+ public static StringComparer Comparer { get; } = OperatingSystem.IsWindows()
+ ? StringComparer.OrdinalIgnoreCase
+ : StringComparer.Ordinal;
+
+ public static bool IsPathInsideRoot(string root, string candidate)
+ {
+ string prefix = Path.EndsInDirectorySeparator(root)
+ ? root
+ : root + Path.DirectorySeparatorChar;
+ return string.Equals(candidate, root, s_comparison)
+ || candidate.StartsWith(prefix, s_comparison);
+ }
+
+ public static string ResolveDeepestExistingTarget(string path)
+ {
+ string absolute = Path.GetFullPath(path);
+ string? current = absolute;
+ var remainder = new Stack();
+ while (!string.IsNullOrEmpty(current) && !PathEntryExists(current))
+ {
+ string? name = Path.GetFileName(current);
+ if (!string.IsNullOrEmpty(name))
+ {
+ remainder.Push(name);
+ }
+
+ current = Path.GetDirectoryName(current);
+ }
+
+ if (string.IsNullOrEmpty(current))
+ {
+ return absolute;
+ }
+
+ string resolved = ResolveExistingPath(current);
+ while (remainder.Count > 0)
+ {
+ resolved = Path.Combine(resolved, remainder.Pop());
+ }
+
+ return Path.GetFullPath(resolved);
+ }
+
+ public static string ResolveExistingPath(string path)
+ {
+ string absolute = Path.GetFullPath(path);
+ string root = Path.GetPathRoot(absolute) ?? absolute;
+ var components = new Stack();
+ string? current = absolute;
+ while (current is not null
+ && current.Length >= root.Length
+ && !string.Equals(current, root, s_comparison))
+ {
+ string name = Path.GetFileName(current);
+ if (!string.IsNullOrEmpty(name))
+ {
+ components.Push(name);
+ }
+
+ current = Path.GetDirectoryName(current);
+ }
+
+ string resolved = root;
+ while (components.Count > 0)
+ {
+ string candidate = Path.Combine(resolved, components.Pop());
+ if (TryResolveLinkTarget(candidate, out string? target))
+ {
+ resolved = Path.IsPathRooted(target)
+ ? Path.GetFullPath(target)
+ : Path.GetFullPath(Path.Combine(resolved, target));
+ }
+ else
+ {
+ resolved = Path.GetFullPath(candidate);
+ }
+ }
+
+ return resolved;
+ }
+
+ private static bool PathEntryExists(string path)
+ => Path.Exists(path) || new FileInfo(path).LinkTarget is not null;
+
+ private static FileSystemInfo? TryResolveLinkTarget(FileSystemInfo info)
+ {
+ try
+ {
+ return info.ResolveLinkTarget(returnFinalTarget: true);
+ }
+ catch (Exception ex) when (ex is IOException
+ or UnauthorizedAccessException
+ or NotSupportedException)
+ {
+ try
+ {
+ string? immediateTarget = info.LinkTarget;
+ string? baseDirectory = Path.GetDirectoryName(info.FullName);
+ if (immediateTarget is null || baseDirectory is null)
+ {
+ return null;
+ }
+
+ string absoluteTarget = Path.IsPathRooted(immediateTarget)
+ ? Path.GetFullPath(immediateTarget)
+ : Path.GetFullPath(Path.Combine(baseDirectory, immediateTarget));
+ return Directory.Exists(absoluteTarget)
+ ? new DirectoryInfo(absoluteTarget)
+ : new FileInfo(absoluteTarget);
+ }
+ catch (Exception fallbackEx) when (fallbackEx is IOException
+ or UnauthorizedAccessException
+ or NotSupportedException
+ or ArgumentException)
+ {
+ return null;
+ }
+ }
+ }
+
+ private static bool TryResolveLinkTarget(
+ string path,
+ [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? target)
+ {
+ FileSystemInfo info = Directory.Exists(path)
+ ? new DirectoryInfo(path)
+ : new FileInfo(path);
+ FileSystemInfo? resolved = TryResolveLinkTarget(info);
+ target = resolved?.FullName;
+ return target is not null;
+ }
+}
diff --git a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs
index c6a3922b1f..54a1eeede8 100644
--- a/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs
+++ b/src/Beutl.Core/Serialization/JsonSerializationContext.Deserialize.cs
@@ -88,6 +88,12 @@ private static bool TryDeserializeCoreSerializable(
try
{
+ if (!baseType.IsAssignableFrom(actualType))
+ {
+ throw new InvalidCastException(
+ $"Discriminator type '{actualType}' is not assignable to the expected type '{baseType}'.");
+ }
+
var instance = Activator.CreateInstance(actualType) as ICoreSerializable
?? throw new InvalidOperationException(
$"Could not create instance of type {actualType.FullName}.");
@@ -110,6 +116,7 @@ private static bool TryDeserializeCoreSerializable(
if (instance is IFallback fallbackObj)
{
fallbackObj.Reason = FallbackReason.TypeNotFound;
+ DeserializationIncidents.RecordFallback(fallbackObj);
}
result = instance;
diff --git a/src/Beutl.Core/Serialization/SuppressedStorageSource.cs b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs
new file mode 100644
index 0000000000..2f086c7904
--- /dev/null
+++ b/src/Beutl.Core/Serialization/SuppressedStorageSource.cs
@@ -0,0 +1,36 @@
+using System.Text.Json.Nodes;
+
+namespace Beutl;
+
+///
+/// The retained on-disk bytes of an object the serializer must not regenerate, together with the
+/// location those bytes came from. The source location is never rewritten; any other location
+/// receives a verbatim copy.
+///
+internal sealed record SuppressedStorageSource(
+ byte[] RawBytes,
+ Uri SourceUri,
+ bool HasNonFallbackIncidents = false,
+ JsonObject[]? UntraversedFallbacks = null,
+ SuppressedReferencedStorageSource[]? ReferencedStorageSources = null,
+ string? SourceRootPath = null,
+ SuppressedRecoveryIncident[]? RecoveryIncidents = null)
+{
+ ///
+ /// True when this suppression record was put back by undoing an in-process repair. Only a
+ /// reinstated record may restore the retained bytes over a mismatched sidecar; a continuously
+ /// held record treats a mismatch as an external repair and leaves the changed file alone.
+ /// Cleared once the retained bytes have been restored.
+ ///
+ public bool WasReinstated { get; set; }
+}
+
+internal sealed record SuppressedReferencedStorageSource(
+ byte[] RawBytes,
+ string RelativePath,
+ string ElementRelativePath);
+
+internal sealed record SuppressedRecoveryIncident(
+ string Reason,
+ string? TypeName,
+ string? Message);
diff --git a/src/Beutl.Core/TypeFormat.cs b/src/Beutl.Core/TypeFormat.cs
index 540a8cf4df..0f116d6ee9 100644
--- a/src/Beutl.Core/TypeFormat.cs
+++ b/src/Beutl.Core/TypeFormat.cs
@@ -10,8 +10,20 @@ internal static class TypeFormat
public static Type? ToType(string fullName)
{
fullName = fullName.Replace("Beutl.Embedding.FFmpeg", "Beutl.Extensions.FFmpeg");
- List tokens = new TypeNameTokenizer(fullName).Tokenize();
- return new TypeNameParser(tokens).Parse();
+ try
+ {
+ List tokens = new TypeNameTokenizer(fullName).Tokenize();
+ return new TypeNameParser(tokens).Parse();
+ }
+ // The tokenizer/parser index freely and throw on ill-formed names (e.g. "x" from a
+ // hand-edited file); every caller already treats null as "unknown type".
+ catch (Exception ex) when (ex is IndexOutOfRangeException
+ or ArgumentOutOfRangeException
+ or ArgumentException
+ or InvalidOperationException)
+ {
+ return null;
+ }
}
public static string ToString(Type type)
@@ -324,6 +336,11 @@ private static string TakeTypeNameTokens(Span tokens, out Span gen
type = _assembly?.GetType($"{_namespace ?? ""}.{typeName}{suffix}")!;
}
+ if (type == null)
+ {
+ return null;
+ }
+
if (genericArgs.Length > 0)
{
type = type.MakeGenericType(genericArgs);
diff --git a/src/Beutl.Core/ValueReplacement.cs b/src/Beutl.Core/ValueReplacement.cs
new file mode 100644
index 0000000000..8a338b4cd3
--- /dev/null
+++ b/src/Beutl.Core/ValueReplacement.cs
@@ -0,0 +1,11 @@
+namespace Beutl;
+
+internal static class ValueReplacement
+{
+ public static bool RequiresReplacement(T current, T candidate, bool replaceEquivalent)
+ {
+ return replaceEquivalent && !typeof(T).IsValueType
+ ? !ReferenceEquals(current, candidate)
+ : !EqualityComparer.Default.Equals(current, candidate);
+ }
+}
diff --git a/src/Beutl.Editor/AutoSaveService.cs b/src/Beutl.Editor/AutoSaveService.cs
index 5082bb0456..f06bcc3e98 100644
--- a/src/Beutl.Editor/AutoSaveService.cs
+++ b/src/Beutl.Editor/AutoSaveService.cs
@@ -41,7 +41,7 @@ public void SaveObjects(IEnumerable objectsToSave)
{
if (obj is IHierarchical hierarchical && hierarchical.HierarchicalRoot == null)
{
- if (obj.Uri!.Scheme == "file")
+ if (obj.SuppressedStorageSource is null && obj.Uri!.Scheme == "file")
{
var path = obj.Uri.LocalPath;
if (File.Exists(path))
diff --git a/src/Beutl.Editor/Services/ElementObjectService.cs b/src/Beutl.Editor/Services/ElementObjectService.cs
index c86864e9f9..a4bd221ab4 100644
--- a/src/Beutl.Editor/Services/ElementObjectService.cs
+++ b/src/Beutl.Editor/Services/ElementObjectService.cs
@@ -45,6 +45,17 @@ public bool Remove(Element element, EngineObject obj)
if (!element.Objects.Contains(obj)) return false;
element.RemoveObject(obj);
+ if (Scene.TryResumeElementPersistence(element) is { } suppression)
+ {
+ _historyManager.Record(
+ () => element.SuppressedStorageSource = null,
+ () =>
+ {
+ suppression.WasReinstated = true;
+ element.SuppressedStorageSource = suppression;
+ });
+ }
+
_historyManager.Commit(CommandNames.RemoveObject);
return true;
}
@@ -85,6 +96,17 @@ public ObjectPasteOutcome PasteOver(Element element, int index, string json)
CoreSerializer.PopulateFromJsonObject(obj, type, newJson);
element.Objects[index] = obj;
+ if (Scene.TryResumeElementPersistence(element) is { } suppression)
+ {
+ _historyManager.Record(
+ () => element.SuppressedStorageSource = null,
+ () =>
+ {
+ suppression.WasReinstated = true;
+ element.SuppressedStorageSource = suppression;
+ });
+ }
+
_historyManager.Commit(CommandNames.PasteObject);
return ObjectPasteOutcome.Pasted;
}
diff --git a/src/Beutl.Engine/Animation/IKeyFrame.cs b/src/Beutl.Engine/Animation/IKeyFrame.cs
index f2cb8bb502..5151f5cfb1 100644
--- a/src/Beutl.Engine/Animation/IKeyFrame.cs
+++ b/src/Beutl.Engine/Animation/IKeyFrame.cs
@@ -17,6 +17,12 @@ public interface IKeyFrame : ICoreObject, INotifyEdited, IHierarchical
object? Value { get; set; }
+ ///
+ /// Installs , replacing distinct reference-type values even when they
+ /// compare equal while preserving the normal validation and notification semantics.
+ ///
+ void ReplaceValue(object? value);
+
Easing Easing { get; set; }
//void SetDuration(TimeSpan timeSpan);
diff --git a/src/Beutl.Engine/Animation/KeyFrame.cs b/src/Beutl.Engine/Animation/KeyFrame.cs
index 401c947f59..535df90070 100644
--- a/src/Beutl.Engine/Animation/KeyFrame.cs
+++ b/src/Beutl.Engine/Animation/KeyFrame.cs
@@ -1,7 +1,11 @@
-using System.Text.Json.Nodes;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text.Json;
+using System.Text.Json.Nodes;
using Beutl.Animation.Easings;
using Beutl.Serialization;
+using Beutl.Utilities;
using Beutl.Validation;
namespace Beutl.Animation;
@@ -11,6 +15,7 @@ public class KeyFrame : Hierarchical
public static readonly CoreProperty EasingProperty;
public static readonly CoreProperty KeyTimeProperty;
private Easing _easing;
+ private Easing? _lossyFallbackEasing;
private TimeSpan _keyTime;
protected KeyFrame()
@@ -37,6 +42,9 @@ public Easing Easing
set => SetAndRaise(EasingProperty, ref _easing, value);
}
+ [NotAutoSerialized]
+ internal bool HasLossyEasing => ReferenceEquals(_easing, _lossyFallbackEasing);
+
public TimeSpan KeyTime
{
get => _keyTime;
@@ -49,28 +57,115 @@ public override void Deserialize(ICoreSerializationContext context)
{
base.Deserialize(context);
- if (context.GetValue(nameof(Easing)) is { } easingNode)
+ JsonNode? easingNode = context.GetValue(nameof(Easing));
+ if (easingNode is null)
{
- if (easingNode is JsonValue easingTypeValue
- && easingTypeValue.TryGetValue(out string? easingType))
+ if (context.Contains(nameof(Easing)))
{
- Type type = TypeFormat.ToType(easingType) ?? typeof(LinearEasing);
-
- if (Activator.CreateInstance(type) is Easing easing)
+ UseFallbackEasing(
+ FallbackReason.DeserializationFailed,
+ null,
+ "The easing value is null.");
+ }
+ }
+ else if (easingNode is JsonValue easingTypeValue
+ && easingTypeValue.TryGetValue(out string? easingType))
+ {
+ Type? type = TypeFormat.ToType(easingType);
+ if (type is null)
+ {
+ UseFallbackEasing(
+ FallbackReason.TypeNotFound,
+ easingType,
+ $"The easing type '{easingType}' could not be resolved.");
+ }
+ else if (!type.IsAssignableTo(typeof(Easing))
+ || type.IsAbstract
+ || type.ContainsGenericParameters
+ || type.GetConstructor(Type.EmptyTypes) is null)
+ {
+ UseFallbackEasing(
+ FallbackReason.DeserializationFailed,
+ easingType,
+ $"The easing type '{easingType}' cannot be instantiated as an Easing.");
+ }
+ else
+ {
+ try
+ {
+ if (Activator.CreateInstance(type) is Easing easing)
+ {
+ Easing = easing;
+ }
+ else
+ {
+ UseFallbackEasing(
+ FallbackReason.DeserializationFailed,
+ easingType,
+ $"The easing type '{easingType}' did not create an Easing instance.");
+ }
+ }
+ catch (Exception ex) when (ex is MissingMethodException
+ or MemberAccessException
+ or TargetInvocationException
+ or TypeInitializationException
+ or NotSupportedException)
{
- Easing = easing;
+ if (ExceptionHelpers.ContainsFileSystemFailure(ex))
+ {
+ if (ex.InnerException is { } inner)
+ {
+ ExceptionDispatchInfo.Capture(inner).Throw();
+ }
+
+ throw;
+ }
+
+ UseFallbackEasing(
+ FallbackReason.DeserializationFailed,
+ easingType,
+ $"{ex.GetType().Name}: {ex.Message}");
}
}
- else if (easingNode is JsonObject easingObject)
+ }
+ else if (easingNode is JsonObject easingObject)
+ {
+ if (easingObject["X1"] is JsonValue x1Value
+ && easingObject["Y1"] is JsonValue y1Value
+ && easingObject["X2"] is JsonValue x2Value
+ && easingObject["Y2"] is JsonValue y2Value
+ && x1Value.TryGetValue(out float x1)
+ && y1Value.TryGetValue(out float y1)
+ && x2Value.TryGetValue(out float x2)
+ && y2Value.TryGetValue(out float y2))
{
- float x1 = (float?)easingObject["X1"] ?? 0;
- float y1 = (float?)easingObject["Y1"] ?? 0;
- float x2 = (float?)easingObject["X2"] ?? 1;
- float y2 = (float?)easingObject["Y2"] ?? 1;
-
Easing = new SplineEasing(x1, y1, x2, y2);
}
+ else
+ {
+ UseFallbackEasing(
+ FallbackReason.DeserializationFailed,
+ null,
+ "The spline easing object does not contain four valid control-point values.");
+ }
}
+ else
+ {
+ UseFallbackEasing(
+ FallbackReason.DeserializationFailed,
+ null,
+ "The easing value has an unsupported JSON representation.");
+ }
+ }
+
+ private void UseFallbackEasing(
+ FallbackReason reason,
+ string? typeName,
+ string message)
+ {
+ DeserializationIncidents.RecordFallback(reason, typeName, message);
+ _lossyFallbackEasing = new LinearEasing();
+ Easing = _lossyFallbackEasing;
}
public override void Serialize(ICoreSerializationContext context)
diff --git a/src/Beutl.Engine/Animation/KeyFrame{T}.cs b/src/Beutl.Engine/Animation/KeyFrame{T}.cs
index a17f5ab04b..8f654de76a 100644
--- a/src/Beutl.Engine/Animation/KeyFrame{T}.cs
+++ b/src/Beutl.Engine/Animation/KeyFrame{T}.cs
@@ -28,19 +28,21 @@ static KeyFrame()
public T? Value
{
get => _value;
- set
+ set => SetValue(value, replaceEquivalent: false);
+ }
+
+ private void SetValue(T? value, bool replaceEquivalent)
+ {
+ if (Validator != null)
{
- if (Validator != null)
+ T? coerced = value;
+ if (Validator.TryCoerce(default, ref coerced))
{
- T? coerced = value;
- if (Validator.TryCoerce(default, ref coerced))
- {
- value = coerced!;
- }
+ value = coerced!;
}
-
- SetAndRaise(ValueProperty, ref _value, value);
}
+
+ SetAndRaise(ValueProperty, ref _value, value, replaceEquivalent);
}
object? IKeyFrame.Value
@@ -53,6 +55,22 @@ public T? Value
}
}
+ void IKeyFrame.ReplaceValue(object? value)
+ {
+ if (value is T typed)
+ {
+ SetValue(typed, replaceEquivalent: true);
+ }
+ else if (value is null && !typeof(T).IsValueType)
+ {
+ SetValue(default, replaceEquivalent: true);
+ }
+ else
+ {
+ throw new InvalidCastException();
+ }
+ }
+
public new IValidator? Validator
{
get => base.Validator as IValidator;
diff --git a/src/Beutl.Engine/Engine/AnimatableProperty.cs b/src/Beutl.Engine/Engine/AnimatableProperty.cs
index 67864a26e7..56c77df6c3 100644
--- a/src/Beutl.Engine/Engine/AnimatableProperty.cs
+++ b/src/Beutl.Engine/Engine/AnimatableProperty.cs
@@ -42,34 +42,43 @@ public AnimatableProperty(T defaultValue, IValidator? validator = null)
public T CurrentValue
{
get => _currentValue;
- set
- {
- var validatedValue = ValidateAndCoerce(value);
- if (!EqualityComparer.Default.Equals(_currentValue, validatedValue))
- {
- var oldValue = _currentValue;
- _currentValue = validatedValue;
- HasLocalValue = true;
+ set => SetCurrentValue(value, replaceEquivalent: false);
+ }
- ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue));
- Edited?.Invoke(this, EventArgs.Empty);
- if (_owner is IModifiableHierarchical ownerHierarchical)
- {
- if (oldValue is IHierarchical oldHierarchical)
- ownerHierarchical.RemoveChild(oldHierarchical);
+ private void SetCurrentValue(T value, bool replaceEquivalent)
+ {
+ var validatedValue = ValidateAndCoerce(value);
+ bool hasReplacement = ValueReplacement.RequiresReplacement(
+ _currentValue,
+ validatedValue,
+ replaceEquivalent);
+ if (hasReplacement)
+ {
+ var oldValue = _currentValue;
+ _currentValue = validatedValue;
+ HasLocalValue = true;
- if (validatedValue is IHierarchical newHierarchical)
- ownerHierarchical.AddChild(newHierarchical);
- }
+ ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue));
+ Edited?.Invoke(this, EventArgs.Empty);
+ if (_owner is IModifiableHierarchical ownerHierarchical)
+ {
+ if (oldValue is IHierarchical oldHierarchical)
+ ownerHierarchical.RemoveChild(oldHierarchical);
- if (oldValue is INotifyEdited oldEdited)
- oldEdited.Edited -= OnChildEdited;
- if (validatedValue is INotifyEdited newEdited)
- newEdited.Edited += OnChildEdited;
+ if (validatedValue is IHierarchical newHierarchical)
+ ownerHierarchical.AddChild(newHierarchical);
}
+
+ if (oldValue is INotifyEdited oldEdited)
+ oldEdited.Edited -= OnChildEdited;
+ if (validatedValue is INotifyEdited newEdited)
+ newEdited.Edited += OnChildEdited;
}
}
+ public void ReplaceCurrentValue(T value)
+ => SetCurrentValue(value, replaceEquivalent: true);
+
public IAnimation? Animation
{
get => _animation;
diff --git a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs
index 0ef9839948..e68b59a16c 100644
--- a/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs
+++ b/src/Beutl.Engine/Engine/Expressions/IReferenceExpression.cs
@@ -9,4 +9,11 @@ public interface IReferenceExpression : IExpression
string PropertyPath { get; }
bool HasPropertyPath { get; }
+
+ ///
+ /// Returns an equivalent expression targeting , preserving the
+ /// concrete implementation and its property path, or when the
+ /// implementation cannot preserve all of its state while rebinding.
+ ///
+ IReferenceExpression? Rebind(Guid objectId);
}
diff --git a/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs b/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs
index f06af10b4d..d6df0ea970 100644
--- a/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs
+++ b/src/Beutl.Engine/Engine/Expressions/ReferenceExpression.cs
@@ -24,6 +24,11 @@ public ReferenceExpression(Guid objectId, string? propertyPath)
public bool HasPropertyPath => !string.IsNullOrEmpty(PropertyPath);
+ public IReferenceExpression? Rebind(Guid objectId)
+ {
+ return new ReferenceExpression(objectId, PropertyPath);
+ }
+
public string ExpressionString =>
HasPropertyPath ? $"{ObjectId}.{PropertyPath}" : ObjectId.ToString();
diff --git a/src/Beutl.Engine/Engine/IProperty.cs b/src/Beutl.Engine/Engine/IProperty.cs
index 626fc6bf4b..65214ad8ed 100644
--- a/src/Beutl.Engine/Engine/IProperty.cs
+++ b/src/Beutl.Engine/Engine/IProperty.cs
@@ -24,6 +24,12 @@ public interface IProperty : INotifyEdited
object? CurrentValue { get; set; }
+ ///
+ /// Validates and installs , replacing distinct reference-type values
+ /// even when they compare equal while preserving the normal notification semantics.
+ ///
+ void ReplaceCurrentValue(object? value);
+
string Name { get; }
Type ValueType { get; }
@@ -67,6 +73,8 @@ public interface IProperty : IProperty
new T CurrentValue { get; set; }
+ void ReplaceCurrentValue(T value);
+
new IAnimation? Animation { get; set; }
new IExpression? Expression { get; set; }
@@ -105,6 +113,22 @@ public interface IProperty : IProperty
}
}
+ void IProperty.ReplaceCurrentValue(object? value)
+ {
+ if (value is T typed)
+ {
+ ReplaceCurrentValue(typed);
+ }
+ else if (value is null && !typeof(T).IsValueType)
+ {
+ ReplaceCurrentValue(default!);
+ }
+ else
+ {
+ throw new InvalidCastException();
+ }
+ }
+
T GetValue(CompositionContext context);
event EventHandler>? ValueChanged;
diff --git a/src/Beutl.Engine/Engine/ListProperty.cs b/src/Beutl.Engine/Engine/ListProperty.cs
index a87f82f69b..f1a5033ae9 100644
--- a/src/Beutl.Engine/Engine/ListProperty.cs
+++ b/src/Beutl.Engine/Engine/ListProperty.cs
@@ -66,6 +66,9 @@ public ICoreList CurrentValue
set => _items.Replace(value);
}
+ public void ReplaceCurrentValue(ICoreList value)
+ => _items.Replace(value);
+
public IAnimation>? Animation
{
get => null;
diff --git a/src/Beutl.Engine/Engine/SimpleProperty.cs b/src/Beutl.Engine/Engine/SimpleProperty.cs
index 20b83d9c83..9b1bcf351a 100644
--- a/src/Beutl.Engine/Engine/SimpleProperty.cs
+++ b/src/Beutl.Engine/Engine/SimpleProperty.cs
@@ -9,7 +9,8 @@
namespace Beutl.Engine;
-public class SimpleProperty(T defaultValue, IValidator? validator = null) : IProperty
+public class SimpleProperty(T defaultValue, IValidator? validator = null)
+ : IProperty
{
private IValidator? _validator = validator;
private T _currentValue = defaultValue;
@@ -32,34 +33,43 @@ public class SimpleProperty(T defaultValue, IValidator? validator = null)
public T CurrentValue
{
get => _currentValue;
- set
+ set => SetCurrentValue(value, replaceEquivalent: false);
+ }
+
+ private void SetCurrentValue(T value, bool replaceEquivalent)
+ {
+ var validatedValue = ValidateAndCoerce(value);
+ bool hasReplacement = ValueReplacement.RequiresReplacement(
+ _currentValue,
+ validatedValue,
+ replaceEquivalent);
+ if (hasReplacement)
{
- var validatedValue = ValidateAndCoerce(value);
- if (!EqualityComparer.Default.Equals(_currentValue, validatedValue))
+ var oldValue = _currentValue;
+ _currentValue = validatedValue;
+ HasLocalValue = true;
+
+ ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue));
+ Edited?.Invoke(this, EventArgs.Empty);
+ if (_owner is IModifiableHierarchical ownerHierarchical)
{
- var oldValue = _currentValue;
- _currentValue = validatedValue;
- HasLocalValue = true;
+ if (oldValue is IHierarchical oldHierarchical)
+ ownerHierarchical.RemoveChild(oldHierarchical);
- ValueChanged?.Invoke(this, new PropertyValueChangedEventArgs(this, oldValue, validatedValue));
- Edited?.Invoke(this, EventArgs.Empty);
- if (_owner is IModifiableHierarchical ownerHierarchical)
- {
- if (oldValue is IHierarchical oldHierarchical)
- ownerHierarchical.RemoveChild(oldHierarchical);
-
- if (validatedValue is IHierarchical newHierarchical)
- ownerHierarchical.AddChild(newHierarchical);
- }
-
- if (oldValue is INotifyEdited oldEdited)
- oldEdited.Edited -= OnChildEdited;
- if (validatedValue is INotifyEdited newEdited)
- newEdited.Edited += OnChildEdited;
+ if (validatedValue is IHierarchical newHierarchical)
+ ownerHierarchical.AddChild(newHierarchical);
}
+
+ if (oldValue is INotifyEdited oldEdited)
+ oldEdited.Edited -= OnChildEdited;
+ if (validatedValue is INotifyEdited newEdited)
+ newEdited.Edited += OnChildEdited;
}
}
+ public void ReplaceCurrentValue(T value)
+ => SetCurrentValue(value, replaceEquivalent: true);
+
public IAnimation? Animation
{
get => null;
diff --git a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs
index 6668d59fc9..6af354482c 100644
--- a/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs
+++ b/src/Beutl.ProjectSystem/ProjectSystem/Scene.cs
@@ -1,12 +1,22 @@
-using System.Collections.Immutable;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
using System.Collections.Specialized;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using Beutl.Animation;
using Beutl.Collections;
using Beutl.Configuration;
+using Beutl.Engine;
+using Beutl.Engine.Expressions;
using Beutl.Language;
using Beutl.Media;
using Beutl.Serialization;
@@ -43,6 +53,20 @@ public enum ElementOverlapHandling
public class Scene : ProjectItem, INotifyEdited
{
+ private const int MaxRecoveredIdCollisionAttempts = 1024;
+ private const string RecoveredDescendantIdsKey = "RecoveredDescendantIds";
+ private const string RecoveredDescendantIdentitiesKey = "RecoveredDescendantIdentities";
+ private const string RecoveredElementIdsKey = "RecoveredElementIds";
+ private static readonly Guid s_recoveredElementNamespace = new("dfad2f76-1d04-5593-ae3b-f371fb1f42ee");
+ private static readonly Regex s_idPattern = new(
+ "\"Id\"\\s*:\\s*\"(?[0-9a-fA-F-]{36})\"",
+ RegexOptions.CultureInvariant);
+ private static readonly Regex s_typePattern = new(
+ "\"\\$type\"\\s*:\\s*(?\"(?:\\\\.|[^\"\\\\])*\")",
+ RegexOptions.CultureInvariant);
+ private static readonly Regex s_legacyTypePattern = new(
+ "\"@type\"\\s*:\\s*(?\"(?:\\\\.|[^\"\\\\])*\")",
+ RegexOptions.CultureInvariant);
public static readonly CoreProperty FrameSizeProperty;
public static readonly CoreProperty ChildrenProperty;
public static readonly CoreProperty StartProperty;
@@ -55,6 +79,13 @@ public class Scene : ProjectItem, INotifyEdited
private readonly Elements _children;
private readonly HierarchicalList _layers;
private readonly HierarchicalList _markers;
+ private readonly Dictionary _recoveredDescendantIds = new(StringComparer.Ordinal);
+ private readonly Dictionary _recoveredDescendantIdentities = new(StringComparer.Ordinal);
+ private readonly ConditionalWeakTable _recoveredDescendantRemaps = new();
+ private readonly Dictionary _recoveredElementIds = new(StringComparer.Ordinal);
+ private readonly Dictionary _pendingRecoveredElementIdMigrations = [];
+ private readonly Dictionary _pendingRecoveredDescendantIdMigrations = [];
+ private readonly ConditionalWeakTable _idlessRecoveredDescendants = new();
private TimeSpan _start = TimeSpan.FromMinutes(0);
private TimeSpan _duration = TimeSpan.FromMinutes(5);
private PixelSize _frameSize;
@@ -539,12 +570,53 @@ static void Process(JsonObject jobject, string jsonName, List list)
context.SetValue("Height", FrameSize.Height);
context.SetValue("Groups", Groups.Select(ids => string.Join(':', ids)).ToArray());
context.SetValue(nameof(Markers), Markers);
+ RecoveredSerializationState recoveredState = BuildRecoveredSerializationState();
+ if (recoveredState.ElementIds.Count > 0)
+ {
+ var recoveredElementIds = new JsonObject();
+ foreach ((string path, Guid id) in recoveredState.ElementIds.OrderBy(
+ static item => item.Key,
+ StringComparer.Ordinal))
+ {
+ recoveredElementIds[path] = id.ToString();
+ }
+
+ context.SetValue(RecoveredElementIdsKey, recoveredElementIds);
+ }
+
+ if (recoveredState.DescendantIds.Count > 0)
+ {
+ var recoveredDescendantIds = new JsonObject();
+ foreach ((string key, Guid id) in recoveredState.DescendantIds.OrderBy(
+ static item => item.Key,
+ StringComparer.Ordinal))
+ {
+ recoveredDescendantIds[key] = id.ToString();
+ }
+
+ context.SetValue(RecoveredDescendantIdsKey, recoveredDescendantIds);
+ }
+
+ if (recoveredState.DescendantIdentities.Count > 0)
+ {
+ var recoveredDescendantIdentities = new JsonObject();
+ foreach ((string key, Guid id) in recoveredState.DescendantIdentities.OrderBy(
+ static item => item.Key,
+ StringComparer.Ordinal))
+ {
+ recoveredDescendantIdentities[key] = id.ToString();
+ }
+
+ context.SetValue(RecoveredDescendantIdentitiesKey, recoveredDescendantIdentities);
+ }
if (context.Mode.HasFlag(CoreSerializationMode.SaveReferencedObjects))
{
+ string sidecarRoot = Path.GetDirectoryName(Uri!.LocalPath)
+ ?? throw new JsonException("Scene has no sidecar directory.");
foreach (Element item in Children)
{
- CoreSerializer.StoreToUri(item, item.Uri!);
+ CoreSerializer.StoreToUri(item, item.Uri!, sidecarRoot);
}
}
@@ -596,6 +668,59 @@ static void Process(Func add, JsonNode node, List list)
FrameSize = new PixelSize(context.GetValue("Width"), context.GetValue("Height"));
}
+ _pendingRecoveredElementIdMigrations.Clear();
+ _pendingRecoveredDescendantIdMigrations.Clear();
+ _idlessRecoveredDescendants.Clear();
+ _recoveredDescendantIds.Clear();
+ _recoveredDescendantIdentities.Clear();
+ _recoveredDescendantRemaps.Clear();
+ _recoveredElementIds.Clear();
+ if (context.GetValue(RecoveredElementIdsKey) is JsonObject recoveredElementIds)
+ {
+ foreach ((string path, JsonNode? idNode) in recoveredElementIds)
+ {
+ if (idNode is JsonValue idValue
+ && idValue.TryGetValue(out string? idText)
+ && Guid.TryParse(idText, out Guid id))
+ {
+ _recoveredElementIds[NormalizeRelativePath(path)] = id;
+ }
+ }
+ }
+
+ if (context.GetValue(RecoveredDescendantIdsKey) is JsonObject recoveredDescendantIds)
+ {
+ foreach ((string key, JsonNode? idNode) in recoveredDescendantIds)
+ {
+ if (idNode is JsonValue idValue
+ && idValue.TryGetValue(out string? idText)
+ && Guid.TryParse(idText, out Guid id))
+ {
+ _recoveredDescendantIds[NormalizeRelativePath(key)] = id;
+ }
+ }
+ }
+
+ if (context.GetValue(RecoveredDescendantIdentitiesKey) is JsonObject recoveredDescendantIdentities)
+ {
+ foreach ((string key, JsonNode? idNode) in recoveredDescendantIdentities)
+ {
+ if (idNode is JsonValue idValue
+ && idValue.TryGetValue(out string? idText)
+ && Guid.TryParse(idText, out Guid id))
+ {
+ _recoveredDescendantIdentities[key] = id;
+ }
+ }
+ }
+
+ Markers.Clear();
+ if (context.Contains(nameof(Markers))
+ && context.GetValue(nameof(Markers)) is { } markers)
+ {
+ Markers.AddRange(markers);
+ }
+
if (context.GetValue(nameof(Elements)) is { } elementsJson)
{
if (elementsJson is JsonObject elementsObject)
@@ -637,6 +762,7 @@ static void Process(Func add, JsonNode node, List list)
{
var ids = group.Split(':')
.Select(s => Guid.TryParse(s, out Guid id) ? id : Guid.Empty)
+ .Select(id => _pendingRecoveredElementIdMigrations.GetValueOrDefault(id, id))
.Where(i => i != Guid.Empty && Children.Any(e => e.Id == i))
.ToImmutableHashSet();
if (ids.Count >= 2)
@@ -646,12 +772,6 @@ static void Process(Func add, JsonNode node, List list)
}
}
- Markers.Clear();
- if (context.Contains(nameof(Markers))
- && context.GetValue(nameof(Markers)) is { } markers)
- {
- Markers.AddRange(markers);
- }
}
private void SyncronizeFiles(IEnumerable pathToElement)
@@ -670,13 +790,1689 @@ private void SyncronizeFiles(IEnumerable pathToElement)
Children.Remove(item);
}
- Children.AddRange(urisAdd.AsParallel().Select(CoreSerializer.RestoreFromUri));
+ Children.AddRange(urisAdd.AsParallel().Select(RestoreElementOrFallback));
+ ReassignDuplicateRecoveredIds();
+ MigrateRecoveredElementReferences();
activity?.SetTag("addCount", urisAdd.Length);
activity?.SetTag("removeCount", elementsRemove.Length);
activity?.SetTag("childrenCount", Children.Count);
}
+ private void ReassignDuplicateRecoveredIds()
+ {
+ string sceneDirectory = Path.GetDirectoryName(Uri!.LocalPath)!;
+ var recoveredChildren = Children
+ .Where(static child => child.SuppressedStorageSource is not null)
+ .Select(child => (
+ Child: child,
+ RelativePath: NormalizeRelativePath(
+ Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath))))
+ .OrderBy(static item => item.RelativePath, StringComparer.Ordinal)
+ .ToArray();
+
+ var claimedIds = new HashSet { Guid.Empty, Id };
+ foreach (CoreObject sceneObject in Layers.Cast().Concat(Markers))
+ {
+ foreach (CoreObject graphObject in EnumerateSerializedGraphObjects(sceneObject).OfType())
+ {
+ claimedIds.Add(graphObject.Id);
+ }
+ }
+
+ var seenDescendants = new HashSet(ReferenceEqualityComparer.Instance);
+ var persistedDescendantIds = new Dictionary(
+ _recoveredDescendantIds,
+ StringComparer.Ordinal);
+ var persistedDescendantIdentities = new Dictionary(
+ _recoveredDescendantIdentities,
+ StringComparer.Ordinal);
+ var healthyChildren = Children
+ .Where(static child => child.SuppressedStorageSource is null)
+ .Select(child => (
+ Child: child,
+ RelativePath: NormalizeRelativePath(
+ Path.GetRelativePath(sceneDirectory, child.Uri!.LocalPath))))
+ .OrderBy(static item => item.RelativePath, StringComparer.Ordinal)
+ .ToArray();
+
+ void ClaimHealthyDescendants(Element child)
+ {
+ foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child))
+ {
+ seenDescendants.Add(descendant);
+ claimedIds.Add(descendant.Id);
+ }
+ }
+
+ void ClaimPreviouslyRecoveredHealthyDescendants(Element child, string relativePath)
+ {
+ var occurrences = new Dictionary();
+ var legacyIndices = new Dictionary(ReferenceEqualityComparer.Instance);
+ int legacyIndex = 0;
+ foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child))
+ {
+ legacyIndices.TryAdd(descendant, legacyIndex++);
+ }
+
+ foreach ((CoreObject descendant, SerializedGraphPath graphPath) in
+ EnumerateSerializedGraphDescendantPaths(child))
+ {
+ if (!seenDescendants.Add(descendant))
+ {
+ continue;
+ }
+
+ string identityKey = CreateRecoveredDescendantIdentityKey(relativePath, graphPath.Stable);
+ Guid originalId = descendant.Id;
+ int occurrence = occurrences.GetValueOrDefault(originalId);
+ occurrences[originalId] = occurrence + 1;
+ string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence);
+ bool hasPersistedId = persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId);
+ bool hasPersistedIdentity = persistedDescendantIdentities.TryGetValue(
+ identityKey,
+ out Guid persistedIdentityId);
+ bool ambiguousPositionalIdentity = false;
+ if (!hasPersistedIdentity && graphPath.Positional != graphPath.Stable)
+ {
+ hasPersistedIdentity = TryGetRecoveredDescendantPositionalIdentity(
+ persistedDescendantIdentities,
+ relativePath,
+ graphPath.Positional,
+ out persistedIdentityId,
+ out ambiguousPositionalIdentity);
+ }
+
+ if (!hasPersistedIdentity
+ && !ambiguousPositionalIdentity
+ && legacyIndices.TryGetValue(descendant, out int persistedIndex))
+ {
+ string legacyIdentityKey = CreateLegacyRecoveredDescendantIdentityKey(
+ relativePath,
+ persistedIndex);
+ hasPersistedIdentity = persistedDescendantIdentities.TryGetValue(
+ legacyIdentityKey,
+ out persistedIdentityId);
+ }
+
+ bool hasPreviousAssignedId = hasPersistedId || hasPersistedIdentity;
+ Guid previousAssignedId = hasPersistedId ? persistedId : persistedIdentityId;
+
+ if (claimedIds.Add(originalId))
+ {
+ if (hasPreviousAssignedId)
+ {
+ _pendingRecoveredDescendantIdMigrations.TryAdd(previousAssignedId, originalId);
+ }
+
+ continue;
+ }
+
+ Guid assignedId = hasPreviousAssignedId && claimedIds.Add(previousAssignedId)
+ ? previousAssignedId
+ : ClaimRecoveredDescendantId(relativePath, remapKey, claimedIds);
+ descendant.Id = assignedId;
+ _pendingRecoveredDescendantIdMigrations.TryAdd(
+ hasPreviousAssignedId ? previousAssignedId : assignedId,
+ assignedId);
+ }
+ }
+
+ foreach ((Element child, string relativePath) in healthyChildren
+ .Where(item => !_recoveredElementIds.ContainsKey(item.RelativePath)))
+ {
+ claimedIds.Add(child.Id);
+ ClaimHealthyDescendants(child);
+ }
+
+ foreach ((Element child, string relativePath) in healthyChildren
+ .Where(item => _recoveredElementIds.ContainsKey(item.RelativePath)))
+ {
+ if (!claimedIds.Add(child.Id))
+ {
+ Guid placeholderId = _recoveredElementIds[relativePath];
+ child.Id = claimedIds.Add(placeholderId)
+ ? placeholderId
+ : ClaimRecoveredElementId(relativePath, claimedIds);
+ }
+
+ ClaimPreviouslyRecoveredHealthyDescendants(child, relativePath);
+ }
+
+ foreach ((Element child, string relativePath) in healthyChildren)
+ {
+ if (_recoveredElementIds.Remove(relativePath, out Guid placeholderId))
+ {
+ _pendingRecoveredElementIdMigrations.TryAdd(placeholderId, child.Id);
+ }
+ }
+
+ var recoveredPaths = recoveredChildren
+ .Select(static item => item.RelativePath)
+ .ToHashSet(StringComparer.Ordinal);
+ foreach (string path in _recoveredElementIds.Keys.Where(path => !recoveredPaths.Contains(path)).ToArray())
+ {
+ _recoveredElementIds.Remove(path);
+ }
+
+ var persistedChildren = new HashSet();
+ foreach ((Element child, string relativePath) in recoveredChildren)
+ {
+ if (_recoveredElementIds.TryGetValue(relativePath, out Guid persistedId))
+ {
+ // A persisted remap that a healthy element now owns is stale; drop it so the
+ // derivation loop assigns a fresh deterministic identity instead of a duplicate.
+ if (claimedIds.Add(persistedId))
+ {
+ child.Id = persistedId;
+ persistedChildren.Add(child);
+ }
+ else
+ {
+ _recoveredElementIds.Remove(relativePath);
+ }
+ }
+ }
+
+ foreach ((Element child, string relativePath) in recoveredChildren)
+ {
+ if (persistedChildren.Contains(child))
+ {
+ continue;
+ }
+
+ if (claimedIds.Add(child.Id))
+ {
+ continue;
+ }
+
+ child.Id = ClaimRecoveredElementId(relativePath, claimedIds);
+ }
+
+ foreach ((Element child, string relativePath) in recoveredChildren)
+ {
+ _recoveredElementIds[relativePath] = child.Id;
+ }
+
+ _recoveredDescendantIds.Clear();
+ _recoveredDescendantRemaps.Clear();
+ var pendingDescendantRemaps
+ = new Dictionary(
+ ReferenceEqualityComparer.Instance);
+ foreach ((Element child, string relativePath) in recoveredChildren)
+ {
+ var occurrences = new Dictionary();
+ int idlessOccurrence = 0;
+ foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child))
+ {
+ if (!seenDescendants.Add(descendant))
+ {
+ continue;
+ }
+
+ bool idless = _idlessRecoveredDescendants.TryGetValue(descendant, out _);
+ Guid originalId = idless ? Guid.Empty : descendant.Id;
+ int occurrence = idless
+ ? idlessOccurrence++
+ : occurrences.GetValueOrDefault(originalId);
+ if (!idless)
+ {
+ occurrences[originalId] = occurrence + 1;
+ }
+
+ string remapKey = CreateRecoveredDescendantKey(relativePath, originalId, occurrence);
+ if (persistedDescendantIds.TryGetValue(remapKey, out Guid persistedId))
+ {
+ if (claimedIds.Add(persistedId))
+ {
+ descendant.Id = persistedId;
+ RecordRecoveredDescendantRemap(
+ descendant,
+ remapKey,
+ originalId,
+ persistedId,
+ occurrence);
+ continue;
+ }
+
+ pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence);
+ }
+ else if (!claimedIds.Add(originalId))
+ {
+ pendingDescendantRemaps[descendant] = (remapKey, originalId, occurrence);
+ }
+ }
+ }
+
+ foreach ((Element child, string relativePath) in recoveredChildren)
+ {
+ foreach (CoreObject descendant in EnumerateSerializedGraphDescendants(child))
+ {
+ if (!pendingDescendantRemaps.Remove(
+ descendant,
+ out (string RemapKey, Guid OriginalId, int Occurrence) remap))
+ {
+ continue;
+ }
+
+ Guid candidate = ClaimRecoveredDescendantId(relativePath, remap.RemapKey, claimedIds);
+ descendant.Id = candidate;
+ RecordRecoveredDescendantRemap(
+ descendant,
+ remap.RemapKey,
+ remap.OriginalId,
+ candidate,
+ remap.Occurrence);
+
+ }
+ }
+
+ // A global OriginalId -> AssignedId migration would redirect every reference that still
+ // targets a surviving object. Only migrate when the original ID was abandoned entirely;
+ // otherwise the references keep pointing at the object that retained it.
+ var retainedIds = new HashSet { Guid.Empty, Id };
+ foreach (CoreObject graphObject in EnumerateSerializedGraphObjects(Children).OfType())
+ {
+ retainedIds.Add(graphObject.Id);
+ }
+
+ foreach (CoreObject sceneObject in Layers.Cast().Concat(Markers))
+ {
+ foreach (CoreObject graphObject in EnumerateSerializedGraphObjects(sceneObject).OfType())
+ {
+ retainedIds.Add(graphObject.Id);
+ }
+ }
+
+ foreach (Guid originalId in _pendingRecoveredElementIdMigrations.Keys.ToArray())
+ {
+ if (_pendingRecoveredElementIdMigrations[originalId] != originalId
+ && retainedIds.Contains(originalId))
+ {
+ _pendingRecoveredElementIdMigrations.Remove(originalId);
+ }
+ }
+
+ foreach (Guid originalId in _pendingRecoveredDescendantIdMigrations.Keys.ToArray())
+ {
+ if (_pendingRecoveredDescendantIdMigrations[originalId] != originalId
+ && retainedIds.Contains(originalId))
+ {
+ _pendingRecoveredDescendantIdMigrations.Remove(originalId);
+ }
+ }
+ }
+
+ private static Guid ClaimRecoveredElementId(string relativePath, ISet claimedIds)
+ {
+ for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++)
+ {
+ string candidateName = attempt == 0
+ ? relativePath
+ : $"{relativePath}#{attempt}";
+ Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName);
+ if (claimedIds.Add(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Could not assign a unique recovered element Id for '{relativePath}'.");
+ }
+
+ private static Guid ClaimRecoveredDescendantId(
+ string relativePath,
+ string remapKey,
+ ISet claimedIds)
+ {
+ for (int attempt = 0; attempt < MaxRecoveredIdCollisionAttempts; attempt++)
+ {
+ string candidateName = attempt == 0
+ ? remapKey
+ : $"{remapKey}#{attempt}";
+ Guid candidate = CreateVersion5Guid(s_recoveredElementNamespace, candidateName);
+ if (claimedIds.Add(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ throw new InvalidOperationException(
+ $"Could not assign a unique recovered descendant Id for '{relativePath}'.");
+ }
+
+ private void RecordRecoveredDescendantRemap(
+ CoreObject descendant,
+ string remapKey,
+ Guid originalId,
+ Guid assignedId,
+ int occurrence)
+ {
+ _recoveredDescendantIds[remapKey] = assignedId;
+ _recoveredDescendantRemaps.Remove(descendant);
+ _recoveredDescendantRemaps.Add(
+ descendant,
+ new RecoveredDescendantRemap(originalId, assignedId, occurrence));
+ if (originalId != Guid.Empty)
+ {
+ _pendingRecoveredDescendantIdMigrations.TryAdd(originalId, assignedId);
+ }
+
+ if (descendant is IFallback fallback)
+ {
+ EnsureFallbackProjection(fallback);
+ }
+ }
+
+ private Element RestoreElementOrFallback(Uri uri)
+ {
+ using DeserializationIncidents.Capture incidentCapture = DeserializationIncidents.BeginCapture();
+ try
+ {
+ Element element = CoreSerializer.RestoreFromUri(uri);
+ IFallback[] fallbacks = EnumerateSerializedGraphFallbacks(element).ToArray();
+ int incidentCount = incidentCapture.Count;
+
+ if (fallbacks.Length > 0 || incidentCount > 0)
+ {
+ var traversedFallbacks = new HashSet(
+ fallbacks,
+ ReferenceEqualityComparer.Instance);
+ DeserializationIncidents.DeserializationIncident[] untraversedIncidents
+ = incidentCapture.Incidents
+ .Where(incident => incident.Fallback is null
+ || !traversedFallbacks.Contains(incident.Fallback))
+ .ToArray();
+ JsonObject[] untraversedFallbacks = untraversedIncidents
+ .Where(static incident => incident.Fallback?.Json != null)
+ .Select(static incident => incident.Fallback!.Json!.DeepClone().AsObject())
+ .ToArray();
+ SuppressedRecoveryIncident[] recoveryIncidents = untraversedIncidents
+ .Select(CreateSuppressedRecoveryIncident)
+ .ToArray();
+ foreach (IFallback fallback in fallbacks)
+ {
+ if (fallback is CoreObject fallbackObject)
+ {
+ if (TryGetSerializedId(fallback.Json, out Guid serializedId))
+ {
+ fallbackObject.Id = serializedId;
+ }
+ else
+ {
+ _idlessRecoveredDescendants.GetValue(
+ fallbackObject,
+ static _ => new IdlessRecoveredDescendant());
+ }
+ }
+
+ EnsureFallbackProjection(fallback);
+ }
+
+ MarkRecoveredElement(
+ element,
+ File.ReadAllBytes(uri.LocalPath),
+ uri,
+ recoveryIncidents.Length > 0,
+ untraversedFallbacks,
+ recoveryIncidents);
+ }
+
+ return element;
+ }
+ catch (Exception ex) when (!ExceptionHelpers.ContainsFatalFailure(ex)
+ && !ExceptionHelpers.ContainsNonRecoverableFileSystemFailure(ex))
+ {
+ // Raw bytes, not text: the sidecar must survive rehoming byte-identically even when it
+ // holds a BOM, another encoding, or undecodable bytes. The lossy decode is only scanned
+ // for top-level recovery metadata.
+ byte[] rawBytes = File.ReadAllBytes(uri.LocalPath);
+ string rawText = DecodeRecoveryMetadata(rawBytes);
+ byte[] metadataBytes = Encoding.UTF8.GetBytes(rawText);
+ JsonObject? root = TryParseTopLevelObject(rawText);
+ var element = new Element
+ {
+ Id = ResolveRecoveredElementId(metadataBytes, rawText, root, uri),
+ Name = Path.GetFileNameWithoutExtension(uri.LocalPath),
+ Uri = uri,
+ IsEnabled = false,
+ };
+ string? topLevelTypeName = TryGetTopLevelTypeName(metadataBytes, rawText, root);
+ FallbackReason fallbackReason = topLevelTypeName is not null
+ && TypeFormat.ToType(topLevelTypeName) is null
+ ? FallbackReason.TypeNotFound
+ : FallbackReason.DeserializationFailed;
+ var fallback = new FallbackEngineObject
+ {
+ Name = "Unreadable element data",
+ Reason = fallbackReason,
+ ErrorMessage = fallbackReason == FallbackReason.DeserializationFailed
+ ? $"{ex.GetType().Name}: {ex.Message}"
+ : null,
+ };
+ fallback.Json = CreateFallbackProjection(fallback, topLevelTypeName);
+ element.AddObject(fallback);
+ _idlessRecoveredDescendants.GetValue(
+ fallback,
+ static _ => new IdlessRecoveredDescendant());
+ MarkRecoveredElement(element, rawBytes, uri);
+ return element;
+ }
+ }
+
+ private static bool TryGetSerializedId(JsonObject? json, out Guid id)
+ {
+ id = Guid.Empty;
+ return json is not null
+ && json.TryGetPropertyValue(nameof(CoreObject.Id), out JsonNode? idNode)
+ && idNode is JsonValue idValue
+ && idValue.TryGetValue(out string? idText)
+ && Guid.TryParse(idText, out id)
+ && id != Guid.Empty;
+ }
+
+ private sealed class IdlessRecoveredDescendant;
+
+ private void MarkRecoveredElement(
+ Element element,
+ byte[] rawBytes,
+ Uri uri,
+ bool hasNonFallbackIncidents = false,
+ JsonObject[]? untraversedFallbacks = null,
+ SuppressedRecoveryIncident[]? recoveryIncidents = null)
+ {
+ string sourceRootPath = Path.GetDirectoryName(Uri?.LocalPath ?? uri.LocalPath)
+ ?? throw new JsonException("Recovered element has no source directory.");
+ element.SuppressedStorageSource = new SuppressedStorageSource(
+ rawBytes,
+ uri,
+ hasNonFallbackIncidents,
+ untraversedFallbacks,
+ CollectReferencedStorageSources(element, uri, sourceRootPath),
+ sourceRootPath,
+ recoveryIncidents);
+ }
+
+ private static SuppressedRecoveryIncident CreateSuppressedRecoveryIncident(
+ DeserializationIncidents.DeserializationIncident incident)
+ {
+ if (incident.Fallback is { } fallback)
+ {
+ fallback.TryGetTypeName(out string? typeName);
+ return new SuppressedRecoveryIncident(
+ fallback.Reason.ToString(),
+ typeName,
+ fallback.ErrorMessage);
+ }
+
+ return new SuppressedRecoveryIncident(
+ incident.Reason?.ToString() ?? nameof(FallbackReason.DeserializationFailed),
+ incident.TypeName,
+ incident.Message);
+ }
+
+ private static SuppressedReferencedStorageSource[]? CollectReferencedStorageSources(
+ Element element,
+ Uri elementUri,
+ string sourceRootPath)
+ {
+ string sourceRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceRootPath));
+ string resolvedSourceRoot = Path.TrimEndingDirectorySeparator(
+ PathBoundary.ResolveDeepestExistingTarget(sourceRoot));
+ string elementPath = Path.GetFullPath(elementUri.LocalPath);
+ string resolvedElementPath = PathBoundary.ResolveDeepestExistingTarget(elementPath);
+ if (!PathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedElementPath))
+ {
+ return null;
+ }
+
+ string elementDirectory = Path.GetDirectoryName(elementPath)
+ ?? throw new JsonException("Recovered element has no source directory.");
+ var seenPaths = new HashSet(PathBoundary.Comparer);
+ var result = new List();
+ foreach (string sourcePath in EnumerateSerializedGraphObjects(element)
+ .OfType()
+ .Where(static coreObject => coreObject.Uri is { IsFile: true })
+ .Select(static coreObject => Path.GetFullPath(coreObject.Uri!.LocalPath)))
+ {
+ string resolvedSourcePath = PathBoundary.ResolveDeepestExistingTarget(sourcePath);
+ if (string.Equals(
+ resolvedSourcePath,
+ resolvedElementPath,
+ PathBoundary.Comparison)
+ || !PathBoundary.IsPathInsideRoot(resolvedSourceRoot, resolvedSourcePath)
+ || !File.Exists(sourcePath)
+ || !seenPaths.Add(resolvedSourcePath))
+ {
+ continue;
+ }
+
+ result.Add(new SuppressedReferencedStorageSource(
+ File.ReadAllBytes(sourcePath),
+ Path.GetRelativePath(sourceRoot, sourcePath),
+ Path.GetRelativePath(elementDirectory, sourcePath)));
+ }
+
+ return result.Count > 0 ? result.ToArray() : null;
+ }
+
+ internal static SuppressedStorageSource? TryResumeElementPersistence(Element element)
+ {
+ if (element.SuppressedStorageSource is not { } source
+ || EnumerateSerializedGraphFallbacks(element).Any()
+ || HasUnresolvedUntraversedFallback(element, source)
+ || EnumerateSerializedGraphObjects(element).OfType().Any(static keyFrame => keyFrame.HasLossyEasing))
+ {
+ return null;
+ }
+
+ element.SuppressedStorageSource = null;
+ return source;
+ }
+
+ internal static SuppressedStorageSource? TryResumeElementPersistence(
+ Element element,
+ object? removedValue)
+ {
+ if (element.SuppressedStorageSource is not { } source
+ || !MayContainRemovedRecoveryBlocker(element, removedValue, source))
+ {
+ return null;
+ }
+
+ return TryResumeElementPersistence(element);
+ }
+
+ private static bool MayContainRemovedRecoveryBlocker(
+ Element element,
+ object? removedValue,
+ SuppressedStorageSource source)
+ {
+ if (removedValue is null)
+ {
+ return false;
+ }
+
+ if (SerializedGraphTraversal.Enumerate(removedValue).Any(static value =>
+ value is IFallback or KeyFrame { HasLossyEasing: true }))
+ {
+ return true;
+ }
+
+ if (source.UntraversedFallbacks is not { Length: > 0 } snapshots)
+ {
+ return false;
+ }
+
+ try
+ {
+ JsonNode serializedValue = CoreSerializer.SerializeToJsonNode(
+ removedValue,
+ new CoreSerializerOptions { BaseUri = element.Uri });
+ return snapshots.Any(snapshot => ContainsEquivalentJsonNode(serializedValue, snapshot));
+ }
+ catch (Exception ex) when (!ExceptionHelpers.ContainsFatalFailure(ex))
+ {
+ return true;
+ }
+ }
+
+ private static bool HasUnresolvedUntraversedFallback(
+ Element element,
+ SuppressedStorageSource source)
+ {
+ if (source.UntraversedFallbacks is not { Length: > 0 } snapshots)
+ {
+ return false;
+ }
+
+ JsonObject current = CoreSerializer.SerializeToJsonObject(
+ element,
+ new CoreSerializerOptions { BaseUri = element.Uri });
+ return snapshots.Any(snapshot => ContainsEquivalentJsonNode(current, snapshot));
+ }
+
+ private static bool ContainsEquivalentJsonNode(JsonNode? current, JsonNode snapshot)
+ {
+ if (JsonNode.DeepEquals(current, snapshot))
+ {
+ return true;
+ }
+
+ return current switch
+ {
+ JsonObject obj => obj.Any(item =>
+ item.Value != null && ContainsEquivalentJsonNode(item.Value, snapshot)),
+ JsonArray array => array.Any(item =>
+ item != null && ContainsEquivalentJsonNode(item, snapshot)),
+ _ => false,
+ };
+ }
+
+ private static IEnumerable EnumerateSerializedGraphFallbacks(Element element)
+ {
+ return EnumerateSerializedGraphObjects(element).OfType();
+ }
+
+ private static IEnumerable EnumerateSerializedGraphDescendants(Element element)
+ {
+ return EnumerateSerializedGraphObjects(element)
+ .OfType()
+ .Where(value => !ReferenceEquals(value, element));
+ }
+
+ private void MigrateRecoveredElementReferences()
+ {
+ if (_pendingRecoveredElementIdMigrations.Count == 0
+ && _pendingRecoveredDescendantIdMigrations.Count == 0)
+ {
+ return;
+ }
+
+ var referenceTargets = new Dictionary();
+ foreach (CoreObject candidate in EnumerateSerializedGraphObjects(Children).OfType())
+ {
+ referenceTargets.TryAdd(candidate.Id, candidate);
+ }
+
+ var rewriteState = new RecoveredReferenceRewriteState(referenceTargets);
+ foreach (CoreObject coreObject in EnumerateSerializedGraphObjects(this).OfType())
+ {
+ foreach (CoreProperty property in PropertyRegistry.GetRegistered(coreObject.GetType()))
+ {
+ if (!property.GetMetadata(coreObject.GetType()).ShouldSerialize)
+ {
+ continue;
+ }
+
+ object? currentValue = coreObject.GetValue(property);
+ object? migratedValue = MigrateRecoveredReferenceValue(currentValue, rewriteState);
+ if (HasReferenceRewrite(currentValue, migratedValue)
+ && property is not IStaticProperty { CanWrite: false })
+ {
+ coreObject.ReplaceValue(property, migratedValue);
+ }
+ }
+
+ if (coreObject is not EngineObject engineObject)
+ {
+ continue;
+ }
+
+ foreach (IProperty property in engineObject.Properties)
+ {
+ object? currentValue = property.CurrentValue;
+ object? migratedValue = MigrateRecoveredReferenceValue(currentValue, rewriteState);
+ if (HasReferenceRewrite(currentValue, migratedValue))
+ {
+ property.ReplaceCurrentValue(migratedValue);
+ }
+
+ if (property.Expression is IReferenceExpression referenceExpression
+ && TryGetMigratedId(referenceExpression.ObjectId, out Guid migratedExpressionId)
+ && referenceExpression.Rebind(migratedExpressionId) is { } reboundExpression)
+ {
+ property.Expression = (IExpression)reboundExpression;
+ }
+
+ if (property.Animation is IKeyFrameAnimation animation)
+ {
+ foreach (IKeyFrame keyFrame in animation.KeyFrames)
+ {
+ object? keyFrameValue = keyFrame.Value;
+ object? migratedKeyFrameValue = MigrateRecoveredReferenceValue(
+ keyFrameValue,
+ rewriteState);
+ if (HasReferenceRewrite(keyFrameValue, migratedKeyFrameValue))
+ {
+ keyFrame.ReplaceValue(migratedKeyFrameValue);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private bool TryGetMigratedId(Guid originalId, out Guid migratedId)
+ {
+ return _pendingRecoveredElementIdMigrations.TryGetValue(originalId, out migratedId)
+ || _pendingRecoveredDescendantIdMigrations.TryGetValue(originalId, out migratedId);
+ }
+
+ private static object ResolveMigratedReference(
+ IReference reference,
+ Guid migratedId,
+ RecoveredReferenceRewriteState state)
+ {
+ if (state.ReferenceTargets.TryGetValue(migratedId, out CoreObject? target)
+ && reference.ObjectType.IsInstanceOfType(target))
+ {
+ return reference.Resolved(target);
+ }
+
+ return reference;
+ }
+
+ private object? MigrateRecoveredReferenceValue(
+ object? value,
+ RecoveredReferenceRewriteState state)
+ {
+ if (value is IReference reference)
+ {
+ object rewritten = TryGetMigratedId(reference.Id, out Guid migratedId)
+ ? ResolveMigratedReference(reference, migratedId, state)
+ : value;
+ if (HasReferenceRewrite(value, rewritten))
+ {
+ state.RecordRewrite();
+ }
+
+ return rewritten;
+ }
+
+ if (value is IOptional { HasValue: true } optional)
+ {
+ object? item = optional.ToObject().Value;
+ object? migratedItem = MigrateRecoveredReferenceValue(item, state);
+ if (HasReferenceRewrite(item, migratedItem))
+ {
+ try
+ {
+ ConstructorInfo? constructor = value.GetType().GetConstructor([optional.GetValueType()]);
+ return constructor?.Invoke([migratedItem]) ?? value;
+ }
+ catch (Exception ex) when (ex is TargetInvocationException
+ or ArgumentException
+ or MemberAccessException)
+ {
+ return value;
+ }
+ }
+
+ return value;
+ }
+
+ if (value is null or string)
+ {
+ return value;
+ }
+
+ bool trackReference = !value.GetType().IsValueType;
+ if (trackReference)
+ {
+ if (state.Memo.TryGetValue(value, out RecoveredReferenceRewriteEntry? cached))
+ {
+ if (state.ActiveRewritables.TryPeek(out RecoveredReferenceRewriteEntry? parent))
+ {
+ parent.Dependencies.Add(cached);
+ }
+
+ return cached.ShouldUseTargetDuringTraversal()
+ ? cached.Target
+ : cached.Source;
+ }
+
+ if (!state.Active.Add(value))
+ {
+ return value;
+ }
+ }
+
+ try
+ {
+ object? rewrittenValue = value;
+ if (value is IReferenceRewritable rewritable)
+ {
+ IReferenceRewritable target = rewritable.CreateReferenceRewriteTarget();
+ if (target is not null && target.GetType() == value.GetType())
+ {
+ var entry = new RecoveredReferenceRewriteEntry(value, target);
+ if (state.ActiveRewritables.TryPeek(out RecoveredReferenceRewriteEntry? parent))
+ {
+ parent.Dependencies.Add(entry);
+ }
+
+ state.Memo[value] = entry;
+ state.ActiveRewritables.Push(entry);
+ try
+ {
+ target.RewriteReferences(new RecoveredReferenceRewriteContext(this, state));
+ }
+ finally
+ {
+ state.ActiveRewritables.Pop();
+ }
+
+ entry.Complete = true;
+ if (entry.ShouldUseTargetDuringTraversal())
+ {
+ rewrittenValue = target;
+ }
+ }
+ }
+ else if (value is IDictionary dictionary)
+ {
+ int rewriteCount = state.RewriteCount;
+ foreach (object key in dictionary.Keys.Cast