diff --git a/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs b/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs index 1b28c2b05..536bc7e5a 100644 --- a/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs +++ b/src/Beutl.Engine.SourceGenerators/EngineObjectResourceGenerator.cs @@ -551,24 +551,27 @@ private static void AppendResourceClass(StringBuilder sb, string indent, string sb.Append(innerIndent).AppendLine("protected override void Dispose(bool disposing)"); sb.Append(innerIndent).AppendLine("{"); sb.Append(innerIndent).AppendLine(" this.PreDispose(disposing);"); + sb.Append(innerIndent).AppendLine(" if (disposing)"); + sb.Append(innerIndent).AppendLine(" {"); foreach (ObjectPropertyInfo property in info.ObjectProperties) { string fieldName = ToFieldName(property.Name); - sb.Append(innerIndent).AppendLine($" {fieldName}?.Dispose();"); + sb.Append(innerIndent).AppendLine($" {fieldName}?.Dispose();"); } foreach (ListPropertyInfo property in info.ListProperties) { string fieldName = ToFieldName(property.Name); - sb.Append(innerIndent).AppendLine($" if ({fieldName} != null)"); - sb.Append(innerIndent).AppendLine(" {"); - sb.Append(innerIndent).AppendLine($" foreach (var item in {fieldName})"); + sb.Append(innerIndent).AppendLine($" if ({fieldName} != null)"); sb.Append(innerIndent).AppendLine(" {"); - sb.Append(innerIndent).AppendLine(" item?.Dispose();"); + sb.Append(innerIndent).AppendLine($" foreach (var item in {fieldName})"); + sb.Append(innerIndent).AppendLine(" {"); + sb.Append(innerIndent).AppendLine(" item?.Dispose();"); + sb.Append(innerIndent).AppendLine(" }"); + sb.Append(innerIndent).AppendLine(" "); sb.Append(innerIndent).AppendLine(" }"); - sb.Append(innerIndent).AppendLine(" "); - sb.Append(innerIndent).AppendLine(" }"); } + sb.Append(innerIndent).AppendLine(" }"); sb.Append(innerIndent).AppendLine(" this.PostDispose(disposing);"); sb.Append(innerIndent).AppendLine(" base.Dispose(disposing);"); sb.Append(innerIndent).AppendLine("}"); diff --git a/src/Beutl.Engine/Audio/Composing/Composer.cs b/src/Beutl.Engine/Audio/Composing/Composer.cs index a2418b503..c7fe9309a 100644 --- a/src/Beutl.Engine/Audio/Composing/Composer.cs +++ b/src/Beutl.Engine/Audio/Composing/Composer.cs @@ -70,12 +70,12 @@ protected virtual void ComposeCore(TimeRange timeRange) } } - protected void AddSound(Sound sound) + public void AddSound(Sound sound) { _currentSounds.Add(sound); } - protected void ClearSounds() + public void ClearSounds() { _currentSounds.Clear(); } @@ -88,9 +88,6 @@ protected void ClearSounds() { IsAudioRendering = true; - // Clear previous sounds list - _currentSounds.Clear(); - // Let subclass populate sounds ComposeCore(timeRange); diff --git a/src/Beutl.Engine/Engine/EngineObject.cs b/src/Beutl.Engine/Engine/EngineObject.cs index c6b20d40c..533f3d512 100644 --- a/src/Beutl.Engine/Engine/EngineObject.cs +++ b/src/Beutl.Engine/Engine/EngineObject.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.ComponentModel; using System.Reactive.Disposables; using System.Reflection; @@ -220,8 +221,7 @@ protected void ScanProperties() where T : EngineObject { if (!typeof(IProperty).IsAssignableFrom(propertyInfo.PropertyType)) continue; - var func = ReflectionCache.Properties.FirstOrDefault(x => x.Item1 == propertyInfo).Item2; - if (func == null) + if (!ReflectionCache.Properties.TryGetValue(propertyInfo, out var func)) { var param = LinqExpression.Parameter(typeof(object), "o"); var cast = LinqExpression.Convert(param, type); @@ -229,7 +229,7 @@ protected void ScanProperties() where T : EngineObject var convertResult = LinqExpression.Convert(propertyAccess, typeof(IProperty)); var lambda = LinqExpression.Lambda>(convertResult, param); func = lambda.Compile(); - ReflectionCache.Properties.Add((propertyInfo, func)); + ReflectionCache.Properties[propertyInfo] = func; } var property = func(this); @@ -451,7 +451,7 @@ public void Dispose() private static class ReflectionCache { - public static readonly List<(PropertyInfo, Func)> Properties = new(); - public static readonly Dictionary Validators = new(); + public static readonly ConcurrentDictionary> Properties = new(); + public static readonly ConcurrentDictionary Validators = new(); } } diff --git a/src/Beutl.Engine/Graphics/SourceVideo.cs b/src/Beutl.Engine/Graphics/SourceVideo.cs index bdf0f7311..699e97183 100644 --- a/src/Beutl.Engine/Graphics/SourceVideo.cs +++ b/src/Beutl.Engine/Graphics/SourceVideo.cs @@ -169,6 +169,11 @@ protected override void OnDraw(GraphicsContext2D context, Drawable.Resource reso } } + internal void DrawInternal(GraphicsContext2D context, Drawable.Resource resource) + { + OnDraw(context, resource); + } + public partial class Resource { public TimeSpan RenderedPosition { get; internal set; } diff --git a/src/Beutl.Engine/Properties/AssemblyInfo.cs b/src/Beutl.Engine/Properties/AssemblyInfo.cs index 559ef9493..ed29643ed 100644 --- a/src/Beutl.Engine/Properties/AssemblyInfo.cs +++ b/src/Beutl.Engine/Properties/AssemblyInfo.cs @@ -1,6 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Beutl")] +[assembly: InternalsVisibleTo("Beutl.Operators")] [assembly: InternalsVisibleTo("Beutl.Controls")] [assembly: InternalsVisibleTo("Beutl.ProjectSystem")] [assembly: InternalsVisibleTo("Beutl.UnitTests")] diff --git a/src/Beutl.Language/Strings.Designer.cs b/src/Beutl.Language/Strings.Designer.cs index 6c7da4030..38731e0cb 100644 --- a/src/Beutl.Language/Strings.Designer.cs +++ b/src/Beutl.Language/Strings.Designer.cs @@ -1293,6 +1293,12 @@ public static string UseNode { } } + public static string DisableThumbnails { + get { + return ResourceManager.GetString("DisableThumbnails", resourceCulture); + } + } + public static string Unsupported { get { return ResourceManager.GetString("Unsupported", resourceCulture); @@ -2660,25 +2666,25 @@ public static string Convert_to_preset { return ResourceManager.GetString("Convert_to_preset", resourceCulture); } } - + public static string Geometry { get { return ResourceManager.GetString("Geometry", resourceCulture); } } - + public static string Progress { get { return ResourceManager.GetString("Progress", resourceCulture); } } - + public static string FollowRotation { get { return ResourceManager.GetString("FollowRotation", resourceCulture); } } - + public static string PathFollowEffect { get { return ResourceManager.GetString("PathFollowEffect", resourceCulture); diff --git a/src/Beutl.Language/Strings.ja.resx b/src/Beutl.Language/Strings.ja.resx index 679edcf3a..97c9939b6 100644 --- a/src/Beutl.Language/Strings.ja.resx +++ b/src/Beutl.Language/Strings.ja.resx @@ -746,6 +746,9 @@ ノードを使う + + サムネイルを無効化 + 非対応 diff --git a/src/Beutl.Language/Strings.resx b/src/Beutl.Language/Strings.resx index 8b70425b9..8a3e46bcb 100644 --- a/src/Beutl.Language/Strings.resx +++ b/src/Beutl.Language/Strings.resx @@ -741,6 +741,9 @@ UseNode + + Disable Thumbnails + Unsupported diff --git a/src/Beutl.Operators/Source/SourceImageOperator.cs b/src/Beutl.Operators/Source/SourceImageOperator.cs index 97aaad43e..c921e7e16 100644 --- a/src/Beutl.Operators/Source/SourceImageOperator.cs +++ b/src/Beutl.Operators/Source/SourceImageOperator.cs @@ -1,4 +1,4 @@ -using Beutl.Graphics; +using Beutl.Graphics; using Beutl.Graphics.Effects; using Beutl.Graphics.Transformation; using Beutl.Media.Source; diff --git a/src/Beutl.Operators/Source/SourceSoundOperator.cs b/src/Beutl.Operators/Source/SourceSoundOperator.cs index 473f7ea6a..e0b467e03 100644 --- a/src/Beutl.Operators/Source/SourceSoundOperator.cs +++ b/src/Beutl.Operators/Source/SourceSoundOperator.cs @@ -1,13 +1,23 @@ -using Beutl.Audio; +using System.Runtime.CompilerServices; +using Beutl.Audio; +using Beutl.Audio.Composing; using Beutl.Audio.Effects; +using Beutl.Graphics.Rendering; +using Beutl.Media; using Beutl.Media.Source; using Beutl.Operation; +using Beutl.Threading; namespace Beutl.Operators.Source; -public sealed class SourceSoundOperator : PublishOperator +public sealed class SourceSoundOperator : PublishOperator, IElementThumbnailsProvider { private Uri? _uri; + private EventHandler? _handler; + + public ElementThumbnailsKind ThumbnailsKind => ElementThumbnailsKind.Audio; + + public event EventHandler? ThumbnailsInvalidated; public override bool HasOriginalLength() { @@ -26,19 +36,31 @@ protected override void FillProperties() protected override void OnDetachedFromHierarchy(in HierarchyAttachmentEventArgs args) { base.OnDetachedFromHierarchy(args); - if (Value is not { Source.CurrentValue: { Uri: { } uri } source } value) return; + + if (Value is { } value && _handler != null) + { + value.Edited -= _handler; + } + + _handler = null; + + if (Value is not { Source.CurrentValue: { Uri: { } uri } source } v) return; _uri = uri; - value.Source.CurrentValue = null; + v.Source.CurrentValue = null; source.Dispose(); } protected override void OnAttachedToHierarchy(in HierarchyAttachmentEventArgs args) { base.OnAttachedToHierarchy(args); - if (_uri is null) return; if (Value is not { } value) return; + _handler = (_, _) => ThumbnailsInvalidated?.Invoke(this, EventArgs.Empty); + value.Edited += _handler; + + if (_uri is null) return; + if (SoundSource.TryOpen(_uri, out SoundSource? source)) { value.Source.CurrentValue = source; @@ -78,4 +100,83 @@ public override bool TryGetOriginalLength(out TimeSpan timeSpan) return base.OnSplit(backward, startDelta, lengthDelta); } } + + public async IAsyncEnumerable<(int Index, int Count, IBitmap Thumbnail)> GetThumbnailStripAsync( + int maxWidth, + int maxHeight, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } + + public async IAsyncEnumerable GetWaveformChunksAsync( + int chunkCount, + int samplesPerChunk, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (Value?.Source.CurrentValue is not { IsDisposed: false } source) + yield break; + + if (chunkCount <= 0 || samplesPerChunk <= 0) + yield break; + + var duration = source.Duration; + if (duration <= TimeSpan.Zero) + yield break; + + int sampleRate = source.SampleRate; + int totalSamples = (int)(duration.TotalSeconds * sampleRate); + + using var composer = new Composer { SampleRate = sampleRate }; + composer.AddSound(Value); + + for (int chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + int startSample = (int)((long)chunkIndex * totalSamples / chunkCount); + int endSample = (int)((long)(chunkIndex + 1) * totalSamples / chunkCount); + int sampleCount = Math.Min(endSample - startSample, samplesPerChunk); + TimeSpan startTime = Value.TimeRange.Start + TimeSpan.FromSeconds((double)startSample / sampleRate); + TimeSpan durationTime = TimeSpan.FromSeconds((double)sampleCount / sampleRate); + + if (sampleCount <= 0) + continue; + + var chunk = await ComposeThread.Dispatcher.InvokeAsync(() => + { + if (cancellationToken.IsCancellationRequested) + return (WaveformChunk?)null; + + using var buffer = composer.Compose(new TimeRange(startTime, durationTime)); + if (buffer == null || buffer.SampleCount == 0) + return null; + + var firstChannel = buffer.GetChannelData(0); + var secondChannel = buffer.GetChannelData(1); + + float minValue = float.MaxValue; + float maxValue = float.MinValue; + + for (int i = 0; i < buffer.SampleCount; i++) + { + float left = firstChannel[i]; + float right = secondChannel[i]; + + float monoValue = (left + right) * 0.5f; + minValue = Math.Min(minValue, monoValue); + maxValue = Math.Max(maxValue, monoValue); + } + + return new WaveformChunk(chunkIndex, minValue, maxValue); + }, DispatchPriority.Low, cancellationToken); + + if (chunk.HasValue) + { + yield return chunk.Value; + } + } + } } diff --git a/src/Beutl.Operators/Source/SourceVideoOperator.cs b/src/Beutl.Operators/Source/SourceVideoOperator.cs index 036c848f6..f94fc1c27 100644 --- a/src/Beutl.Operators/Source/SourceVideoOperator.cs +++ b/src/Beutl.Operators/Source/SourceVideoOperator.cs @@ -1,19 +1,32 @@ -using Beutl.Graphics; +using System.Runtime.CompilerServices; +using Beutl.Engine; +using Beutl.Graphics; using Beutl.Graphics.Effects; +using Beutl.Graphics.Rendering; using Beutl.Graphics.Transformation; +using Beutl.Media; using Beutl.Media.Source; using Beutl.Operation; +using Beutl.Threading; +using SkiaSharp; namespace Beutl.Operators.Source; -public sealed class SourceVideoOperator : PublishOperator +public sealed class SourceVideoOperator : PublishOperator, IElementThumbnailsProvider { private Uri? _uri; + private EventHandler? _handler; + + public ElementThumbnailsKind ThumbnailsKind => ElementThumbnailsKind.Video; + + public event EventHandler? ThumbnailsInvalidated; protected override void FillProperties() { AddProperty(Value.OffsetPosition, TimeSpan.Zero); + AddProperty(Value.Speed, 100f); AddProperty(Value.Source); + AddProperty(Value.IsLoop); AddProperty(Value.Transform, new TransformGroup()); AddProperty(Value.AlignmentX); AddProperty(Value.AlignmentY); @@ -26,19 +39,38 @@ protected override void FillProperties() protected override void OnDetachedFromHierarchy(in HierarchyAttachmentEventArgs args) { base.OnDetachedFromHierarchy(args); - if (Value is not { Source.CurrentValue: { Uri: { } uri } source } value) return; + + if (Value is { } value && _handler != null) + { + value.Source.Edited -= _handler; + value.OffsetPosition.Edited -= _handler; + value.Speed.Edited -= _handler; + value.IsLoop.Edited -= _handler; + } + + _handler = null; + + if (Value is not { Source.CurrentValue: { Uri: { } uri } source } v) return; _uri = uri; - value.Source.CurrentValue = null; + v.Source.CurrentValue = null; source.Dispose(); } protected override void OnAttachedToHierarchy(in HierarchyAttachmentEventArgs args) { base.OnAttachedToHierarchy(args); - if (_uri is null) return; if (Value is not { } value) return; + _handler = (_, _) => ThumbnailsInvalidated?.Invoke(this, EventArgs.Empty); + + value.Source.Edited += _handler; + value.OffsetPosition.Edited += _handler; + value.Speed.Edited += _handler; + value.IsLoop.Edited += _handler; + + if (_uri is null) return; + if (VideoSource.TryOpen(_uri, out VideoSource? source)) { value.Source.CurrentValue = source; @@ -84,4 +116,86 @@ public override bool TryGetOriginalLength(out TimeSpan timeSpan) return base.OnSplit(backward, startDelta, lengthDelta); } } + + public async IAsyncEnumerable<(int Index, int Count, IBitmap Thumbnail)> GetThumbnailStripAsync( + int maxWidth, + int maxHeight, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (Value.Source.CurrentValue is not { IsDisposed: false } source) + yield break; + + if (source.Duration <= TimeSpan.Zero) + yield break; + var duration = Value.TimeRange.Duration; + + var frameSize = source.FrameSize; + float aspectRatio = (float)frameSize.Width / frameSize.Height; + float thumbWidth = maxHeight * aspectRatio; + int count = (int)MathF.Ceiling(maxWidth / thumbWidth); + double interval = duration.TotalSeconds / count; + SourceVideo.Resource? resource = null; + DrawableRenderNode? node = null; + RenderNodeProcessor? processor = null; + + try + { + for (int i = 0; i < count; i++) + { + if (cancellationToken.IsCancellationRequested) + yield break; + + var time = TimeSpan.FromSeconds(i * interval); + + var thumbnail = await RenderThread.Dispatcher.InvokeAsync(() => + { + if (cancellationToken.IsCancellationRequested) + return null; + + var ctx = new RenderContext(time + Value.TimeRange.Start); + if (resource == null) + { + resource = Value.ToResource(ctx); + node = new DrawableRenderNode(resource); + processor = new RenderNodeProcessor(node, false); + } + else + { + bool updateOnly = false; + resource.Update(Value, ctx, ref updateOnly); + } + + using (var gctx = new GraphicsContext2D(node!, new PixelSize((int)thumbWidth, maxHeight))) + using (gctx.PushTransform(Matrix.CreateScale(thumbWidth / frameSize.Width, (float)maxHeight / frameSize.Height))) + { + Value.DrawInternal(gctx, resource); + } + + return processor!.RasterizeAndConcat(); + }, DispatchPriority.Medium, cancellationToken); + + if (thumbnail != null) + { + yield return (i, count, thumbnail); + } + } + } + finally + { + RenderThread.Dispatcher.Dispatch(() => + { + node?.Dispose(); + resource?.Dispose(); + }, ct: CancellationToken.None); + } + } + + public async IAsyncEnumerable GetWaveformChunksAsync( + int chunkCount, + int samplesPerChunk, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield break; + } } diff --git a/src/Beutl.ProjectSystem/Operation/IElementThumbnailsProvider.cs b/src/Beutl.ProjectSystem/Operation/IElementThumbnailsProvider.cs new file mode 100644 index 000000000..86efe5ff7 --- /dev/null +++ b/src/Beutl.ProjectSystem/Operation/IElementThumbnailsProvider.cs @@ -0,0 +1,29 @@ +using Beutl.Media; + +namespace Beutl.Operation; + +public interface IElementThumbnailsProvider +{ + ElementThumbnailsKind ThumbnailsKind { get; } + + event EventHandler? ThumbnailsInvalidated; + + IAsyncEnumerable<(int Index, int Count, IBitmap Thumbnail)> GetThumbnailStripAsync( + int maxWidth, + int maxHeight, + CancellationToken cancellationToken = default); + + IAsyncEnumerable GetWaveformChunksAsync( + int chunkCount, + int samplesPerChunk, + CancellationToken cancellationToken = default); +} + +public readonly record struct WaveformChunk(int Index, float MinValue, float MaxValue); + +public enum ElementThumbnailsKind +{ + None = 0, + Video = 2, + Audio = 3, +} diff --git a/src/Beutl.ProjectSystem/SceneComposer.cs b/src/Beutl.ProjectSystem/SceneComposer.cs index 74af55f98..0ea16baae 100644 --- a/src/Beutl.ProjectSystem/SceneComposer.cs +++ b/src/Beutl.ProjectSystem/SceneComposer.cs @@ -21,6 +21,7 @@ public sealed class SceneComposer(Scene scene, IRenderer renderer) : Composer protected override void ComposeCore(TimeRange timeRange) { + ClearSounds(); SortLayers(timeRange, out _); Span elements = CollectionsMarshal.AsSpan(_current); Span entered = CollectionsMarshal.AsSpan(_entered); diff --git a/src/Beutl/ViewModels/ElementViewModel.cs b/src/Beutl/ViewModels/ElementViewModel.cs index f25b2e02a..a410c0f68 100644 --- a/src/Beutl/ViewModels/ElementViewModel.cs +++ b/src/Beutl/ViewModels/ElementViewModel.cs @@ -1,17 +1,21 @@ -using System.Collections.Generic; using System.Collections.Immutable; -using System.Reactive.Disposables; +using System.Reactive.Subjects; using System.Text.Json.Nodes; using Avalonia; using Avalonia.Input; using Avalonia.Input.Platform; +using Avalonia.Media.Imaging; using Beutl.Animation; using Beutl.Helpers; +using Beutl.Logging; +using Beutl.Media; using Beutl.Models; +using Beutl.Operation; using Beutl.ProjectSystem; using Beutl.Serialization; using Beutl.Services; using FluentAvalonia.UI.Media; +using Microsoft.Extensions.Logging; using Reactive.Bindings; using Reactive.Bindings.Extensions; @@ -19,8 +23,13 @@ namespace Beutl.ViewModels; public sealed class ElementViewModel : IDisposable, IContextCommandHandler { + private readonly ILogger _logger = Log.CreateLogger(); private readonly CompositeDisposable _disposables = []; private ImmutableHashSet? _elementGroup; + private readonly Subject _thumbnailsInvalidatedSubject = new(); + private CancellationTokenSource? _thumbnailsCts; + private IElementThumbnailsProvider? _currentThumbnailsProvider; + private EventHandler? _thumbnailsInvalidatedHandler; public ElementViewModel(Element element, TimelineViewModel timeline) { @@ -127,6 +136,54 @@ public ElementViewModel(Element element, TimelineViewModel timeline) .AddTo(_disposables); Scope = new ElementScopeViewModel(Model, this); + + // プレビュー関連の初期化 + IsThumbnailsKindAudio = ThumbnailsKind.Select(k => k == ElementThumbnailsKind.Audio) + .ToReadOnlyReactivePropertySlim() + .AddTo(_disposables); + IsThumbnailsKindVideo = ThumbnailsKind.Select(k => k == ElementThumbnailsKind.Video) + .ToReadOnlyReactivePropertySlim() + .AddTo(_disposables); + + InitializeThumbnails(); + } + + private void InitializeThumbnails() + { + // プレビュー無効化の初期値を設定 + IsThumbnailsDisabled.Value = Timeline.ThumbnailsDisabledElements.Contains(Model.Id); + + // ThumbnailsDisabledElementsの変更を購読 + Timeline.ThumbnailsDisabledElements.Attached += OnThumbnailsDisabledElementsAttached; + Timeline.ThumbnailsDisabledElements.Detached += OnThumbnailsDisabledElementsDetached; + + // IsThumbnailsDisabledが変更されたらThumbnailsDisabledElementsを更新し、プレビューを再読み込み + IsThumbnailsDisabled.Skip(1) + .Subscribe(isDisabled => + { + if (isDisabled) + { + if (!Timeline.ThumbnailsDisabledElements.Contains(Model.Id)) + { + Timeline.ThumbnailsDisabledElements.Add(Model.Id); + } + } + else + { + Timeline.ThumbnailsDisabledElements.Remove(Model.Id); + } + + UpdateThumbnailsAsync(); + }) + .AddTo(_disposables); + + // Width変更とThumbnailsInvalidatedイベントをマージして、いずれかが発生してから500ms後に更新 + Observable.Merge( + Width.Select(_ => Unit.Default), + _thumbnailsInvalidatedSubject.AsObservable()) + .Throttle(TimeSpan.FromMilliseconds(500)) + .Subscribe(_ => UpdateThumbnailsAsync()) + .AddTo(_disposables); } private void InitializeElementGroup() @@ -215,6 +272,26 @@ private void InitializeElementGroup() public ReactiveCommand ChangeToOriginalLength { get; } = new(); + public ReactivePropertySlim ThumbnailsKind { get; } = new(ElementThumbnailsKind.None); + + public ReadOnlyReactivePropertySlim IsThumbnailsKindVideo { get; } + + public ReadOnlyReactivePropertySlim IsThumbnailsKindAudio { get; } + + public ReactivePropertySlim IsThumbnailsDisabled { get; } = new(); + + public ReactivePropertySlim VideoThumbnailCount { get; } = new(); + + public ReactivePropertySlim WaveformChunkCount { get; } = new(); + + public event Action? ThumbnailReady; + + public event Action? ThumbnailsClear; + + public event Action? WaveformChunkReady; + + public event Action? WaveformClear; + public IReadOnlyList GetGroupOrSelectedElements() { var ids = new HashSet(); @@ -253,10 +330,27 @@ public bool CanUngroupSelectedElements() public void Dispose() { + CancelThumbnailsLoading(); + + // ThumbnailsInvalidatedイベントの購読を解除 + if (_currentThumbnailsProvider != null && _thumbnailsInvalidatedHandler != null) + { + _currentThumbnailsProvider.ThumbnailsInvalidated -= _thumbnailsInvalidatedHandler; + } + _thumbnailsInvalidatedSubject.Dispose(); + + // ThumbnailsDisabledElementsイベントの購読を解除 + Timeline.ThumbnailsDisabledElements.Attached -= OnThumbnailsDisabledElementsAttached; + Timeline.ThumbnailsDisabledElements.Detached -= OnThumbnailsDisabledElementsDetached; + _disposables.Dispose(); LayerHeader.Dispose(); Scope.Dispose(); + ThumbnailsKind.Dispose(); + VideoThumbnailCount.Dispose(); + WaveformChunkCount.Dispose(); + LayerHeader.Value = null!; Timeline = null!; Model = null!; @@ -372,6 +466,22 @@ public PrepareAnimationContext PrepareAnimation() Scope: Scope.PrepareAnimation()); } + private void OnThumbnailsDisabledElementsAttached(Guid id) + { + if (id == Model.Id && !IsThumbnailsDisabled.Value) + { + IsThumbnailsDisabled.Value = true; + } + } + + private void OnThumbnailsDisabledElementsDetached(Guid id) + { + if (id == Model.Id && IsThumbnailsDisabled.Value) + { + IsThumbnailsDisabled.Value = false; + } + } + private void OnExclude() { CommandRecorder recorder = Timeline.EditorContext.CommandRecorder; @@ -532,7 +642,7 @@ private async Task OnCut() } else { - if (await SetClipboard([..targets])) + if (await SetClipboard([.. targets])) { CommandRecorder recorder = Timeline.EditorContext.CommandRecorder; targets @@ -694,4 +804,176 @@ public record struct PrepareAnimationContext( (InlineAnimationLayerViewModel ViewModel, InlineAnimationLayerViewModel.PrepareAnimationContext Context)[] Inlines, ElementScopeViewModel.PrepareAnimationContext Scope); + + private void CancelThumbnailsLoading() + { + _thumbnailsCts?.Cancel(); + _thumbnailsCts?.Dispose(); + _thumbnailsCts = null; + } + + private async void UpdateThumbnailsAsync() + { + // プレビューが無効化されている場合は何もしない + if (IsThumbnailsDisabled.Value) + { + CancelThumbnailsLoading(); + ThumbnailsKind.Value = ElementThumbnailsKind.None; + ThumbnailsClear?.Invoke(); + WaveformClear?.Invoke(); + return; + } + + var provider = FindThumbnailsProvider(); + + // プロバイダーが変更された場合、イベント購読を更新 + if (_currentThumbnailsProvider != provider) + { + if (_currentThumbnailsProvider != null && _thumbnailsInvalidatedHandler != null) + { + _currentThumbnailsProvider.ThumbnailsInvalidated -= _thumbnailsInvalidatedHandler; + } + + _currentThumbnailsProvider = provider; + + if (provider != null) + { + _thumbnailsInvalidatedHandler = (_, _) => _thumbnailsInvalidatedSubject.OnNext(Unit.Default); + provider.ThumbnailsInvalidated += _thumbnailsInvalidatedHandler; + } + } + + if (provider == null) + { + ThumbnailsKind.Value = ElementThumbnailsKind.None; + return; + } + + ThumbnailsKind.Value = provider.ThumbnailsKind; + + CancelThumbnailsLoading(); + _thumbnailsCts = new CancellationTokenSource(); + var ct = _thumbnailsCts.Token; + + try + { + switch (provider.ThumbnailsKind) + { + case ElementThumbnailsKind.Video: + await UpdateVideoThumbnailsAsync(provider, ct); + break; + case ElementThumbnailsKind.Audio: + await UpdateAudioThumbnailsAsync(provider, ct); + break; + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update thumbnails."); + } + } + + private IElementThumbnailsProvider? FindThumbnailsProvider() + { + if (Model.UseNode) + return null; + + foreach (var child in Model.Operation.Children) + { + if (child is IElementThumbnailsProvider provider) + return provider; + } + + return null; + } + + private async Task UpdateVideoThumbnailsAsync(IElementThumbnailsProvider provider, CancellationToken ct) + { + const int MaxThumbnailHeight = 25; + double width = Width.Value; + if (width <= 0) + return; + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + if (!ct.IsCancellationRequested) + { + ThumbnailsClear?.Invoke(); + } + }); + + await foreach (var (index, count, thumbnail) in provider.GetThumbnailStripAsync((int)width, MaxThumbnailHeight, ct)) + { + if (ct.IsCancellationRequested) + { + thumbnail.Dispose(); + break; + } + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + using (thumbnail) + { + if (!ct.IsCancellationRequested) + { + VideoThumbnailCount.Value = count; + ThumbnailReady?.Invoke(index, ConvertToAvaloniaBitmap(thumbnail)); + } + } + }); + } + } + + private async Task UpdateAudioThumbnailsAsync(IElementThumbnailsProvider provider, CancellationToken ct) + { + const int MaxSamplesPerChunk = 4096; + double width = Width.Value; + if (width <= 0) + return; + + int chunkCount = Math.Max(1, (int)width); + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + if (!ct.IsCancellationRequested) + { + WaveformClear?.Invoke(); + WaveformChunkCount.Value = chunkCount; + } + }); + + await foreach (var chunk in provider.GetWaveformChunksAsync(chunkCount, MaxSamplesPerChunk, ct)) + { + if (ct.IsCancellationRequested) + break; + + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + { + if (!ct.IsCancellationRequested) + { + WaveformChunkReady?.Invoke(chunk); + } + }); + } + } + + private static Bitmap? ConvertToAvaloniaBitmap(IBitmap source) + { + if (source.IsDisposed) + return null; + + var width = source.Width; + var height = source.Height; + + return new Bitmap( + Avalonia.Platform.PixelFormat.Bgra8888, + Avalonia.Platform.AlphaFormat.Premul, + source.Data, + new Avalonia.PixelSize(width, height), + new Vector(96, 96), + width * 4); + } } diff --git a/src/Beutl/ViewModels/TimelineViewModel.cs b/src/Beutl/ViewModels/TimelineViewModel.cs index 5751285fa..078738245 100644 --- a/src/Beutl/ViewModels/TimelineViewModel.cs +++ b/src/Beutl/ViewModels/TimelineViewModel.cs @@ -336,6 +336,8 @@ private void OnSetEndTimeToCurrentTime() public CoreList Elements { get; } = []; + public CoreList ThumbnailsDisabledElements { get; } = []; + public CoreList Inlines { get; } = []; public CoreList LayerHeaders { get; } = []; @@ -763,6 +765,22 @@ public void ReadFromJson(JsonObject json) RestoreInlineAnimation(inlinesArray); } + if (json.TryGetPropertyValue(nameof(ThumbnailsDisabledElements), out JsonNode? ThumbnailsDisabledNode) + && ThumbnailsDisabledNode is JsonArray thumbnailsDisabledArray) + { + ThumbnailsDisabledElements.Clear(); + foreach (JsonNode? item in thumbnailsDisabledArray) + { + if (item is JsonValue value + && value.TryGetValue(out string? guidStr) + && Guid.TryParse(guidStr, out Guid id) + && Scene.Children.Any(e => e.Id == id)) + { + ThumbnailsDisabledElements.Add(id); + } + } + } + _logger.LogInformation("TimelineViewModel state read from JSON successfully."); } @@ -891,6 +909,14 @@ public void WriteToJson(JsonObject json) json[nameof(Inlines)] = inlines; + var thumbnailsDisabledArray = new JsonArray(); + foreach (Guid id in ThumbnailsDisabledElements) + { + thumbnailsDisabledArray.Add(id.ToString()); + } + + json[nameof(ThumbnailsDisabledElements)] = thumbnailsDisabledArray; + _logger.LogInformation("TimelineViewModel state written to JSON successfully."); } diff --git a/src/Beutl/Views/ElementView.axaml b/src/Beutl/Views/ElementView.axaml index 99b533a4a..9386c62b8 100644 --- a/src/Beutl/Views/ElementView.axaml +++ b/src/Beutl/Views/ElementView.axaml @@ -8,6 +8,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:p="using:Beutl.Services.PrimitiveImpls" xmlns:ui="using:FluentAvalonia.UI.Controls" + xmlns:v="using:Beutl.Views" xmlns:vm="using:Beutl.ViewModels" x:Name="root" Height="{DynamicResource LayerHeight}" @@ -118,6 +119,13 @@ Command="{Binding ChangeToOriginalLength}" Text="{x:Static lang:Strings.ChangeToOriginalLength}" /> + + + + + @@ -141,6 +149,19 @@ + + + + Task.CompletedTask; obj.RenameRequested = () => { }; obj.GetClickedTime = null; + + obj.ThumbnailReady -= OnThumbnailReady; + obj.ThumbnailsClear -= OnThumbnailsClear; + obj.WaveformChunkReady -= OnWaveformChunkReady; + obj.WaveformClear -= OnWaveformClear; + _disposables.Clear(); } @@ -94,6 +102,31 @@ await Dispatcher.UIThread.InvokeAsync(async () => .ObserveOnUIDispatcher() .Subscribe(v => ZIndex = v ? 5 : 0) .DisposeWith(_disposables); + + obj.ThumbnailReady += OnThumbnailReady; + obj.ThumbnailsClear += OnThumbnailsClear; + obj.WaveformChunkReady += OnWaveformChunkReady; + obj.WaveformClear += OnWaveformClear; + } + + private void OnThumbnailReady(int index, Bitmap? thumbnail) + { + thumbnailStrip.SetThumbnail(index, thumbnail); + } + + private void OnThumbnailsClear() + { + thumbnailStrip.ClearThumbnails(); + } + + private void OnWaveformChunkReady(WaveformChunk chunk) + { + waveformControl.SetChunk(chunk); + } + + private void OnWaveformClear() + { + waveformControl.ClearChunks(); } protected override void OnAttachedToLogicalTree(LogicalTreeAttachmentEventArgs e) diff --git a/src/Beutl/Views/ThumbnailStripControl.cs b/src/Beutl/Views/ThumbnailStripControl.cs new file mode 100644 index 000000000..c6e5366da --- /dev/null +++ b/src/Beutl/Views/ThumbnailStripControl.cs @@ -0,0 +1,122 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Stretch = Avalonia.Media.Stretch; + +namespace Beutl.Views; + +public sealed class ThumbnailStripControl : Control +{ + public static readonly StyledProperty ThumbnailCountProperty = + AvaloniaProperty.Register(nameof(ThumbnailCount)); + + private readonly Dictionary _thumbnails = new(); + private readonly Lock _lock = new(); + + static ThumbnailStripControl() + { + AffectsRender(ThumbnailCountProperty); + } + + public int ThumbnailCount + { + get => GetValue(ThumbnailCountProperty); + set => SetValue(ThumbnailCountProperty, value); + } + + public void SetThumbnail(int index, Bitmap? thumbnail) + { + lock (_lock) + { + if (_thumbnails.TryGetValue(index, out var old)) + { + old?.Dispose(); + } + + _thumbnails[index] = thumbnail; + } + + Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateVisual); + } + + public void ClearThumbnails() + { + lock (_lock) + { + foreach (var kvp in _thumbnails) + { + kvp.Value?.Dispose(); + } + + _thumbnails.Clear(); + } + + InvalidateVisual(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ThumbnailCountProperty) + { + ClearThumbnails(); + } + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bounds = Bounds; + double width = bounds.Width; + double height = bounds.Height; + + if (width <= 0 || height <= 0) + return; + + int count = ThumbnailCount; + if (count <= 0) + return; + + double slotWidth = width / count; + + lock (_lock) + { + foreach (var kvp in _thumbnails) + { + int i = kvp.Key; + var img = kvp.Value; + + if (img == null || i < 0 || i >= count) + continue; + + double slotX = i * slotWidth; + + // 背景が見えてしまうので微調整 + var dstSize = new Size(slotWidth + 1, height); + var srcSize = img.Size; + var viewPort = new Rect(dstSize); + + Vector scale = Stretch.UniformToFill.CalculateScaling(dstSize, srcSize); + Size scaledSize = srcSize * scale; + Rect dstRect = viewPort + .CenterRect(new Rect(scaledSize)) + .Intersect(viewPort); + Rect srcRect = new Rect(srcSize) + .CenterRect(new Rect(dstRect.Size / scale)); + + dstRect = dstRect.Translate(new(slotX, 0)); + + context.DrawImage(img, srcRect, dstRect); + } + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + ClearThumbnails(); + } +} diff --git a/src/Beutl/Views/WaveformControl.cs b/src/Beutl/Views/WaveformControl.cs new file mode 100644 index 000000000..f31cc885d --- /dev/null +++ b/src/Beutl/Views/WaveformControl.cs @@ -0,0 +1,118 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Beutl.Operation; + +namespace Beutl.Views; + +public sealed class WaveformControl : Control +{ + public static readonly StyledProperty ChunkCountProperty = + AvaloniaProperty.Register(nameof(ChunkCount)); + + public static readonly StyledProperty WaveformBrushProperty = + AvaloniaProperty.Register(nameof(WaveformBrush), Brushes.White); + + private readonly Dictionary _chunks = new(); + private readonly Lock _lock = new(); + + static WaveformControl() + { + AffectsRender(ChunkCountProperty, WaveformBrushProperty); + } + + public int ChunkCount + { + get => GetValue(ChunkCountProperty); + set => SetValue(ChunkCountProperty, value); + } + + public IBrush? WaveformBrush + { + get => GetValue(WaveformBrushProperty); + set => SetValue(WaveformBrushProperty, value); + } + + public void SetChunk(WaveformChunk chunk) + { + lock (_lock) + { + _chunks[chunk.Index] = chunk; + } + + Avalonia.Threading.Dispatcher.UIThread.Post(InvalidateVisual); + } + + public void ClearChunks() + { + lock (_lock) + { + _chunks.Clear(); + } + + InvalidateVisual(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (change.Property == ChunkCountProperty) + { + ClearChunks(); + } + } + + public override void Render(DrawingContext context) + { + base.Render(context); + + var bounds = Bounds; + double width = bounds.Width; + double height = bounds.Height; + + if (width <= 0 || height <= 0) + return; + + int count = ChunkCount; + if (count <= 0) + return; + + var brush = WaveformBrush; + if (brush == null) + return; + + double slotWidth = width / count; + double centerY = height / 2; + + lock (_lock) + { + foreach (var kvp in _chunks) + { + int i = kvp.Key; + var chunk = kvp.Value; + + if (i < 0 || i >= count) + continue; + + double slotX = i * slotWidth; + + float minVal = Math.Clamp(chunk.MinValue, -1f, 1f); + float maxVal = Math.Clamp(chunk.MaxValue, -1f, 1f); + + double topY = centerY - (maxVal * centerY); + double bottomY = centerY - (minVal * centerY); + double barHeight = Math.Max(1, bottomY - topY); + + var rect = new Rect(slotX, topY, Math.Max(1, slotWidth - 0.5), barHeight); + context.FillRectangle(brush, rect); + } + } + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + ClearChunks(); + } +}