Починка проекта: клипы на кадровую сетку, пункт в меню

Меню «Проект» → «Проверить и починить проект...». Показывает найденное и
чинит только по согласию, отменяется через Ctrl+Z.

Ищутся клипы мимо кадровой сетки, клипы на дорожках чужого вида и
повторяющиеся имена дорожек. Первое — наследство: в старых проектах
времена записаны как попало (2.457599 с при 59.94 к/с — это 147.31 кадра),
и пока монтаж просит доли кадра, предпросмотр и сборка округляют их
каждый по-своему. Сдвиг при починке не больше полкадра.

Края отмеченного участка при сборке тоже кладутся на сетку: подрезка
возвращала в крайние куски доли кадра, которые уплощение только что
убрало.

Проверено на копии настоящего проекта: 82 клипа, после починки мимо сетки
ни одного, клипы остались встык, общая длина изменилась на 0.028 с за два
часа; Ctrl+Z возвращает как было. Плюс проверка в lfmcheck, включая клип
короче кадра — он не должен схлопнуться в ничто.
This commit is contained in:
4aiman 2026-09-16 20:39:33 +03:00
commit 37787ce96c
4 changed files with 311 additions and 0 deletions

View file

@ -272,6 +272,29 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
удаляет: она лежит, пока проект не сохранят заново. После сохранения
копия не нужна и убирается.
## Починка проекта
Меню «Проект» → **Проверить и починить проект...**. Показывает, что нашла,
и чинит только по согласию; отменяется через Ctrl+Z.
Что ищется:
- **клипы мимо кадровой сетки.** Между кадрами времени нет, а в старых
проектах времена записаны как попало: 2.457599 с при 59.94 к/с — это
147.31 кадра. Картинка всё равно выйдет целым числом кадров, звук
наберётся с точностью до отсчёта, и предпросмотр со сборкой не смогут
совпасть в точности. Починка двигает клип не больше чем на полкадра;
на проекте из 82 клипов общая длина изменилась на 0.028 с, то есть на
полтора кадра за два часа;
- **клипы на дорожках чужого вида.** Такой клип нарисован на монтаже, но
в готовый ролик не попадает. То же самое предлагается при открытии
проекта, здесь — по требованию;
- **дорожки с повторяющимися именами.**
Для синхронности звука с картинкой чинить проект не нужно: рез правится
при сборке, и старый проект соберётся сходящимся. Починка — про то, чтобы
монтаж не хранил невозможных времён.
## Файл не совпадает с проектом
Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не

View file

@ -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

View file

@ -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;

View file

@ -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;