diff --git a/README.md b/README.md index b32216d..197f009 100644 --- a/README.md +++ b/README.md @@ -272,6 +272,29 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро удаляет: она лежит, пока проект не сохранят заново. После сохранения копия не нужна и убирается. +## Починка проекта + +Меню «Проект» → **Проверить и починить проект...**. Показывает, что нашла, +и чинит только по согласию; отменяется через Ctrl+Z. + +Что ищется: + +- **клипы мимо кадровой сетки.** Между кадрами времени нет, а в старых + проектах времена записаны как попало: 2.457599 с при 59.94 к/с — это + 147.31 кадра. Картинка всё равно выйдет целым числом кадров, звук + наберётся с точностью до отсчёта, и предпросмотр со сборкой не смогут + совпасть в точности. Починка двигает клип не больше чем на полкадра; + на проекте из 82 клипов общая длина изменилась на 0.028 с, то есть на + полтора кадра за два часа; +- **клипы на дорожках чужого вида.** Такой клип нарисован на монтаже, но + в готовый ролик не попадает. То же самое предлагается при открытии + проекта, здесь — по требованию; +- **дорожки с повторяющимися именами.** + +Для синхронности звука с картинкой чинить проект не нужно: рез правится +при сборке, и старый проект соберётся сходящимся. Починка — про то, чтобы +монтаж не хранил невозможных времён. + ## Файл не совпадает с проектом Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 7335fca..7e6856c 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -255,6 +255,7 @@ type procedure PoolEditTransform(Sender: TObject); procedure TimelineClipTransform(Sender: TObject; AClip: TClip); procedure MenuProjectInfo(Sender: TObject); + procedure MenuRepairProject(Sender: TObject); procedure FormFilesDropped(Sender: TObject; const FileNames: array of string); function ThumbIndexFor(const APath: string): Integer; procedure ApplySettings; @@ -807,6 +808,7 @@ begin MProject := AddTop('Проект'); AddItem(MProject, 'Сведения о проекте...', @MenuProjectInfo); + AddItem(MProject, 'Проверить и починить проект...', @MenuRepairProject); AddSep(MProject); AddItem(MProject, 'Добавить текст', @MenuAddText, ShortCut(VK_T, [ssCtrl])); AddSep(MProject); @@ -3035,6 +3037,112 @@ begin ShowProjectInfo(Self, FProject); end; +{ Проверка и починка проекта. + + Зачем. В проекте копятся вещи, которые сами по себе не видны, а в + готовом ролике оборачиваются сюрпризом: клип на дорожке чужого вида + (нарисован, но в сборку не попадает), одинаковые имена дорожек, времена + мимо кадровой сетки. Последнее — наследство: доли кадра в монтаже + означают, что предпросмотр и сборка округлят их каждый по-своему. + + Молча ничего не правится: сначала показываем, что нашли, и спрашиваем. + Отмена работает как у любой правки — Ctrl+Z. } +procedure TMainForm.MenuRepairProject(Sender: TObject); +var + OffGrid, Misplaced, Renamed, MovedClips, SnappedClips: Integer; + Report: string; + Names: TStringList; + T: Integer; +begin + OffGrid := FProject.OffGridClipCount; + Misplaced := FProject.MisplacedClipCount; + + { Одинаковые имена дорожек: считаем до правки, иначе не о чем говорить. } + Renamed := 0; + Names := TStringList.Create; + try + Names.Sorted := True; + Names.Duplicates := dupAccept; + for T := 0 to FProject.TrackCount - 1 do + begin + if Names.IndexOf(FProject.Tracks[T].Name) >= 0 then + Inc(Renamed); + Names.Add(FProject.Tracks[T].Name); + end; + finally + Names.Free; + end; + + if (OffGrid = 0) and (Misplaced = 0) and (Renamed = 0) then + begin + ThemedMessageDlg('nlve', + 'Проект в порядке: клипы стоят на кадрах, все они на дорожках ' + + 'своего вида, имена дорожек не повторяются.', + mtInformation, [mbOK], 0); + SetStatus('Проект проверен: чинить нечего.', True); + Exit; + end; + + Report := 'Найдено:' + LineEnding + LineEnding; + if OffGrid > 0 then + Report := Report + Format('— клипов мимо кадровой сетки: %d.' + LineEnding + + 'Между кадрами времени нет, и доля кадра означает, что предпросмотр ' + + 'и сборка округлят её каждый по-своему. Сдвиг при починке не больше ' + + 'полкадра.' + LineEnding + LineEnding, [OffGrid]); + if Misplaced > 0 then + Report := Report + Format('— клипов на дорожках чужого вида: %d.' + + LineEnding + 'Такой клип нарисован на монтаже, но в готовый ролик не ' + + 'попадает.' + LineEnding + LineEnding, [Misplaced]); + if Renamed > 0 then + Report := Report + Format('— дорожек с повторяющимся именем: %d.' + + LineEnding + LineEnding, [Renamed]); + Report := Report + 'Починить? Отменить можно будет через Ctrl+Z.'; + + if ThemedMessageDlg('nlve', Report, mtConfirmation, [mbYes, mbNo], 0) <> mrYes then + Exit; + + FProject.BeginChange; + SnappedClips := FProject.SnapClipsToFrameGrid; + MovedClips := FProject.FixMisplacedClips; + FProject.EnsureUniqueTrackNames; + + if (SnappedClips = 0) and (MovedClips = 0) and (Renamed = 0) then + begin + FProject.CancelChange; + SetStatus('Чинить не пришлось.', True); + Exit; + end; + + FProject.Modified := True; + FTimeline.Invalidate; + RefreshPool; + UpdateCaption; + FLastLoadedEdl := ''; + SchedulePreview; + + Report := ''; + if SnappedClips > 0 then + Report := Report + Format('на кадры поставлено %d %s', [SnappedClips, + Plural(SnappedClips, 'клип', 'клипа', 'клипов')]); + if MovedClips > 0 then + begin + if Report <> '' then + Report := Report + ', '; + Report := Report + Format('перенесено %d %s', [MovedClips, + Plural(MovedClips, 'клип', 'клипа', 'клипов')]); + end; + if Renamed > 0 then + begin + if Report <> '' then + Report := Report + ', '; + Report := Report + Format('переименовано %d %s', [Renamed, + Plural(Renamed, 'дорожка', 'дорожки', 'дорожек')]); + end; + + Log('Починка проекта: ' + Report + '.'); + SetStatus('Проект починен: ' + Report + '. Отменить — Ctrl+Z.', True); +end; + procedure TMainForm.PoolRemoveFromProject(Sender: TObject); var Idx: Integer; @@ -4317,6 +4425,11 @@ begin end else begin + { Края участка — тоже на кадровую сетку. Иначе подрезка вернёт в + крайние куски доли кадра, которые уплощение только что убрало, и + картинка с звуком на краях участка разойдутся на полкадра. } + RegionFrom := FrameAlign(RegionFrom, FProject.Master); + RegionTo := FrameAlign(RegionTo, FProject.Master); ClipSegmentsToRange(Segments, RegionFrom, RegionTo); ClipSegmentsToRange(AudioSegs, RegionFrom, RegionTo); if Length(Segments) = 0 then diff --git a/src/uProject.pas b/src/uProject.pas index ebcdad0..f70c803 100644 --- a/src/uProject.pas +++ b/src/uProject.pas @@ -422,6 +422,19 @@ type такой нет — заводится новая. Время клипа не меняется: человек ставил его именно туда. } function FixMisplacedClips: Integer; + { Сколько клипов стоит мимо кадровой сетки. + + Доля кадра в монтаже — это не «почти точно»: картинка всё равно + выйдет целым числом кадров, а звук набирается с точностью до + отсчёта. Пока монтаж просит доли кадра, предпросмотр и готовый ролик + не могут совпасть в точности. } + function OffGridClipCount: Integer; + { Ставит клипы на кадровую сетку: начало на монтаже, длину и точку + входа в исходник. Возвращает, сколько клипов подвинулось. + + Сдвиг не превышает полкадра — на глаз и на слух незаметен, но + монтаж перестаёт хранить невозможные времена. } + function SnapClipsToFrameGrid: Integer; { Автоматическое схлопывание при перетаскивании клипов. } property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled; property FileName: string read FFileName write FFileName; @@ -444,6 +457,14 @@ function SameColorKey(const A, B: TColorKey): Boolean; центру — то, что читается на любой картинке. } function DefaultTextStyle: TTextStyle; +{ Время на кадровую сетку проекта. + + Между кадрами времени не существует: доля кадра в монтаже означает, что + картинка и звук округлят её каждый по-своему и разойдутся. Мера общая + для монтажа, уплощения и подрезки по участку — чтобы все считали + одинаково. } +function FrameAlign(const ATime: Double; const AMaster: TMediaInfo): Double; + { Шрифт по умолчанию. Разный по системам: одинаковых имён у них нет. } const DefaultTextFont = @@ -1125,6 +1146,81 @@ begin end; end; +function TProject.OffGridClipCount: Integer; +var + T, C: Integer; + Track: TTrack; + Clip: TClip; + + function OffGrid(const ATime: Double): Boolean; + begin + Result := Abs(FrameAlign(ATime, FMaster) - ATime) > 0.0005; + end; + +begin + Result := 0; + if (FMaster.FpsNum <= 0) or (FMaster.FpsDen <= 0) then + Exit; + for T := 0 to TrackCount - 1 do + begin + Track := Tracks[T]; + for C := 0 to Track.Count - 1 do + begin + Clip := Track[C]; + if OffGrid(Clip.TlStart) or OffGrid(Clip.Duration) or + OffGrid(Clip.SrcIn) then + Inc(Result); + end; + end; +end; + +function TProject.SnapClipsToFrameGrid: Integer; +var + T, C: Integer; + Track: TTrack; + Clip: TClip; + NewStart, NewDur, NewSrc, Step: Double; +begin + Result := 0; + if (FMaster.FpsNum <= 0) or (FMaster.FpsDen <= 0) then + Exit; + Step := FMaster.FpsDen / FMaster.FpsNum; + + for T := 0 to TrackCount - 1 do + begin + Track := Tracks[T]; + for C := 0 to Track.Count - 1 do + begin + Clip := Track[C]; + NewStart := FrameAlign(Clip.TlStart, FMaster); + NewDur := FrameAlign(Clip.Duration, FMaster); + { Клип короче кадра оставить нельзя: он исчезнет. } + if NewDur < Step then + NewDur := Step; + NewSrc := FrameAlign(Clip.SrcIn, FMaster); + if NewSrc < 0 then + NewSrc := 0; + + if (Abs(NewStart - Clip.TlStart) > 0.0000001) or + (Abs(NewDur - Clip.Duration) > 0.0000001) or + (Abs(NewSrc - Clip.SrcIn) > 0.0000001) then + begin + Clip.TlStart := NewStart; + Clip.Duration := NewDur; + Clip.SrcIn := NewSrc; + Inc(Result); + end; + end; + { Порядок мог измениться на доли кадра — пересортируем, иначе дальше + всё, что полагается на порядок, будет считать неверно. } + Track.Sort; + end; + + { Отметки участка — той же мерой: по ним режется сборка. } + FRegionStart := FrameAlign(FRegionStart, FMaster); + FRegionEnd := FrameAlign(FRegionEnd, FMaster); +end; + function TProject.FixMisplacedClips: Integer; var T, C, K: Integer; @@ -1429,6 +1525,17 @@ end; { Цветовой ключ по умолчанию: выключен, пурпурный, с небольшим запасом по сходству — сжатие видео размывает чистый цвет, и точное совпадение оставило бы кайму. } +function FrameAlign(const ATime: Double; const AMaster: TMediaInfo): Double; +var + Step: Double; +begin + Result := ATime; + if (AMaster.FpsNum <= 0) or (AMaster.FpsDen <= 0) then + Exit; + Step := AMaster.FpsDen / AMaster.FpsNum; + Result := Round(ATime / Step) * Step; +end; + function DefaultColorKey: TColorKey; begin Result.Enabled := False; diff --git a/tests/lfmcheck/lfmcheck.lpr b/tests/lfmcheck/lfmcheck.lpr index 4ea6e2f..aaa3fea 100644 --- a/tests/lfmcheck/lfmcheck.lpr +++ b/tests/lfmcheck/lfmcheck.lpr @@ -564,6 +564,73 @@ begin end; end; +{ Починка проекта: клипы на кадровую сетку. + + Между кадрами времени нет, и монтаж не должен хранить доли кадра: + картинка выйдет целым числом кадров, звук наберётся до отсчёта, и + округление разведёт их. Правка двигает клип не больше чем на полкадра, + но сама сетка обязана получиться ровной. } +procedure CheckFrameGridRepair; +var + Proj: TProject; + Info: TMediaInfo; + Media: TMediaItem; + Step: Double; + I: Integer; + Clip: TClip; + OnGrid: Boolean; +begin + WriteLn; + WriteLn('Починка: клипы на кадровую сетку'); + WriteLn; + + Info := TestInfo; + { 59.94 к/с — та самая сетка, на которой доли кадра особенно заметны. } + Info.FpsNum := 60000; + Info.FpsDen := 1001; + + Proj := TProject.Create; + try + Proj.Clear; + Proj.AddTrack('V1'); + Media := Proj.AddMedia('/несуществующий/видео.mp4', Info); + Proj.Master := Info; + Step := Info.FpsDen / Info.FpsNum; + + { Времена как в настоящем проекте: ни одно не попадает на кадр. } + Proj.AddClip(Proj.Tracks[0], Media, 48.558521, 2.457599, 0); + Proj.AddClip(Proj.Tracks[0], Media, 56.054198, 1.469934, 2.457599); + { И клип короче кадра: его нельзя схлопнуть в ничто. } + Proj.AddClip(Proj.Tracks[0], Media, 62.710196, 0.004, 3.927533); + + CheckTrue('все три клипа мимо сетки', Proj.OffGridClipCount = 3); + CheckTrue('починка подвинула три клипа', Proj.SnapClipsToFrameGrid = 3); + CheckTrue('мимо сетки не осталось', Proj.OffGridClipCount = 0); + + OnGrid := True; + for I := 0 to Proj.Tracks[0].Count - 1 do + begin + Clip := Proj.Tracks[0][I]; + if Abs(Clip.TlStart / Step - Round(Clip.TlStart / Step)) > 0.001 then + OnGrid := False; + if Abs(Clip.Duration / Step - Round(Clip.Duration / Step)) > 0.001 then + OnGrid := False; + if Abs(Clip.SrcIn / Step - Round(Clip.SrcIn / Step)) > 0.001 then + OnGrid := False; + end; + CheckTrue('начала, длины и точки входа — целые кадры', OnGrid); + + CheckTrue('короткий клип остался длиной в кадр', + Abs(Proj.Tracks[0][2].Duration - Step) < 0.0001); + CheckTrue('клипы не разъехались больше чем на полкадра', + Abs(Proj.Tracks[0][0].TlStart - 0) < Step); + CheckTrue('повторная починка ничего не двигает', + Proj.SnapClipsToFrameGrid = 0); + finally + Proj.Free; + end; +end; + begin WriteLn('Свойства из файлов форм'); WriteLn; @@ -595,6 +662,7 @@ begin CheckRippleWithoutMouse; CheckClipCopy; CheckRubberBand; + CheckFrameGridRepair; CheckSplitAllTracks; CheckGapTolerance;