Уголки участка, курсор посередине и журнал сборки рядом с роликом

Отметки участка стали видимыми уголками на линейке — жёлтые треугольники
по краям, за них можно тянуть. Клавишами это выходило долго: поставил,
посмотрел, промахнулся, ставь заново. Край прилипает к границам клипов,
как и курсор, и края не пропускаются друг за друга.

Клавиша \ и кнопка с целью переключают, держать ли курсор посередине
монтажа при воспроизведении. Раньше монтаж ехал под курсором всегда, а
это мешает, когда разглядываешь конкретное место.

В окне сборки вместо надписи про участок появилась галка «только
отмеченный участок». Надпись легко проглядеть, а вопрос из тех, где
ошибка стоит часа работы: соберёшь кусок вместо ролика или наоборот.
Снятая галка собирает весь монтаж, отметки при этом остаются.

Журнал сборки пишется рядом с готовым файлом и под тем же именем. Общий
журнал программы перезаписывается следующим запуском, а разбираться,
почему в ролике не то, приходится потом. В журнале: время, проект,
участок, кадр и битрейт, что с текстом, и полный список кусков — с
пометками, где слои, где текст и что пересобирается. Строка пишется
сразу, чтобы после падения осталась последняя.
This commit is contained in:
4aiman 2026-08-31 11:00:30 +03:00
commit 96672341d2
8 changed files with 434 additions and 61 deletions

View file

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

View file

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

View file

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

View file

@ -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 = 'Готов.'

View file

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

View file

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

View file

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

View file

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