diff --git a/README.md b/README.md index a4f52b7..f9e6ba9 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,14 @@ xdg-mime query default application/x-nlve-project времени. На слабой машине это может быть заметнее, потому выключатель и есть. +**Отмеченный участок.** Клавиша `I` ставит начало по курсору, `O` — +конец; оба пункта есть в меню «Правка», там же снятие отметок. Участок +показан полосой под линейкой и стойками по краям. Пока он отмечен, сборка +собирает только его — это единственный способ проверить правку на длинном +монтаже, не тратя на каждую проверку столько же времени, сколько идёт сам +ролик. Отметки живут в файле проекта: участок относится к монтажу, а не к +привычкам человека. + **Схлопывание.** Кнопка внизу показывает и режим, и состояние: утоплена — автосхлопывание включено, и тогда при перетаскивании клипа всё, что начинается не раньше него, едет следом. Стрелка рядом открывает выбор @@ -370,6 +378,21 @@ Linux потом искала по нему `.dll`. каждого слоя считается на каждый отсчёт, и длина результата получается ровно такой, какая нужна. +## Качество пересобираемых кусков + +Куски, которые нельзя скопировать, целятся в наибольший битрейт среди +файлов проекта. Без этого кодировщик берёт своё значение по умолчанию, и +оно скромное: на съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с — +пересобранный кусок бросался в глаза среди скопированных соседей. + +Кроме среднего задаётся потолок в полтора раза выше и буфер вдвое: без +них кодировщик разгоняется на сложных кадрах ровно настолько, насколько +позволит средний, и мелкие детали всё равно теряются. + +Берётся именно наибольший, а не средний: пересобранный кусок стоит рядом +со скопированными, и если он окажется хуже их, разница видна именно на +нём. Лишнее место дешевле. + ## Как собирается ролик 1. Монтаж уплощается в последовательность непересекающихся кусков. diff --git a/src/uAppTypes.pas b/src/uAppTypes.pas index f1cbd9f..c7b9144 100644 --- a/src/uAppTypes.pas +++ b/src/uAppTypes.pas @@ -58,6 +58,12 @@ type Y: Integer; { Постоянная непрозрачность слоя. } Opacity: Double; + { Слой — неподвижная картинка, а не видео. + + Её надо повторять: одиночный файл даёт ffmpeg ровно один кадр, и + кусок с наложением схлопывался в одну шестидесятую секунды — + фотография из ролика просто пропадала. } + IsImage: Boolean; { Цветовой ключ слоя: этот цвет становится прозрачным. Ключ выключен, если KeyEnabled равно False. } KeyEnabled: Boolean; diff --git a/src/uExport.pas b/src/uExport.pas index 88ac49f..2a4cca5 100644 --- a/src/uExport.pas +++ b/src/uExport.pas @@ -537,7 +537,8 @@ begin FSegments[I].Duration, FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen, FMaster.PixFmt, PreferredEncoder(FMaster.VideoCodec), - FIntermediateFormat = 'mpegts'), + FIntermediateFormat = 'mpegts', FMaster.VideoBitrate, + FSegments[I].SubFile), 'Наложение в куске ' + IntToStr(I + 1)) then Exit; @@ -568,7 +569,8 @@ begin FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen, FMaster.VideoCodec, FMaster.PixFmt, FMaster.AudioCodec, FMaster.SampleRate, FMaster.Channels, FSegments[I].Volume, - FIntermediateFormat, FSegments[I].Filter, FSegments[I].SubFile), + FIntermediateFormat, FSegments[I].Filter, FSegments[I].SubFile, + FMaster.VideoBitrate), 'Пересборка куска ' + IntToStr(I + 1)) then Exit; diff --git a/src/uExportForm.pas b/src/uExportForm.pas index 01cf3bb..2dca9eb 100644 --- a/src/uExportForm.pas +++ b/src/uExportForm.pas @@ -37,9 +37,13 @@ type AHasText — есть ли в проекте надписи: без них про текст и спрашивать незачем. APreferMkv — в монтаже есть точные резы, и MKV надёжнее. - ABurnDefault — ответ, данный в прошлый раз. } + ABurnDefault — ответ, данный в прошлый раз. + + ARegion — описание отмеченного участка. Не пусто — окно прямо говорит, + что собирается не весь ролик: это легко забыть и потом долго искать, + почему в файле не хватает половины. } function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean; - const ADefaultDir: string): Boolean; + const ADefaultDir: string; const ARegion: string = ''): Boolean; implementation @@ -166,7 +170,7 @@ begin end; function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean; - const ADefaultDir: string): Boolean; + const ADefaultDir: string; const ARegion: string): Boolean; var Dlg: TExportDialog; Lbl: TLabel; @@ -221,6 +225,16 @@ begin Dlg.BtnMkv.TabStop := False; Dlg.BtnMkv.OnClick := @Dlg.FormatClick; + if ARegion <> '' then + begin + Lbl := TLabel.Create(Dlg.Form); + Lbl.Parent := Dlg.Form; + Lbl.SetBounds(310, 76, 300, 20); + Lbl.Anchors := [akTop, akLeft, akRight]; + Lbl.Caption := 'Только участок: ' + ARegion; + Lbl.Font.Color := TColor($30A0D0); + end; + Dlg.cbBurn := TCheckBox.Create(Dlg.Form); Dlg.cbBurn.Parent := Dlg.Form; Dlg.cbBurn.SetBounds(12, 116, 300, 22); diff --git a/src/uFfmpegCmd.pas b/src/uFfmpegCmd.pas index b08f31e..0ec1913 100644 --- a/src/uFfmpegCmd.pas +++ b/src/uFfmpegCmd.pas @@ -87,7 +87,7 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double; const AVideoCodec, APixFmt, AAudioCodec: string; ASampleRate, AChannels: Integer; AVolume: Double; const AFormat: string; const AFilter: string = ''; - const ASubFile: string = ''): TArgs; + const ASubFile: string = ''; ABitrate: Int64 = 0): TArgs; { Путь внутри строки фильтра. Двоеточие там разделяет настройки, обратная косая и кавычка имеют своё значение — всё это надо обезвредить, иначе @@ -146,11 +146,16 @@ function ArgsEncodeAudio(const ASource, ATarget: string; Постоянная непрозрачность делается наложением с приглушённым альфа- каналом, переход — фильтром xfade. Их нельзя перепутать: xfade умеет - плавно менять долю во времени, а простое наложение — нет. } + плавно менять долю во времени, а простое наложение — нет. + + ASubFile — разметка текста, которую надо вжечь в этот кусок. Пусто — не + вжигать. Без этого текст пропадал везде, где есть наложение: кусок шёл + этой веткой, а она про субтитры не знала. } function ArgsRenderLayers(const ALayers: array of TSegmentLayer; const ATarget: string; const ADuration: Double; AWidth, AHeight, AFpsNum, AFpsDen: Integer; - const APixFmt, ACodec: string; AIsTs: Boolean): TArgs; + const APixFmt, ACodec: string; AIsTs: Boolean; + ABitrate: Int64 = 0; const ASubFile: string = ''): TArgs; { Сшивание готовой картинки с готовым звуком. Оба потока копируются. @@ -197,7 +202,7 @@ function ArgsNormalize(const ASrc, ADst: string; function ArgsImageClip(const ASrc, ADst: string; ADuration: Double; AWidth, AHeight, AFpsNum, AFpsDen: Integer; const AVideoCodec, APixFmt, AAudioCodec: string; - ASampleRate, AChannels: Integer): TArgs; + ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs; { Сделать чёрный клип с тишиной заданной длины. @@ -237,6 +242,10 @@ implementation uses StrUtils, uTransform; +{ Ключи качества. Объявлено в самом начале: ими пользуется отрисовка + слоёв, а она по тексту раньше самой функции. } +function BitrateArgs(ABitrate: Int64): TArgs; forward; + { Общие параметры вывода в формате проекта. Объявлено заранее, потому что используется выше по тексту, чем определено. } function ArgsExtractAudioRaw(const ASource, ATarget: string; @@ -279,20 +288,33 @@ end; function ArgsRenderLayers(const ALayers: array of TSegmentLayer; const ATarget: string; const ADuration: Double; AWidth, AHeight, AFpsNum, AFpsDen: Integer; - const APixFmt, ACodec: string; AIsTs: Boolean): TArgs; + const APixFmt, ACodec: string; AIsTs: Boolean; + ABitrate: Int64; const ASubFile: string): TArgs; var I: Integer; - Graph, Fit, Src: string; + Graph, Fit, Src, OutLabel: string; Crossfade: Boolean; begin Result := ['-hide_banner', '-nostdin', '-loglevel', 'warning', '-y']; - { Каждый слой — отдельный вход, вырезанный ровно по длине участка. } + { Каждый слой — отдельный вход, вырезанный ровно по длине участка. + + Неподвижная картинка — случай особый: её надо повторять ключом -loop, + иначе ffmpeg отдаёт один-единственный кадр, и весь кусок с наложением + схлопывается в одну шестидесятую секунды. Перематывать её тоже + незачем: у картинки нет времени. } for I := 0 to High(ALayers) do - Result := Concat(Result, - ['-ss', SecondsToStr(ALayers[I].SrcStart), - '-t', SecondsToStr(ADuration), - '-i', ALayers[I].FilePath]); + if ALayers[I].IsImage then + Result := Concat(Result, + ['-loop', '1', + '-framerate', Format('%d/%d', [AFpsNum, AFpsDen]), + '-t', SecondsToStr(ADuration), + '-i', ALayers[I].FilePath]) + else + Result := Concat(Result, + ['-ss', SecondsToStr(ALayers[I].SrcStart), + '-t', SecondsToStr(ADuration), + '-i', ALayers[I].FilePath]); { Переход отличается от наложения тем, что доля меняется во времени. } Crossfade := (Length(ALayers) = 2) and @@ -360,15 +382,27 @@ begin ALayers[High(ALayers)].X, ALayers[High(ALayers)].Y]); end; + { Текст вжигается последним: он ложится поверх уже сведённой картинки, + как и в куске без наложения. Разметка приходит со сдвигом под этот + кусок, поэтому время в ней считается от его начала. } + if ASubFile <> '' then + Graph := Graph + Format(';[out]subtitles=filename=''%s''[outs]', + [EscapeFilterPath(ASubFile)]); + { Кодировщик выбирается предпочтительный, а не тот, что ffmpeg возьмёт по имени кодека. Имя «hevc» уводит его на процессорный x265, и участок наложения считался медленнее реального времени: замерено 9.6 с материала за 11.2 с работы. } + if ASubFile <> '' then + OutLabel := '[outs]' + else + OutLabel := '[out]'; Result := Concat(Result, - ['-filter_complex', Graph, '-map', '[out]', + ['-filter_complex', Graph, '-map', OutLabel, '-t', SecondsToStr(ADuration), '-c:v', PreferredEncoder(ACodec), '-r', Format('%d/%d', [AFpsNum, AFpsDen]), '-pix_fmt', APixFmt]); + Result := Concat(Result, BitrateArgs(ABitrate)); Result := Concat(Result, ['-bf', '0', '-an', '-avoid_negative_ts', 'make_zero', '-progress', 'pipe:1', '-nostats']); @@ -403,7 +437,7 @@ end; function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer; const AVideoCodec, APixFmt, AAudioCodec: string; - ASampleRate, AChannels: Integer): TArgs; forward; + ASampleRate, AChannels: Integer; ABitrate: Int64 = 0): TArgs; forward; { Запрет двунаправленных кадров. Объявлено заранее по той же причине. } function NoBFramesArgs: TArgs; forward; @@ -617,7 +651,7 @@ function ArgsTranscodeSegment(const ASrc, ADst: string; AStart, AEnd: Double; const AVideoCodec, APixFmt, AAudioCodec: string; ASampleRate, AChannels: Integer; AVolume: Double; const AFormat: string; const AFilter: string; - const ASubFile: string): TArgs; + const ASubFile: string; ABitrate: Int64): TArgs; var Chain: string; begin @@ -651,7 +685,7 @@ begin Result := Concat(Result, ['-af', 'volume=' + SecondsToStr(AVolume)]); Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen, - AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels)); + AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate)); Result := Concat(Result, NoBFramesArgs); Result := Concat(Result, ['-avoid_negative_ts', 'make_zero']); Result := Concat(Result, ProgressArgs); @@ -925,9 +959,31 @@ begin end; { Реализация объявленной выше функции. } +{ Ключи качества для пересобираемых кусков. + + Без них кодировщик берёт своё значение по умолчанию, а оно скромное: на + съёмке 2688x1512 с 56 Мбит/с NVENC выдавал 5.9 Мбит/с, и пересобранный + кусок бросался в глаза среди скопированных. Поэтому целимся в тот же + битрейт, что у исходного материала. + + Кроме среднего задаётся потолок и размер буфера: без них кодировщик + разгоняется на сложных кадрах ровно настолько, насколько ему позволит + средний, и мелкие детали всё равно пропадают. } +function BitrateArgs(ABitrate: Int64): TArgs; +begin + Result := []; + { Ниже ста килобит в секунду — это не битрейт, а неизвестность: ffprobe + не всегда его сообщает. Тогда оставляем решение кодировщику. } + if ABitrate < 100000 then + Exit; + Result := ['-b:v', IntToStr(ABitrate div 1000) + 'k', + '-maxrate', IntToStr(ABitrate * 3 div 2000) + 'k', + '-bufsize', IntToStr(ABitrate div 500) + 'k']; +end; + function MasterOutputArgs(AWidth, AHeight, AFpsNum, AFpsDen: Integer; const AVideoCodec, APixFmt, AAudioCodec: string; - ASampleRate, AChannels: Integer): TArgs; + ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs; var Fps: string; begin @@ -943,6 +999,7 @@ begin Result := Concat(Result, ['-r', Fps]); if APixFmt <> '' then Result := Concat(Result, ['-pix_fmt', APixFmt]); + Result := Concat(Result, BitrateArgs(ABitrate)); if AAudioCodec <> '' then begin @@ -975,7 +1032,7 @@ end; function ArgsImageClip(const ASrc, ADst: string; ADuration: Double; AWidth, AHeight, AFpsNum, AFpsDen: Integer; const AVideoCodec, APixFmt, AAudioCodec: string; - ASampleRate, AChannels: Integer): TArgs; + ASampleRate, AChannels: Integer; ABitrate: Int64): TArgs; begin { -loop 1 повторяет единственный кадр, -t задаёт длину. Картинка вписывается в кадр целиком с чёрными полями по краям: @@ -998,7 +1055,7 @@ begin [AWidth, AHeight, AWidth, AHeight])]); Result := Concat(Result, MasterOutputArgs(0, 0, AFpsNum, AFpsDen, - AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels)); + AVideoCodec, APixFmt, AAudioCodec, ASampleRate, AChannels, ABitrate)); Result := Concat(Result, NoBFramesArgs); Result := Concat(Result, ['-shortest']); Result := Concat(Result, ProgressArgs); diff --git a/src/uFlatten.pas b/src/uFlatten.pas index b639f8b..b8ac238 100644 --- a/src/uFlatten.pas +++ b/src/uFlatten.pas @@ -152,6 +152,20 @@ function FlattenPreview(AProject: TProject; out AMissing: TGenRequests; AWithKey: Boolean = False): Boolean; +{ Оставляет от списка кусков только то, что попадает в участок. + + Куски, задетые краем, подрезаются: у начального сдвигается точка входа в + исходник, у конечного укорачивается длина. Слои внутри куска сдвигаются + вместе с ним — иначе наложение поехало бы относительно картинки. + + Зачем: собрать кусок монтажа, не собирая весь ролик. На длинном + материале это разница между минутой и часом, и без этого проверить + правку в готовом файле почти невозможно. + + Возвращает, сколько секунд осталось. } +function ClipSegmentsToRange(var ASegments: TFlatSegmentArray; + const AFrom, ATo: Double): Double; + { Путь к сгенерированному клипу. Имя выводится из содержимого запроса, поэтому одинаковые запросы дают один и тот же файл, и он делается один раз. } @@ -277,6 +291,48 @@ begin ALayers[I].KeySimilarity, ALayers[I].KeyBlend]); end; +function ClipSegmentsToRange(var ASegments: TFlatSegmentArray; + const AFrom, ATo: Double): Double; +var + I, J, N: Integer; + At, SegStart, SegEnd, NewStart, NewEnd, Shift: Double; + Kept: TFlatSegmentArray; + Seg: TFlatSegment; +begin + Result := 0; + if ATo <= AFrom then + Exit; + + N := 0; + At := 0; + SetLength(Kept, Length(ASegments)); + for I := 0 to High(ASegments) do + begin + SegStart := At; + SegEnd := At + ASegments[I].Duration; + At := SegEnd; + + NewStart := Max(SegStart, AFrom); + NewEnd := Min(SegEnd, ATo); + if NewEnd <= NewStart + 0.0005 then + Continue; + + Seg := ASegments[I]; + Shift := NewStart - SegStart; + Seg.SrcStart := Seg.SrcStart + Shift; + Seg.Duration := NewEnd - NewStart; + for J := 0 to High(Seg.Layers) do + Seg.Layers[J].SrcStart := Seg.Layers[J].SrcStart + Shift; + + Kept[N] := Seg; + Inc(N); + Result := Result + Seg.Duration; + end; + + SetLength(Kept, N); + ASegments := Kept; +end; + procedure ResolveLayers(AProject: TProject; var ASegments: TFlatSegmentArray; var AMissing: TGenRequests); var @@ -437,6 +493,7 @@ var if It = nil then Exit; Result.FilePath := It.EffectivePath; + Result.IsImage := (AClip.Kind = ckImage) or (It.Info.Kind = mkImage); Result.SrcStart := AClip.SrcIn + (AAt - AClip.TlStart); Result.KeyEnabled := AClip.ColorKey.Enabled; Result.KeyColor := AClip.ColorKey.Color; diff --git a/src/uGenerate.pas b/src/uGenerate.pas index 1f16904..75b3fdb 100644 --- a/src/uGenerate.pas +++ b/src/uGenerate.pas @@ -226,11 +226,13 @@ begin FMaster.VideoCodec, FMaster.PixFmt, AudioCodec, FMaster.SampleRate, FMaster.Channels); gkImage: + { Фотография попадает в ролик наравне со съёмкой, и жать её + сильнее, чем её соседей, незачем. } Args := ArgsImageClip(FRequests[I].SourcePath, FRequests[I].TargetPath, FRequests[I].Duration, FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen, FMaster.VideoCodec, FMaster.PixFmt, AudioCodec, - FMaster.SampleRate, FMaster.Channels); + FMaster.SampleRate, FMaster.Channels, FMaster.VideoBitrate); gkSilence: Args := ArgsSilenceClip(FRequests[I].TargetPath, FRequests[I].Duration, AudioCodec, @@ -243,7 +245,7 @@ begin Args := ArgsRenderLayers(FRequests[I].Layers, FRequests[I].TargetPath, FRequests[I].Duration, FMaster.Width, FMaster.Height, FMaster.FpsNum, FMaster.FpsDen, - FMaster.PixFmt, FMaster.VideoCodec, False); + FMaster.PixFmt, FMaster.VideoCodec, False, FMaster.VideoBitrate); else Continue; end; diff --git a/src/uMainForm.pas b/src/uMainForm.pas index c60e5c0..a5bb913 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -303,6 +303,11 @@ type procedure MenuSplitPrecise(Sender: TObject); procedure MenuDelete(Sender: TObject); procedure MenuColorKey(Sender: TObject); + procedure MenuRegionIn(Sender: TObject); + procedure MenuRegionOut(Sender: TObject); + procedure MenuRegionClear(Sender: TObject); + { Что и откуда собирать: с учётом отмеченного участка. } + procedure DescribeRegion(out AFrom, ATo: Double); procedure MenuSelectAll(Sender: TObject); procedure MenuRipple(Sender: TObject); procedure MenuToggleRipple(Sender: TObject); @@ -665,6 +670,10 @@ begin AddItem(MEdit, 'Выделить всё', @MenuSelectAll, ShortCut(VK_A, [ssCtrl])); AddItem(MEdit, 'Цветовой ключ клипа...', @MenuColorKey); AddSep(MEdit); + AddItem(MEdit, 'Отметить начало участка', @MenuRegionIn, ShortCut(VK_I, [])); + AddItem(MEdit, 'Отметить конец участка', @MenuRegionOut, ShortCut(VK_O, [])); + AddItem(MEdit, 'Снять отметки участка', @MenuRegionClear); + AddSep(MEdit); AddItem(MEdit, 'Схлопнуть дыру под курсором', @MenuRipple, ShortCut(VK_R, [ssCtrl, ssShift])); AddItem(MEdit, 'Автосхлопывание при перетаскивании', @MenuToggleRipple, @@ -1877,6 +1886,7 @@ procedure TMainForm.RebuildPreview; var Segments, AudioSegs, TrackSegs: TFlatSegmentArray; Overlays: TPreviewOverlays; + GenMaster: TMediaInfo; Missing, TrackMissing: TGenRequests; I, J: Integer; TotalLen: Double; @@ -1953,7 +1963,11 @@ begin begin FGenerateBusy := True; SetStatus('Готовлю вставки для предпросмотра...'); - FGenerate := TGenerateThread.Create(Missing, FProject.Master); + { Вставки — картинки и связки — попадают в ролик наравне со съёмкой, + поэтому целятся в тот же битрейт. } + GenMaster := FProject.Master; + GenMaster.VideoBitrate := FProject.MaxVideoBitrate; + FGenerate := TGenerateThread.Create(Missing, GenMaster); FGenerate.OnProgress := @GenerateProgress; FGenerate.OnLog := @GenerateLog; FGenerate.OnTerminate := @GenerateDone; @@ -3112,6 +3126,67 @@ begin SetStatus('Цветовой ключ выключен.'); end; +{ Отметки участка. + + Участок нужен, чтобы собрать кусок монтажа, а не весь ролик: на длинном + материале проверить правку иначе почти невозможно — сборка занимает + столько же, сколько сам ролик, а то и больше. + + Отметки ставятся по курсору, как в монтажных программах. Перепутанные + местами меняются сами: требовать от человека помнить порядок — лишняя + строгость там, где всё однозначно. } +procedure TMainForm.MenuRegionIn(Sender: TObject); +begin + FProject.RegionStart := FTimeline.Playhead; + if FProject.RegionEnd <= FProject.RegionStart then + FProject.RegionEnd := FProject.EndTime; + FTimeline.Invalidate; + UpdateCaption; + SetStatus(Format('Участок: %s — %s', + [SecondsToStr(FProject.RegionStart), SecondsToStr(FProject.RegionEnd)])); +end; + +procedure TMainForm.MenuRegionOut(Sender: TObject); +var + Tmp: Double; +begin + FProject.RegionEnd := FTimeline.Playhead; + if FProject.RegionEnd < FProject.RegionStart then + begin + Tmp := FProject.RegionStart; + FProject.RegionStart := FProject.RegionEnd; + FProject.RegionEnd := Tmp; + end; + FTimeline.Invalidate; + UpdateCaption; + SetStatus(Format('Участок: %s — %s', + [SecondsToStr(FProject.RegionStart), SecondsToStr(FProject.RegionEnd)])); +end; + +procedure TMainForm.MenuRegionClear(Sender: TObject); +begin + FProject.RegionStart := 0; + FProject.RegionEnd := 0; + FTimeline.Invalidate; + UpdateCaption; + SetStatus('Отметки участка сняты: собирается весь монтаж.'); +end; + +{ Границы того, что собираем. Без отметок — весь монтаж. } +procedure TMainForm.DescribeRegion(out AFrom, ATo: Double); +begin + if FProject.HasRegion then + begin + AFrom := Max(0, FProject.RegionStart); + ATo := FProject.RegionEnd; + end + else + begin + AFrom := 0; + ATo := FProject.EndTime; + end; +end; + procedure TMainForm.MenuSelectAll(Sender: TObject); begin FTimeline.SelectAll; @@ -3259,8 +3334,9 @@ var Missing: TGenRequests; HasPrecise: Boolean; T, C: Integer; - Rebuilt: Double; - SubsPath: string; + Rebuilt, RegionFrom, RegionTo: Double; + SubsPath, RegionCaption: string; + Master: TMediaInfo; begin if not FlattenProject(FProject, Segments, AudioSegs, Missing) then begin @@ -3278,6 +3354,24 @@ begin Exit; end; + { Отмеченный участок: от списка кусков остаётся только он. Куски, + задетые краем, подрезаются — картинка и звук режутся по одному и тому + же времени, иначе они разъедутся. } + DescribeRegion(RegionFrom, RegionTo); + RegionCaption := ''; + if FProject.HasRegion then + begin + RegionCaption := SecondsToStr(RegionFrom) + ' — ' + SecondsToStr(RegionTo); + ClipSegmentsToRange(Segments, RegionFrom, RegionTo); + ClipSegmentsToRange(AudioSegs, RegionFrom, RegionTo); + if Length(Segments) = 0 then + begin + ThemedMessageDlg('nlve', + 'В отмеченном участке нечего собирать.', mtInformation, [mbOK], 0); + Exit; + end; + end; + { Сколько материала придётся пересобрать. Наложение, переход, полупрозрачность и чужие параметры файла — всё это перекодирование, и человек должен знать цену заранее, а не обнаружить её по времени @@ -3300,7 +3394,7 @@ begin { Куда собирать, в каком контейнере и что делать с текстом — один вопрос: ответ на любую его часть меняет допустимое в остальных. } if not AskExport(FExportChoice, HasText(FProject), HasPrecise, - FSettings.LastExportDir) then + FSettings.LastExportDir, RegionCaption) then Exit; FSettings.LastExportDir := ExtractFilePath(FExportChoice.Path); @@ -3319,13 +3413,17 @@ begin if HasText(FProject) then begin if FExportChoice.BurnText then + { Начало списка — не обязательно начало монтажа: при отмеченном + участке текст надо брать с его начала. } Rebuilt := Rebuilt + - BurnTextIntoSegments(FProject, Segments, FProject.CacheDir) + BurnTextIntoSegments(FProject, Segments, FProject.CacheDir, RegionFrom) else begin SubsPath := IncludeTrailingPathDelimiter(FProject.CacheDir) + 'export.ass'; - if not WriteAssFile(FProject, SubsPath) then + { Разметка тоже по участку: иначе субтитры в готовом файле + появлялись бы со сдвигом на его начало. } + if not WriteAssForRange(FProject, RegionFrom, RegionTo, SubsPath) then SubsPath := ''; end; end; @@ -3343,13 +3441,22 @@ begin mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; + if FProject.HasRegion then + Log(Format('Собирается отмеченный участок: %s — %s', + [SecondsToStr(RegionFrom), SecondsToStr(RegionTo)])); Log('Сборка в ' + FExportChoice.Path); Log(Format('%d %s, длительность %s с', [Length(Segments), Plural(Length(Segments), 'кусок', 'куска', 'кусков'), SecondsToStr(TotalDuration(Segments))])); + { Пересобираемые куски целятся в битрейт самого щедрого исходника, а не + в то, что кодировщик выберет сам: его выбор скромнее в разы, и + пересобранный кусок оказывается заметно хуже соседних скопированных. } + Master := FProject.Master; + Master.VideoBitrate := FProject.MaxVideoBitrate; + FExport := TExportThread.Create(Segments, AudioSegs, - FExportChoice.Path, True, FProject.Master, AProfile); + FExportChoice.Path, True, Master, AProfile); FExport.SubtitlePath := SubsPath; FExport.OnProgress := @ExportProgress; FExport.OnLog := @ExportLog; diff --git a/src/uProject.pas b/src/uProject.pas index ef9bebb..d355467 100644 --- a/src/uProject.pas +++ b/src/uProject.pas @@ -249,6 +249,9 @@ type FTracks: TObjectList; FMaster: TMediaInfo; FRippleMode: TRippleMode; + { Отмеченный участок монтажа: от и до. Пока не отмечен, оба нуля. } + FRegionStart: Double; + FRegionEnd: Double; FRippleEnabled: Boolean; FFileName: string; FModified: Boolean; @@ -361,6 +364,22 @@ type { Режим действует всегда, даже когда автосхлопывание выключено: он определяет, что сделает Ctrl+Shift+R. } property RippleMode: TRippleMode read FRippleMode write FRippleMode; + { Отмеченный участок: собирать можно только его. Нужен, чтобы + проверить кусок монтажа, не собирая весь ролик — на длинном + материале это разница между минутой и часом. + + Отметок две, и обе живут в проекте, а не в настройках программы: + участок относится к этому монтажу, а не к привычкам человека. } + property RegionStart: Double read FRegionStart write FRegionStart; + property RegionEnd: Double read FRegionEnd write FRegionEnd; + function HasRegion: Boolean; + { Наибольший битрейт среди видеофайлов проекта. + + Им целятся при пересборке кусков. Брать средний или битрейт первого + файла нельзя: пересобранный кусок стоит рядом со скопированными, и + если он окажется хуже их, разница бросится в глаза именно на нём. + Лучше потратить место, чем показать шакала посреди чистой съёмки. } + function MaxVideoBitrate: Int64; { Автоматическое схлопывание при перетаскивании клипов. } property RippleEnabled: Boolean read FRippleEnabled write FRippleEnabled; property FileName: string read FFileName write FFileName; @@ -576,6 +595,8 @@ begin FMaster := Default(TMediaInfo); FRippleMode := rmCurrentTrack; FRippleEnabled := False; + FRegionStart := 0; + FRegionEnd := 0; FFileName := ''; FModified := False; FNextId := 1; @@ -962,6 +983,29 @@ begin end; end; +{ Участок считается отмеченным, только если он не пустой: две отметки в + одной точке — это отсутствие участка, а не участок нулевой длины. } +function TProject.HasRegion: Boolean; +begin + Result := FRegionEnd > FRegionStart + 0.001; +end; + +function TProject.MaxVideoBitrate: Int64; +var + I: Integer; + Item: TMediaItem; +begin + Result := FMaster.VideoBitrate; + for I := 0 to FPool.Count - 1 do + begin + Item := TMediaItem(FPool[I]); + if not Item.Info.HasVideo then + Continue; + if Item.Info.VideoBitrate > Result then + Result := Item.Info.VideoBitrate; + end; +end; + procedure TProject.ClearSelection; var T, C: Integer; @@ -1462,7 +1506,9 @@ begin Root.Add('settings', TJSONObject.Create([ 'ripple_mode', RippleModeToStr(FRippleMode), - 'ripple_enabled', FRippleEnabled + 'ripple_enabled', FRippleEnabled, + 'region_start', FRegionStart, + 'region_end', FRegionEnd ])); Result := Root.FormatJSON; @@ -1585,6 +1631,8 @@ begin begin FRippleMode := StrToRippleMode(Settings.Get('ripple_mode', 'current')); FRippleEnabled := Settings.Get('ripple_enabled', False); + FRegionStart := Settings.Get('region_start', Double(0)); + FRegionEnd := Settings.Get('region_end', Double(0)); { Старые проекты хранили один флаг «все дорожки». } if Settings.Get('ripple_all_tracks', False) then FRippleMode := rmAllTracks; diff --git a/src/uSubtitles.pas b/src/uSubtitles.pas index 5b74bdf..6f10e16 100644 --- a/src/uSubtitles.pas +++ b/src/uSubtitles.pas @@ -79,9 +79,14 @@ function WriteAssForRange(AProject: TProject; const AFrom, ATo: Double; пересборки и подкладывает свою разметку. Возвращает, сколько секунд придётся пересобрать сверх того, что и так - пересобиралось: человеку это говорят до начала. } + пересобиралось: человеку это говорят до начала. + + AStartTime — время монтажа, которому соответствует начало списка. + Нужен, когда собирают не весь ролик, а отмеченный участок: иначе текст + брался бы с начала монтажа и лёг бы не на те куски. } function BurnTextIntoSegments(AProject: TProject; - var ASegments: TFlatSegmentArray; const ACacheDir: string): Double; + var ASegments: TFlatSegmentArray; const ACacheDir: string; + const AStartTime: Double = 0): Double; implementation @@ -479,7 +484,8 @@ begin end; function BurnTextIntoSegments(AProject: TProject; - var ASegments: TFlatSegmentArray; const ACacheDir: string): Double; + var ASegments: TFlatSegmentArray; const ACacheDir: string; + const AStartTime: Double): Double; var I: Integer; At, SegEnd: Double; @@ -489,7 +495,7 @@ begin if (AProject = nil) or (not HasText(AProject)) then Exit; - At := 0; + At := AStartTime; for I := 0 to High(ASegments) do begin SegEnd := At + ASegments[I].Duration; diff --git a/src/uTimelineCtrl.pas b/src/uTimelineCtrl.pas index 753eeeb..c81f127 100644 --- a/src/uTimelineCtrl.pas +++ b/src/uTimelineCtrl.pas @@ -143,6 +143,7 @@ type function YToVolume(const AY: Integer; ATop: Integer): Double; procedure DrawRuler(ACanvas: TCanvas); + procedure DrawRegion(ACanvas: TCanvas); procedure DrawTracks(ACanvas: TCanvas); procedure DrawClip(ACanvas: TCanvas; AClip: TClip; ATop: Integer); procedure DrawPlayhead(ACanvas: TCanvas); @@ -351,6 +352,8 @@ const ColorClipBorder = TColor($5080AA); // светлая рамка клипа ColorClipText = TColor($F0F0F0); ColorPlayhead = TColor($4040FF); // красный: привычный цвет курсора + ColorRegion = TColor($30A0D0); // отмеченный участок: янтарная полоса + ColorRegionEdge = TColor($1878B0); ColorEdgeSnap = TColor($909090); // серый: рез притянут к опорному кадру ColorEdgePrecise = TColor($FFC030); // голубой: рез потребует перекодирования ColorWave = TColor($96C8D2); // пастельно-жёлтый: форма звука @@ -704,9 +707,41 @@ begin T := T + Step; end; + { Отмеченный участок: полоса по низу линейки и стойки по краям. + + Рисуется после делений, чтобы не оказаться под ними, и только на + линейке: закрашивать сами дорожки нельзя — под краской не разглядеть + клипы, а участок отмечают как раз чтобы в них разбираться. } + DrawRegion(ACanvas); + ACanvas.Brush.Style := bsSolid; end; +procedure TTimelineControl.DrawRegion(ACanvas: TCanvas); +var + L, R: Integer; +begin + if (FProject = nil) or (not FProject.HasRegion) then + Exit; + + L := Max(FHeaderWidth, TimeToX(FProject.RegionStart)); + R := Min(ClientWidth, TimeToX(FProject.RegionEnd)); + if R <= L then + Exit; + + ACanvas.Brush.Style := bsSolid; + ACanvas.Brush.Color := ColorRegion; + ACanvas.FillRect(L, FRulerHeight - 6, R, FRulerHeight - 1); + + { Стойки во всю высоту: край участка должен быть виден и там, где + линейка уже уехала вверх за пределы видимого. } + ACanvas.Pen.Color := ColorRegionEdge; + if TimeToX(FProject.RegionStart) >= FHeaderWidth then + ACanvas.Line(L, 0, L, ClientHeight); + if TimeToX(FProject.RegionEnd) <= ClientWidth then + ACanvas.Line(R - 1, 0, R - 1, ClientHeight); +end; + procedure TTimelineControl.DrawClip(ACanvas: TCanvas; AClip: TClip; ATop: Integer); var diff --git a/tests/nlvetool.lpr b/tests/nlvetool.lpr index cb79d12..9e5f7dc 100644 --- a/tests/nlvetool.lpr +++ b/tests/nlvetool.lpr @@ -781,6 +781,51 @@ begin end; WriteLn; + { --- 9. Обрезка списка кусков по отмеченному участку --- } + WriteLn(' 9. Сборка только отмеченного участка'); + Proj := MakeProject(1, Media); + try + { Три куска по 5 секунд из разных мест файла: склеиться в один они не + могут, и границы участка приходятся на середину крайних. } + Proj.AddClip(Proj.Tracks[0], Media, 100, 5, 0); + Proj.AddClip(Proj.Tracks[0], Media, 200, 5, 5); + Proj.AddClip(Proj.Tracks[0], Media, 300, 5, 10); + FlattenStream(Proj, tkVideo, 0, Segs, Missing); + Check(Length(Segs) = 3, 'до обрезки три куска'); + + Check(Abs(ClipSegmentsToRange(Segs, 2, 12) - 10) < 0.001, + 'осталось десять секунд'); + DumpSegments(Segs); + Check(Length(Segs) = 3, 'куски остались все три'); + if Length(Segs) = 3 then + begin + Check(Abs(Segs[0].SrcStart - 102) < 0.001, + 'первый начинается на две секунды позже'); + Check(Abs(Segs[0].Duration - 3) < 0.001, 'и длится три секунды'); + Check(Abs(Segs[1].SrcStart - 200) < 0.001, 'средний не тронут'); + Check(Abs(Segs[1].Duration - 5) < 0.001, 'и целиком'); + Check(Abs(Segs[2].SrcStart - 300) < 0.001, 'последний с начала'); + Check(Abs(Segs[2].Duration - 2) < 0.001, 'и укорочен до двух секунд'); + end; + + { Участок целиком внутри одного куска. } + FlattenStream(Proj, tkVideo, 0, Segs, Missing); + Check(Abs(ClipSegmentsToRange(Segs, 6, 8) - 2) < 0.001, + 'внутри одного куска остались две секунды'); + Check(Length(Segs) = 1, 'и один кусок'); + if Length(Segs) = 1 then + Check(Abs(Segs[0].SrcStart - 201) < 0.001, 'взят с нужного места'); + + { Участок мимо монтажа. } + FlattenStream(Proj, tkVideo, 0, Segs, Missing); + Check(Abs(ClipSegmentsToRange(Segs, 100, 200)) < 0.001, + 'за концом монтажа не осталось ничего'); + Check(Length(Segs) = 0, 'и ни одного куска'); + finally + Proj.Free; + end; + WriteLn; + WriteLn; if FailCount = 0 then begin @@ -1239,6 +1284,13 @@ begin end; end; + { Вжигание текста — последним, уже по окончательному списку кусков: + повторное уплощение стёрло бы отметки о разметке. Так же устроено и + окно сборки. } + if (ParamCount >= 4) and (LowerCase(ParamStr(4)) = 'burn') then + WriteLn('вжигаю текст, пересобрать придётся ', + SecondsToStr(BurnTextIntoSegments(Proj, Segs, Proj.CacheDir)), ' с'); + Thread := TExportThread.Create(Segs, AudioSegs, ExpandFileName(ParamStr(3)), True, Proj.Master, Default(TEncodeProfile)); Watcher := TExportWatcher.Create(Thread);