From 96672341d2ceecf8aa714ced03f8fac017641c18 Mon Sep 17 00:00:00 2001 From: 4aiman Date: Mon, 31 Aug 2026 11:00:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B3=D0=BE=D0=BB=D0=BA=D0=B8=20=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D1=82=D0=BA=D0=B0,=20=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D1=81=D0=BE=D1=80=20=D0=BF=D0=BE=D1=81=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B5=20=D0=B8=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BC=20=D1=81=20=D1=80=D0=BE=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отметки участка стали видимыми уголками на линейке — жёлтые треугольники по краям, за них можно тянуть. Клавишами это выходило долго: поставил, посмотрел, промахнулся, ставь заново. Край прилипает к границам клипов, как и курсор, и края не пропускаются друг за друга. Клавиша \ и кнопка с целью переключают, держать ли курсор посередине монтажа при воспроизведении. Раньше монтаж ехал под курсором всегда, а это мешает, когда разглядываешь конкретное место. В окне сборки вместо надписи про участок появилась галка «только отмеченный участок». Надпись легко проглядеть, а вопрос из тех, где ошибка стоит часа работы: соберёшь кусок вместо ролика или наоборот. Снятая галка собирает весь монтаж, отметки при этом остаются. Журнал сборки пишется рядом с готовым файлом и под тем же именем. Общий журнал программы перезаписывается следующим запуском, а разбираться, почему в ролике не то, приходится потом. В журнале: время, проект, участок, кадр и битрейт, что с текстом, и полный список кусков — с пометками, где слои, где текст и что пересобирается. Строка пишется сразу, чтобы после падения осталась последняя. --- src/uExport.pas | 40 ++++++++ src/uExportForm.pas | 38 ++++++-- src/uIconData.pas | 69 ++++++++++++-- src/uMainForm.lfm | 19 +++- src/uMainForm.pas | 215 ++++++++++++++++++++++++++++++++++-------- src/uSettings.pas | 6 ++ src/uTheme.pas | 4 +- src/uTimelineCtrl.pas | 104 +++++++++++++++++++- 8 files changed, 434 insertions(+), 61 deletions(-) diff --git a/src/uExport.pas b/src/uExport.pas index 2a4cca5..c639865 100644 --- a/src/uExport.pas +++ b/src/uExport.pas @@ -115,6 +115,7 @@ type FStageText: string; FFraction: Double; FPendingLog: string; + FLogPath: string; FReport: TExportReport; FOnProgress: TNotifyEvent; @@ -142,6 +143,7 @@ type procedure SyncProgress; procedure SyncLog; procedure Log(const AText: string); + procedure WriteLogLine(const AText: string); procedure SetStage(const AText: string); procedure PublishProgress; @@ -187,6 +189,14 @@ type дорожкой. Пусто — либо текста нет, либо он вжигается в картинку. Годится только для Matroska: MP4 хранит субтитры без оформления. } property SubtitlePath: string read FSubtitlePath write FSubtitlePath; + { Куда писать журнал сборки. Пусто — никуда. + + Журнал кладётся рядом с готовым файлом и под тем же именем: когда + через неделю выяснится, что в ролике не то, разбираться придётся + именно по нему, а общий журнал программы к тому времени перезапишется + следующим запуском. Строка пишется сразу, а не копится в памяти: + если сборка упадёт, важна как раз последняя строка. } + property LogPath: string read FLogPath write FLogPath; end; { Каталог для временных кусков рядом с готовым файлом. } @@ -272,8 +282,38 @@ begin FOnLog(Self); end; +{ Строка в журнал сборки рядом с готовым файлом. + + Файл открывается и закрывается на каждой строке. Это медленно, но строк + за сборку десятки, а взамен журнал остаётся целым даже если программа + упадёт на следующем шаге — ради этого он и заводится. } +procedure TExportThread.WriteLogLine(const AText: string); +var + F: TextFile; +begin + if FLogPath = '' then + Exit; + try + AssignFile(F, FLogPath); + if FileExists(FLogPath) then + Append(F) + else + Rewrite(F); + try + WriteLn(F, AText); + finally + CloseFile(F); + end; + except + { Журнал не должен ронять сборку: не записалось — и ладно. } + on E: Exception do + FLogPath := ''; + end; +end; + procedure TExportThread.Log(const AText: string); begin + WriteLogLine(AText); FPendingLog := AText; Synchronize(@SyncLog); end; diff --git a/src/uExportForm.pas b/src/uExportForm.pas index 2dca9eb..72c5c97 100644 --- a/src/uExportForm.pas +++ b/src/uExportForm.pas @@ -31,6 +31,9 @@ type Path: string; { Вжигать ли текст в картинку. При MP4 всегда True. } BurnText: Boolean; + { Собирать только отмеченный участок. Имеет смысл, только когда + участок отмечен. } + RegionOnly: Boolean; end; { Показывает окно. @@ -39,9 +42,10 @@ type незачем. APreferMkv — в монтаже есть точные резы, и MKV надёжнее. ABurnDefault — ответ, данный в прошлый раз. - ARegion — описание отмеченного участка. Не пусто — окно прямо говорит, - что собирается не весь ролик: это легко забыть и потом долго искать, - почему в файле не хватает половины. } + ARegion — описание отмеченного участка. Не пусто — в окне появляется + галка «только отмеченный участок». Галка, а не надпись: надпись легко + проглядеть, а тут вопрос из тех, где ошибка стоит часа работы — соберёшь + кусок вместо ролика или наоборот. } function AskExport(var AChoice: TExportChoice; AHasText, APreferMkv: Boolean; const ADefaultDir: string; const ARegion: string = ''): Boolean; @@ -55,6 +59,7 @@ type BtnMp4: TThemedButton; BtnMkv: TThemedButton; cbBurn: TCheckBox; + cbRegion: TCheckBox; lblNote: TLabel; HasText: Boolean; { Ответ человека про вжигание, сохранённый до того, как MP4 навязал @@ -69,6 +74,7 @@ type procedure FormatClick(Sender: TObject); procedure BrowseClick(Sender: TObject); procedure BurnClick(Sender: TObject); + procedure RegionClick(Sender: TObject); function IsMp4: Boolean; procedure ApplyFormat; end; @@ -143,6 +149,12 @@ begin ApplyFormat; end; +{ Галка участка ничего не пересчитывает: она только запоминает ответ. + Всё, что от неё зависит, решается уже после закрытия окна. } +procedure TExportDialog.RegionClick(Sender: TObject); +begin +end; + procedure TExportDialog.BrowseClick(Sender: TObject); var Dlg: TSaveDialog; @@ -187,7 +199,7 @@ begin Dlg.Form.Caption := 'Сборка ролика'; Dlg.Form.Position := poScreenCenter; Dlg.Form.ClientWidth := 620; - Dlg.Form.ClientHeight := 250; + Dlg.Form.ClientHeight := 272; Lbl := TLabel.Create(Dlg.Form); Lbl.Parent := Dlg.Form; @@ -235,23 +247,32 @@ begin Lbl.Font.Color := TColor($30A0D0); end; + Dlg.cbRegion := TCheckBox.Create(Dlg.Form); + Dlg.cbRegion.Parent := Dlg.Form; + Dlg.cbRegion.SetBounds(12, 112, 596, 22); + Dlg.cbRegion.Anchors := [akTop, akLeft, akRight]; + Dlg.cbRegion.Caption := 'Только отмеченный участок: ' + ARegion; + Dlg.cbRegion.Visible := ARegion <> ''; + Dlg.cbRegion.Checked := ARegion <> ''; + Dlg.cbRegion.OnClick := @Dlg.RegionClick; + Dlg.cbBurn := TCheckBox.Create(Dlg.Form); Dlg.cbBurn.Parent := Dlg.Form; - Dlg.cbBurn.SetBounds(12, 116, 300, 22); + Dlg.cbBurn.SetBounds(12, 138, 300, 22); Dlg.cbBurn.Caption := 'Вжечь текст в картинку'; Dlg.cbBurn.Visible := AHasText; Dlg.cbBurn.OnClick := @Dlg.BurnClick; Dlg.lblNote := TLabel.Create(Dlg.Form); Dlg.lblNote.Parent := Dlg.Form; - Dlg.lblNote.SetBounds(12, 144, 596, 52); + Dlg.lblNote.SetBounds(12, 164, 596, 52); Dlg.lblNote.Anchors := [akTop, akLeft, akRight]; Dlg.lblNote.AutoSize := False; Dlg.lblNote.WordWrap := True; BtnOk := TThemedButton.Create(Dlg.Form); BtnOk.Parent := Dlg.Form; - BtnOk.SetBounds(400, 208, 100, 30); + BtnOk.SetBounds(400, 230, 100, 30); BtnOk.Anchors := [akRight, akBottom]; BtnOk.Caption := 'Собрать'; BtnOk.ModalResult := mrOk; @@ -259,7 +280,7 @@ begin BtnCancel := TThemedButton.Create(Dlg.Form); BtnCancel.Parent := Dlg.Form; - BtnCancel.SetBounds(508, 208, 100, 30); + BtnCancel.SetBounds(508, 230, 100, 30); BtnCancel.Anchors := [akRight, akBottom]; BtnCancel.Caption := 'Отмена'; BtnCancel.ModalResult := mrCancel; @@ -290,6 +311,7 @@ begin if AChoice.Path = '' then Exit; AChoice.BurnText := AHasText and Dlg.cbBurn.Checked; + AChoice.RegionOnly := (ARegion <> '') and Dlg.cbRegion.Checked; Result := True; finally Dlg.Form.Free; diff --git a/src/uIconData.pas b/src/uIconData.pas index 3d096f3..2487924 100644 --- a/src/uIconData.pas +++ b/src/uIconData.pas @@ -32,8 +32,8 @@ const $60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00, $02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70, $48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18, - $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E, - $20, $4F, $DD, $00, $00, $01, $3E, $49, $44, $41, $54, $38, $CB, $BD, $92, $31, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7, + $2C, $48, $09, $00, $00, $01, $3E, $49, $44, $41, $54, $38, $CB, $BD, $92, $31, $4B, $C3, $40, $14, $C7, $7F, $97, $44, $29, $68, $03, $5A, $45, $EA, $D0, $A5, $6B, $41, $44, $5C, $0A, $0E, $3A, $15, $DC, $1D, $DD, $FB, $51, $74, $71, $29, $F8, $39, $EC, $62, $06, $17, $5D, $EA, $22, $D2, $88, $93, $E0, $22, $68, $A1, @@ -66,8 +66,8 @@ const $60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00, $02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70, $48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18, - $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E, - $20, $4F, $DD, $00, $00, $01, $2F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $3D, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7, + $2C, $48, $09, $00, $00, $01, $2F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $3D, $2F, $83, $51, $18, $C6, $F1, $DF, $C3, $63, $31, $34, $11, $B5, $35, $D1, $A9, $C1, $E0, $25, $4C, $66, $49, $F9, $16, $24, $96, $C6, $37, $A8, $18, $7D, $02, $31, $98, $18, $99, $25, $44, $F8, $02, $26, $B1, $30, $30, $78, $D9, $90, $18, @@ -99,8 +99,8 @@ const $60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00, $02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70, $48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18, - $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E, - $20, $4F, $DD, $00, $00, $01, $0F, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BF, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7, + $2C, $48, $09, $00, $00, $01, $0F, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BF, $2E, $83, $51, $18, $C7, $F1, $CF, $5B, $15, $0C, $74, $93, $88, $91, $0B, $E8, $68, $60, $33, $34, $B1, $1A, $F4, $1E, $0C, $66, $8B, $C5, $05, $B8, $01, $89, $2B, $30, $18, $24, $4C, $0D, $8B, $41, $13, $46, $62, $90, $18, $A4, $4B, $55, @@ -130,8 +130,8 @@ const $60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00, $02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70, $48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18, - $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1E, $15, $14, $31, $9E, - $20, $4F, $DD, $00, $00, $01, $67, $49, $44, $41, $54, $38, $CB, $B5, $D3, $3D, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7, + $2C, $48, $09, $00, $00, $01, $67, $49, $44, $41, $54, $38, $CB, $B5, $D3, $3D, $4F, $93, $61, $14, $C6, $F1, $5F, $AB, $80, $9D, $A8, $A1, $71, $95, $08, $14, $3F, $80, $71, $D2, $8D, $C5, $D8, $A4, $A6, $61, $31, $74, $32, $31, $1A, $99, $89, $B3, $8B, $71, $B0, $7C, $0F, $37, $42, $4B, $07, $07, $D0, $98, $30, $E0, @@ -158,6 +158,59 @@ const $60, $82 ); + { icons8-цель-30.png, 24 на 24 точек } + IconTargetData: array[0..779] of Byte = ( + $89, $50, $4E, $47, $0D, $0A, $1A, $0A, $00, $00, $00, $0D, $49, $48, $44, $52, + $00, $00, $00, $18, $00, $00, $00, $18, $08, $04, $00, $00, $00, $4A, $7E, $F5, + $73, $00, $00, $00, $20, $63, $48, $52, $4D, $00, $00, $7A, $26, $00, $00, $80, + $84, $00, $00, $FA, $00, $00, $00, $80, $E8, $00, $00, $75, $30, $00, $00, $EA, + $60, $00, $00, $3A, $98, $00, $00, $17, $70, $9C, $BA, $51, $3C, $00, $00, $00, + $02, $62, $4B, $47, $44, $00, $00, $AA, $8D, $23, $32, $00, $00, $00, $09, $70, + $48, $59, $73, $00, $00, $0B, $13, $00, $00, $0B, $13, $01, $00, $9A, $9C, $18, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $08, $1F, $07, $2E, $1B, $C7, + $2C, $48, $09, $00, $00, $02, $71, $49, $44, $41, $54, $38, $CB, $95, $D4, $41, + $68, $DB, $65, $18, $06, $F0, $5F, $E2, $DA, $2A, $2E, $59, $B2, $65, $29, $4B, + $B4, $A7, $C9, $B0, $DB, $A0, $8C, $49, $63, $55, $50, $0F, $56, $31, $ED, $68, + $BA, $CB, $70, $3B, $C8, $F0, $24, $08, $0A, $1E, $44, $99, $4E, $AA, $B2, $5D, + $44, $86, $D9, $4D, $9B, $0E, $3C, $4D, $58, $87, $14, $75, $22, $C8, $DC, $9C, + $2B, $78, $4A, $D9, $84, $04, $6B, $93, $B5, $34, $15, $BB, $FE, $6B, $65, $5D, + $B5, $F1, $F0, $67, $AC, $DD, $40, $D8, $73, $FB, $E0, $7B, $78, $DF, $E7, $7D, + $DE, $E7, $E5, $1E, $11, $B9, $EB, $9D, $B0, $43, $46, $1C, $81, $69, $57, $2D, + $68, $AE, $FD, $B0, $01, $3C, $67, $97, $1F, $3C, $E1, $8C, $59, $AF, $3A, $20, + $23, $26, $22, $30, $E3, $8A, $AF, $9D, $31, $77, $9B, $70, $1F, $D8, $E7, $4D, + $3D, $0E, $DA, $62, $DC, $92, $55, $C3, $4E, $18, $71, $C1, $8A, $27, $E5, $ED, + $35, $A9, $BE, $9E, $70, $53, $8F, $9C, $16, $BB, $B4, $29, $B9, $EC, $2F, $AB, + $96, $D5, $7C, $67, $C4, $8A, $17, $F4, $FA, $5D, $65, $6D, $6B, $AF, $59, $D2, + $D4, $D4, $54, $13, $D7, $6F, $D2, $8A, $9B, $AA, $4E, $D9, $2F, $29, $AF, $6C, + $42, $4F, $A8, $37, $AC, $30, $23, $65, $B7, $28, $56, $7C, $26, $EA, $5F, $A3, + $CE, $F9, $D3, $D3, $06, $6D, $74, $DA, $55, $03, $1E, $F1, $AD, $BF, $43, $42, + $C4, $0D, $E3, $5A, $65, $FD, $E3, $41, $71, $A3, $C6, $FC, $EC, $8A, $F3, $C6, + $A4, $1C, $D2, $EA, $0B, $6D, $06, $5C, $F3, $4B, $D8, $50, $D2, $3B, $9E, $F1, + $80, $B8, $AC, $93, $16, $14, $6D, $B5, $DB, $59, $25, $DD, $D2, $4A, $1A, $06, + $75, $A8, $38, $2D, $11, $56, $E8, $F2, $B6, $55, $97, $3D, $25, $6A, $D4, $66, + $07, $6C, $31, $66, $D2, $CB, $FA, $9D, $F7, $A3, $7D, $36, $3A, $27, $E3, $31, + $17, $5C, $83, $82, $79, $07, $ED, $F4, $9B, $8F, $B4, $48, $F9, $D4, $BC, $A2, + $94, $01, $93, $86, $B5, $2B, $AA, $EA, $74, $C8, $BC, $42, $68, $5C, $4C, $4C, + $5D, $9B, $AC, $39, $4D, $DB, $BC, $2F, $E2, $25, $81, $0F, $8C, $7B, $D6, $B2, + $8A, $C3, $EE, $57, $17, $13, $BB, $E5, $74, $B8, $22, $4D, $4D, $31, $43, $4E, + $38, $22, $B0, $24, $22, $72, $F7, $F2, $6C, $C0, $A2, $45, $59, $B3, $EA, $B6, + $6B, $71, $DD, $C7, $8E, $18, $42, $C1, $5E, $DF, $6B, $B5, $5D, $CD, $0D, $3B, + $2D, $5A, $24, $8A, $69, $33, $72, $16, $5C, $D4, $2B, $A3, $88, $4F, $7C, $6E, + $D8, $90, $C0, $49, $DB, $F4, $BA, $E8, $BA, $9C, $E9, $50, $32, $09, $5F, $AA, + $78, $D8, $7E, $73, $4A, $D2, $BA, $95, $D4, $D4, $0C, $EB, $96, $36, $A2, $A1, + $A0, $43, $35, $1C, $6B, $88, $57, $34, $BC, $2B, $E9, $B8, $40, $49, $97, $B4, + $84, $A4, $B4, $2E, $25, $81, $63, $12, $8E, $9A, $75, $F8, $B6, $92, $AD, $BE, + $32, $25, $AF, $DD, $71, $0D, $55, $45, $AF, $7B, $43, $51, $55, $C3, $31, $ED, + $FA, $4C, $39, $2B, $B5, $36, $36, $3D, $26, $94, $F5, $49, $1A, $74, $4A, $C5, + $B2, $65, $15, $23, $0A, $12, $FA, $94, $95, $3D, $BE, $7E, $5E, $51, $79, $13, + $A6, $BC, $A7, $43, $46, $A7, $3D, $F6, $E8, $94, $D1, $E1, $A8, $9A, $B2, $17, + $45, $EF, $8C, $68, $44, $CE, $5B, $72, $02, $DF, $B8, $A4, $8E, $87, $E4, $3C, + $6F, $93, $9F, $7C, $E8, $D2, $AD, $34, $AC, $B7, $65, $B3, $7E, $79, $8F, $CA, + $88, $B1, $26, $A2, $7F, $FC, $DF, $11, $D8, $64, $87, $AC, $B8, $A6, $C0, $B4, + $5F, $EF, $3C, $02, $F7, $8C, $FF, $00, $D3, $AE, $CE, $9E, $6E, $47, $CD, $DC, + $00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82 + ); + { icons8-показ-видеокадров-30.png, значок окна, 32 на 32 точек, формат ico } IconAppData: array[0..4285] of Byte = ( $00, $00, $01, $00, $01, $00, $20, $20, $00, $00, $01, $00, $20, $00, $A8, $10, diff --git a/src/uMainForm.lfm b/src/uMainForm.lfm index c079a73..f9902ae 100644 --- a/src/uMainForm.lfm +++ b/src/uMainForm.lfm @@ -67,8 +67,19 @@ object MainForm: TMainForm ShowHint = True TabStop = False end + object btnCenter: TThemedButton + Left = 350 + Height = 28 + Top = 6 + Width = 28 + Down = True + Icon = tiTarget + OnClick = btnCenterClick + ShowHint = True + TabStop = False + end object btnRipple: TThemedButton - Left = 356 + Left = 386 Height = 28 Top = 6 Width = 28 @@ -78,7 +89,7 @@ object MainForm: TMainForm TabStop = False end object btnRippleMenu: TThemedButton - Left = 386 + Left = 416 Height = 28 Top = 6 Width = 22 @@ -87,10 +98,10 @@ object MainForm: TMainForm TabStop = False end object lblStatus: TLabel - Left = 458 + Left = 488 Height = 20 Top = 10 - Width = 810 + Width = 780 Anchors = [akTop, akLeft, akRight] AutoSize = False Caption = 'Готов.' diff --git a/src/uMainForm.pas b/src/uMainForm.pas index a5bb913..a8fb45b 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -47,6 +47,7 @@ type btnSnap: TThemedButton; btnGlue: TThemedButton; + btnCenter: TThemedButton; btnRipple: TThemedButton; btnRippleMenu: TThemedButton; lblPoolInfo: TLabel; @@ -67,6 +68,7 @@ type splitVideo: TSplitter; procedure btnSnapClick(Sender: TObject); procedure btnGlueClick(Sender: TObject); + procedure btnCenterClick(Sender: TObject); procedure btnRippleClick(Sender: TObject); procedure btnRippleMenuClick(Sender: TObject); procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean); @@ -97,6 +99,8 @@ type FLastLoadedEdl: string; FLastLoadedAudioEdl: string; { Наложение для плеера: список кусков оверлея и окна показа. } + { Журнал текущей сборки: файл рядом с готовым роликом. } + FExportLogPath: string; FPreviewOverlayUrl: string; FPreviewOverlayWindows: TOverlayWindows; { Списки кусков звуковых дорожек для плеера. } @@ -198,6 +202,8 @@ type procedure UpdateRippleButton; procedure UpdateSnapButton; procedure UpdateGlueButton; + procedure UpdateCenterButton; + procedure ToggleCenterPlayhead; procedure RippleModeChosen(Sender: TObject); procedure ToggleRipple; procedure BuildPoolMenu; @@ -330,6 +336,8 @@ type procedure ExportDone(Sender: TObject); procedure ExportCancel(Sender: TObject); procedure ShowExportReport(const AReport: TExportReport); + { Строка и в общий журнал программы, и в журнал этой сборки. } + procedure LogExport(const AText: string); procedure NormalizeProgress(Sender: TObject); procedure NormalizeLog(Sender: TObject); procedure NormalizeDone(Sender: TObject); @@ -433,6 +441,7 @@ begin UpdateRippleButton; UpdateSnapButton; UpdateGlueButton; + UpdateCenterButton; UpdateCaption; SetStatus('Готов. Добавьте файлы в проект.'); @@ -790,7 +799,7 @@ procedure TMainForm.BuildJobIndicator; const JobWidth = 330; LogButtonWidth = 28; - LogButtonLeft = 418; + LogButtonLeft = 448; begin FJobLabel := TLabel.Create(Self); FJobLabel.Parent := pnlBottom; @@ -2269,6 +2278,43 @@ begin UpdateGlueButton; end; +{ Держать ли курсор посередине при воспроизведении. + + Включено — монтаж едет под курсором, и всегда видно, что будет дальше. + Выключено — картинка стоит на месте, пока курсор не уйдёт за край; так + спокойнее, когда разглядываешь конкретное место, а ролик при этом + играет. } +procedure TMainForm.UpdateCenterButton; +var + State: string; +begin + btnCenter.Down := FSettings.CenterPlayhead; + if FSettings.CenterPlayhead then + State := 'включено' + else + State := 'выключено'; + btnCenter.Hint := 'Курсор посередине при воспроизведении: ' + State + '.' + + LineEnding + 'Клавиша \ переключает.'; +end; + +procedure TMainForm.ToggleCenterPlayhead; +begin + FSettings.CenterPlayhead := not FSettings.CenterPlayhead; + UpdateCenterButton; + if FSettings.CenterPlayhead then + begin + FTimeline.ScrollToPlayhead(True); + SetStatus('Курсор держится посередине монтажа.'); + end + else + SetStatus('Монтаж стоит на месте, курсор идёт по нему.'); +end; + +procedure TMainForm.btnCenterClick(Sender: TObject); +begin + ToggleCenterPlayhead; +end; + procedure TMainForm.ToggleRipple; begin FProject.RippleEnabled := not FProject.RippleEnabled; @@ -2811,8 +2857,11 @@ begin FTimeline.Playhead := FPlayer.TimePos; { Во время воспроизведения держим курсор посередине: так видно, что будет дальше, и не приходится гоняться за ним прокруткой. } + { Держать курсор посередине или дать монтажу стоять — решает человек: + на слабой машине и на мелком масштабе постоянная прокрутка мешает + разглядывать. } if not FPlayer.Paused then - FTimeline.ScrollToPlayhead(True); + FTimeline.ScrollToPlayhead(FSettings.CenterPlayhead); finally FUpdatingFromPlayer := False; end; @@ -2890,6 +2939,15 @@ begin begin Key := 0; TogglePlayback; + Exit; + end; + + { Обратная косая: пункта меню для неё нет намеренно — сочетание из + одной такой клавиши в меню выглядит как опечатка, а работать должно. } + if (Key = VK_OEM_5) and (Shift = []) then + begin + Key := 0; + ToggleCenterPlayhead; end; end; @@ -3335,7 +3393,7 @@ var HasPrecise: Boolean; T, C: Integer; Rebuilt, RegionFrom, RegionTo: Double; - SubsPath, RegionCaption: string; + SubsPath, RegionCaption, Extra: string; Master: TMediaInfo; begin if not FlattenProject(FProject, Segments, AudioSegs, Missing) then @@ -3354,32 +3412,16 @@ 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; { Сколько материала придётся пересобрать. Наложение, переход, полупрозрачность и чужие параметры файла — всё это перекодирование, и человек должен знать цену заранее, а не обнаружить её по времени сборки. } Rebuilt := 0; - for T := 0 to High(Segments) do - if Segments[T].Transcode or (Length(Segments[T].Layers) > 0) then - Rebuilt := Rebuilt + Segments[T].Duration; { Если есть хоть один точный рез, предупреждаем про контейнер: в MP4 перекодированный кусок и скопированный уживаются не всегда, а MKV @@ -3398,6 +3440,25 @@ begin Exit; FSettings.LastExportDir := ExtractFilePath(FExportChoice.Path); + { Участок режется после вопроса: человек мог снять галку и собрать всё. + Картинка и звук подрезаются по одному времени, иначе они разъедутся. } + if not (FProject.HasRegion and FExportChoice.RegionOnly) then + begin + RegionFrom := 0; + RegionTo := TotalDuration(Segments); + end + else + begin + ClipSegmentsToRange(Segments, RegionFrom, RegionTo); + ClipSegmentsToRange(AudioSegs, RegionFrom, RegionTo); + if Length(Segments) = 0 then + begin + ThemedMessageDlg('nlve', + 'В отмеченном участке нечего собирать.', mtInformation, [mbOK], 0); + Exit; + end; + end; + if HasPrecise and (LowerCase(ExtractFileExt(FExportChoice.Path)) <> '.mkv') then if ThemedMessageDlg('nlve', @@ -3407,6 +3468,13 @@ begin mtWarning, [mbYes, mbNo], 0) <> mrYes then Exit; + { Сколько материала придётся пересобрать — считаем уже по тому списку, + который пойдёт в сборку: с участком он короче, и обещать человеку + работу на весь ролик было бы неправдой. } + for T := 0 to High(Segments) do + if Segments[T].Transcode or (Length(Segments[T].Layers) > 0) then + Rebuilt := Rebuilt + Segments[T].Duration; + { Вжигание меняет расклад: куски с текстом перестают копироваться. Готовим их до подсчёта того, сколько придётся пересобрать. } SubsPath := ''; @@ -3441,23 +3509,64 @@ 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; + { Журнал этой сборки: рядом с готовым файлом, под тем же именем. + Прежний одноимённый убираем — иначе две сборки слиплись бы в один + файл, и разобрать, где чья, было бы нельзя. } + FExportLogPath := ChangeFileExt(FExportChoice.Path, '.log'); + if FileExists(FExportLogPath) then + SysUtils.DeleteFile(FExportLogPath); + + LogExport(Format('nlve, сборка от %s', + [FormatDateTime('yyyy-mm-dd hh:nn:ss', Now)])); + LogExport('Проект: ' + FProject.FileName); + LogExport('Готовый файл: ' + FExportChoice.Path); + if FProject.HasRegion and FExportChoice.RegionOnly then + LogExport(Format('Собирается отмеченный участок: %s — %s', + [SecondsToStr(RegionFrom), SecondsToStr(RegionTo)])) + else + LogExport('Собирается весь монтаж.'); + LogExport(Format('Кадр проекта: %dx%d, %d/%d к/с, %s, %s', + [Master.Width, Master.Height, Master.FpsNum, Master.FpsDen, + Master.VideoCodec, Master.PixFmt])); + LogExport(Format('Целевой битрейт: %d кбит/с (наибольший среди файлов проекта)', + [Master.VideoBitrate div 1000])); + if FExportChoice.BurnText then + LogExport('Текст вжигается в картинку.') + else if SubsPath <> '' then + LogExport('Текст идёт отдельной дорожкой субтитров.') + else if HasText(FProject) then + LogExport('ВНИМАНИЕ: текст в проекте есть, но в сборку не попадает.'); + LogExport(Format('%d %s, длительность %s с', + [Length(Segments), Plural(Length(Segments), 'кусок', 'куска', 'кусков'), + SecondsToStr(TotalDuration(Segments))])); + + { Что именно собирается — по кускам. Без этого списка по журналу нельзя + понять, попал ли в сборку тот клип, которого потом не нашли в ролике. } + for T := 0 to High(Segments) do + begin + Extra := ''; + if Length(Segments[T].Layers) > 0 then + Extra := Extra + ', слоёв ' + IntToStr(Length(Segments[T].Layers)); + if Segments[T].SubFile <> '' then + Extra := Extra + ', с текстом'; + if Segments[T].Transcode then + Extra := Extra + ', пересобирается'; + LogExport(Format(' кусок %d: %s, из %s, длина %s%s', + [T + 1, ExtractFileName(Segments[T].FilePath), + SecondsToStr(Segments[T].SrcStart), SecondsToStr(Segments[T].Duration), + Extra])); + end; + FExport := TExportThread.Create(Segments, AudioSegs, FExportChoice.Path, True, Master, AProfile); FExport.SubtitlePath := SubsPath; + FExport.LogPath := FExportLogPath; FExport.OnProgress := @ExportProgress; FExport.OnLog := @ExportLog; FExport.OnTerminate := @ExportDone; @@ -3515,6 +3624,36 @@ begin FExportUi.AddLog('Файл: ' + Rep.OutputPath); end; +{ Журнал сборки лежит рядом с готовым файлом и называется так же. + + Зачем отдельно от общего журнала программы: тот перезаписывается при + следующем запуске, а разбираться, почему в ролике не то, приходится + потом — иногда через неделю. Рядом с файлом журнал никуда не денется и + не перепутается с чужим. } +procedure TMainForm.LogExport(const AText: string); +var + F: TextFile; +begin + Log(AText); + if FExportLogPath = '' then + Exit; + try + AssignFile(F, FExportLogPath); + if FileExists(FExportLogPath) then + Append(F) + else + Rewrite(F); + try + WriteLn(F, AText); + finally + CloseFile(F); + end; + except + on E: Exception do + FExportLogPath := ''; + end; +end; + procedure TMainForm.ShowExportReport(const AReport: TExportReport); var I, Encoded: Integer; @@ -3522,17 +3661,17 @@ var begin if AReport.Cancelled then begin - Log('Сборка отменена.'); + LogExport('Сборка отменена.'); Exit; end; if not AReport.Succeeded then begin - Log('Сборка не удалась: ' + AReport.Message); + LogExport('Сборка не удалась: ' + AReport.Message); Exit; end; - Log('--- Отчёт о сборке ---'); + LogExport('--- Отчёт о сборке ---'); Encoded := 0; for I := 0 to High(AReport.Segments) do begin @@ -3540,14 +3679,14 @@ begin if AReport.Segments[I].Encoded then begin Inc(Encoded); - Log(Format(' часть %d (кусок %d, ПЕРЕКОДИРОВАНА): старт %s, длина %s, пакетов %d', + LogExport(Format(' часть %d (кусок %d, ПЕРЕКОДИРОВАНА): старт %s, длина %s, пакетов %d', [I + 1, AReport.Segments[I].Index + 1, SecondsToStr(AReport.Segments[I].ActualStart), SecondsToStr(AReport.Segments[I].Duration), AReport.Segments[I].Packets])); end else - Log(Format(' часть %d (кусок %d): старт %s (просили %s, сдвиг %s), длина %s, пакетов %d', + LogExport(Format(' часть %d (кусок %d): старт %s (просили %s, сдвиг %s), длина %s, пакетов %d', [I + 1, AReport.Segments[I].Index + 1, SecondsToStr(AReport.Segments[I].ActualStart), SecondsToStr(AReport.Segments[I].RequestedStart), @@ -3556,17 +3695,17 @@ begin AReport.Segments[I].Packets])); end; if Encoded > 0 then - Log(Format(' перекодировано частей: %d, остальное скопировано без потерь', + LogExport(Format(' перекодировано частей: %d, остальное скопировано без потерь', [Encoded])); - Log(Format(' сумма пакетов кусков: %d', [AReport.ExpectedPackets])); - Log(Format(' пакетов в готовом файле: %d', [AReport.ActualPackets])); + LogExport(Format(' сумма пакетов кусков: %d', [AReport.ExpectedPackets])); + LogExport(Format(' пакетов в готовом файле: %d', [AReport.ActualPackets])); if AReport.ExpectedPackets = AReport.ActualPackets then - Log(' СВЕРКА ПРОЙДЕНА: склейка не потеряла и не продублировала ни одного кадра.') + LogExport(' СВЕРКА ПРОЙДЕНА: склейка не потеряла и не продублировала ни одного кадра.') else - Log(Format(' РАСХОЖДЕНИЕ на %d кадров.', + LogExport(Format(' РАСХОЖДЕНИЕ на %d кадров.', [AReport.ActualPackets - AReport.ExpectedPackets])); - Log(' файл: ' + AReport.OutputPath); + LogExport(' файл: ' + AReport.OutputPath); SetStatus('Готово: ' + AReport.OutputPath); end; diff --git a/src/uSettings.pas b/src/uSettings.pas index b4b3684..72ff417 100644 --- a/src/uSettings.pas +++ b/src/uSettings.pas @@ -38,6 +38,7 @@ type FLogVisible: Boolean; FPlayheadSnap: Boolean; FPreviewKey: Boolean; + FCenterPlayhead: Boolean; FDepsReminder: Boolean; FRecentProjects: TStringList; function FileName: string; @@ -73,6 +74,8 @@ type property PlayheadSnap: Boolean read FPlayheadSnap write FPlayheadSnap; { Применять ли ключ и прозрачность прямо в предпросмотре. } property PreviewKey: Boolean read FPreviewKey write FPreviewKey; + { Держать ли курсор посередине монтажа при воспроизведении. } + property CenterPlayhead: Boolean read FCenterPlayhead write FCenterPlayhead; { Напоминать ли о недостающих зависимостях при запуске. Гасится кнопкой «Не напоминать» в самом напоминании. } property DepsReminder: Boolean read FDepsReminder write FDepsReminder; @@ -107,6 +110,7 @@ begin FLogVisible := True; FPlayheadSnap := True; FPreviewKey := True; + FCenterPlayhead := True; FDepsReminder := True; end; @@ -189,6 +193,7 @@ begin FLogVisible := Obj.Get('log_visible', FLogVisible); FPlayheadSnap := Obj.Get('playhead_snap', FPlayheadSnap); FPreviewKey := Obj.Get('preview_key', FPreviewKey); + FCenterPlayhead := Obj.Get('center_playhead', FCenterPlayhead); FDepsReminder := Obj.Get('deps_reminder', FDepsReminder); Arr := Obj.Find('recent_projects', jtArray) as TJSONArray; @@ -242,6 +247,7 @@ begin Root.Add('log_visible', FLogVisible); Root.Add('playhead_snap', FPlayheadSnap); Root.Add('preview_key', FPreviewKey); + Root.Add('center_playhead', FCenterPlayhead); Root.Add('deps_reminder', FDepsReminder); Arr := TJSONArray.Create; diff --git a/src/uTheme.pas b/src/uTheme.pas index f95f569..b2a2a23 100644 --- a/src/uTheme.pas +++ b/src/uTheme.pas @@ -69,7 +69,8 @@ type tiSnap, // прилипание: края притягиваются к общей линии tiRipple, // схлопывание: дыра между кусками закрывается tiLog, // журнал: полоса с сообщениями - tiGlue // курсор липнет к границам клипов + tiGlue, // курсор липнет к границам клипов + tiTarget // курсор держится середины монтажа ); { Полоска выполнения, которая рисует себя сама. @@ -453,6 +454,7 @@ begin tiRipple: Stream.WriteBuffer(IconRippleData[0], Length(IconRippleData)); tiLog: Stream.WriteBuffer(IconLogData[0], Length(IconLogData)); tiGlue: Stream.WriteBuffer(IconGlueData[0], Length(IconGlueData)); + tiTarget: Stream.WriteBuffer(IconTargetData[0], Length(IconTargetData)); else Exit; end; diff --git a/src/uTimelineCtrl.pas b/src/uTimelineCtrl.pas index c81f127..5179e58 100644 --- a/src/uTimelineCtrl.pas +++ b/src/uTimelineCtrl.pas @@ -38,7 +38,9 @@ type taPan, // прокрутка средней кнопкой taVolume, // тянем ручку громкости клипа taFadeIn, // тянем появление за верхний левый угол - taFadeOut // тянем уход за верхний правый угол + taFadeOut, // тянем уход за верхний правый угол + taRegionIn, // тянем начало отмеченного участка + taRegionOut // тянем конец отмеченного участка ); { Что находится под указателем в полосе заголовков дорожек. } @@ -121,6 +123,8 @@ type FOnPlayheadChange: TNotifyEvent; FOnStructureChange: TNotifyEvent; FOnSelectionChange: TNotifyEvent; + { Отмеченный участок подвинули мышью. } + FOnRegionChange: TNotifyEvent; FOnMediaDrop: TMediaDropEvent; FOnClipTransform: TClipEvent; FOnTrackAdded: TNotifyEvent; @@ -144,6 +148,10 @@ type procedure DrawRuler(ACanvas: TCanvas); procedure DrawRegion(ACanvas: TCanvas); + procedure DrawRegionHandle(ACanvas: TCanvas; AX: Integer; + AIsStart: Boolean); + function RegionHandleAt(AX, AY: Integer): Integer; + procedure DragRegionEdge(AX: Integer; AIsStart: Boolean); procedure DrawTracks(ACanvas: TCanvas); procedure DrawClip(ACanvas: TCanvas; AClip: TClip; ATop: Integer); procedure DrawPlayhead(ACanvas: TCanvas); @@ -284,6 +292,8 @@ type write FOnStructureChange; property OnSelectionChange: TNotifyEvent read FOnSelectionChange write FOnSelectionChange; + property OnRegionChange: TNotifyEvent read FOnRegionChange + write FOnRegionChange; property OnMediaDrop: TMediaDropEvent read FOnMediaDrop write FOnMediaDrop; { Нажата кнопка кадрирования в углу клипа. } property OnClipTransform: TClipEvent read FOnClipTransform @@ -302,6 +312,8 @@ const { На сколько пикселей примагничиваются края при перетаскивании. } SnapPixels = 8; + { Уголок отметки участка: сторона треугольника в точках. } + RegionHandleWidth = 9; { Сдвиг мыши, до которого нажатие считается щелчком, а не перетаскиванием. Совсем без запаса нельзя: рука дрожит, и попасть кнопкой в один и тот же @@ -354,6 +366,7 @@ const ColorPlayhead = TColor($4040FF); // красный: привычный цвет курсора ColorRegion = TColor($30A0D0); // отмеченный участок: янтарная полоса ColorRegionEdge = TColor($1878B0); + ColorRegionHandle = TColor($40E0F0); // уголки отметок: жёлтые, как в Vegas ColorEdgeSnap = TColor($909090); // серый: рез притянут к опорному кадру ColorEdgePrecise = TColor($FFC030); // голубой: рез потребует перекодирования ColorWave = TColor($96C8D2); // пастельно-жёлтый: форма звука @@ -740,6 +753,80 @@ begin ACanvas.Line(L, 0, L, ClientHeight); if TimeToX(FProject.RegionEnd) <= ClientWidth then ACanvas.Line(R - 1, 0, R - 1, ClientHeight); + + { Уголки, за которые тянут. Отметки должны не только показывать, где + участок, но и позволять его двигать: клавишами это выходит долго — + поставил, посмотрел, промахнулся, ставь заново. } + DrawRegionHandle(ACanvas, TimeToX(FProject.RegionStart), True); + DrawRegionHandle(ACanvas, TimeToX(FProject.RegionEnd), False); +end; + +{ Уголок отметки: прямоугольный треугольник, прижатый к своей стороне + участка. Так видно, какой край он держит, — у симметричной стрелки это + пришлось бы угадывать. } +procedure TTimelineControl.DrawRegionHandle(ACanvas: TCanvas; AX: Integer; + AIsStart: Boolean); +var + Pts: array[0..2] of TPoint; +begin + if (AX < FHeaderWidth - RegionHandleWidth) or + (AX > ClientWidth + RegionHandleWidth) then + Exit; + + if AIsStart then + begin + Pts[0] := Point(AX, 0); + Pts[1] := Point(AX + RegionHandleWidth, 0); + Pts[2] := Point(AX, RegionHandleWidth); + end + else + begin + Pts[0] := Point(AX, 0); + Pts[1] := Point(AX - RegionHandleWidth, 0); + Pts[2] := Point(AX, RegionHandleWidth); + end; + + ACanvas.Brush.Style := bsSolid; + ACanvas.Brush.Color := ColorRegionHandle; + ACanvas.Pen.Color := ColorRegionEdge; + ACanvas.Polygon(Pts); +end; + +{ Что под указателем на линейке: 1 — уголок начала, 2 — уголок конца. } +{ Двигает край участка за уголком. + + Край прилипает к границам клипов тем же способом, что и курсор: участок + чаще всего отмечают по кускам, а попасть мышью в стык на общем плане + нельзя. Края не пропускаются друг за друга: перевёрнутый участок — это + не участок. } +procedure TTimelineControl.DragRegionEdge(AX: Integer; AIsStart: Boolean); +var + T: Double; +begin + if FProject = nil then + Exit; + T := Max(0, SnapPlayhead(XToTime(AX))); + if AIsStart then + FProject.RegionStart := Min(T, FProject.RegionEnd - 0.001) + else + FProject.RegionEnd := Max(T, FProject.RegionStart + 0.001); + Invalidate; + if Assigned(FOnRegionChange) then + FOnRegionChange(Self); +end; + +function TTimelineControl.RegionHandleAt(AX, AY: Integer): Integer; +begin + Result := 0; + if (FProject = nil) or (not FProject.HasRegion) then + Exit; + if (AY < 0) or (AY > RegionHandleWidth + 2) then + Exit; + + if Abs(AX - TimeToX(FProject.RegionStart)) <= RegionHandleWidth then + Exit(1); + if Abs(AX - TimeToX(FProject.RegionEnd)) <= RegionHandleWidth then + Exit(2); end; procedure TTimelineControl.DrawClip(ACanvas: TCanvas; AClip: TClip; @@ -1684,9 +1771,16 @@ begin Exit; end; - { Щелчок по линейке ставит курсор воспроизведения. } + { Щелчок по линейке: сперва уголки отмеченного участка, потом курсор. + + Приоритет у уголков: они мелкие, промахнуться мимо них легко, а + поставить курсор можно где угодно рядом. } if Y < FRulerHeight then begin + case RegionHandleAt(X, Y) of + 1: begin FAction := taRegionIn; Exit; end; + 2: begin FAction := taRegionOut; Exit; end; + end; FAction := taScrub; Playhead := Max(0, SnapPlayhead(XToTime(X))); Exit; @@ -1869,6 +1963,12 @@ begin на стык, соскакивал бы с него от малейшего движения мыши. } Playhead := Max(0, SnapPlayhead(XToTime(X))); + taRegionIn: + DragRegionEdge(X, True); + + taRegionOut: + DragRegionEdge(X, False); + taVolume: if FDragClip <> nil then begin