diff --git a/README.md b/README.md index 197f009..aafac5d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ xdg-mime query default application/x-nlve-project | Удалить выделенное | Delete | | Выделить всё | Ctrl+A | | Выделить областью | протяжка по пустому месту дорожек | +| Режим выделения: рамкой или по времени | D | | Схлопнуть дыру под курсором | Ctrl+Shift+R | | Отменить, повторить | Ctrl+Z, Ctrl+Y | | Масштаб таймлайна | Ctrl + колесо мыши | @@ -272,6 +273,32 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро удаляет: она лежит, пока проект не сохранят заново. После сохранения копия не нужна и убирается. +## Два режима выделения + +Кнопка внизу (справа от схлопывания) и клавиша `D`. Режим показан +значком: рамка — выделение клипов, промежуток со стрелкой — выделение по +времени. + +**Рамкой по клипам.** Протяжка по пустому месту дорожек выделяет всё, что +рамка задела. Обычное выделение: выделил, нажал Delete. + +**По времени.** Протяжка отмечает промежуток от и до — по всем дорожкам +сразу, сколько бы их ни было. Промежуток виден пунктиром и после того, +как кнопку отпустили: по нему режет `S`. + +Зачем второй режим: вырезать из фильма кусок целиком. Отметил промежуток, +нажал `S` — все дорожки разрезаны в двух местах, по краям промежутка, и +выделенной осталась ровно середина. Дальше Delete, и при включённом +автосхлопывании дыра закрывается сама. + +Края промежутка кладутся на кадровую сетку: между кадрами времени нет. + +**Курсор идёт за резом.** После `S` он встаёт ровно на место реза (в +режиме по времени — на начало промежутка), после удаления куска и +схлопывания — на место стыка. Дальше с этим местом и работают: следующий +рез, схлопывание дыры, просмотр перехода. Раньше курсор оставался там, где +был, и в место реза приходилось попадать мышью заново. + ## Починка проекта Меню «Проект» → **Проверить и починить проект...**. Показывает, что нашла, diff --git a/icons_online/attribution.txt b/icons_online/attribution.txt index 0f3b577..1586d15 100644 --- a/icons_online/attribution.txt +++ b/icons_online/attribution.txt @@ -2,4 +2,6 @@ Log panel: Prop Ripple gaps: Merge Vertical icon by Icons8 Magnet: Magnet icon by Icons8 Playhead snapping: Клей icon by Icons8 -App icon: Showing Video Frames icon by Icons8 \ No newline at end of file +App icon: Showing Video Frames icon by Icons8 +Selection mode, clips: Select All icon by Icons8 +Selection mode, time range: Width icon by Icons8 diff --git a/icons_online/icons8-select-all-90.png b/icons_online/icons8-select-all-90.png new file mode 100644 index 0000000..0bfdc90 Binary files /dev/null and b/icons_online/icons8-select-all-90.png differ diff --git a/icons_online/icons8-width-90.png b/icons_online/icons8-width-90.png new file mode 100644 index 0000000..80a187b Binary files /dev/null and b/icons_online/icons8-width-90.png differ diff --git a/src/uIconData.pas b/src/uIconData.pas index 2487924..34a8ee4 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, $1F, $07, $2E, $1B, $C7, - $2C, $48, $09, $00, $00, $01, $3E, $49, $44, $41, $54, $38, $CB, $BD, $92, $31, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27, + $B3, $C9, $F8, $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, $1F, $07, $2E, $1B, $C7, - $2C, $48, $09, $00, $00, $01, $2F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $3D, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27, + $B3, $C9, $F8, $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, $1F, $07, $2E, $1B, $C7, - $2C, $48, $09, $00, $00, $01, $0F, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BF, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27, + $B3, $C9, $F8, $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, $1F, $07, $2E, $1B, $C7, - $2C, $48, $09, $00, $00, $01, $67, $49, $44, $41, $54, $38, $CB, $B5, $D3, $3D, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27, + $B3, $C9, $F8, $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, @@ -167,8 +167,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, $1F, $07, $2E, $1B, $C7, - $2C, $48, $09, $00, $00, $02, $71, $49, $44, $41, $54, $38, $CB, $95, $D4, $41, + $00, $00, $00, $07, $74, $49, $4D, $45, $07, $EA, $09, $1D, $14, $21, $19, $27, + $B3, $C9, $F8, $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, @@ -211,6 +211,76 @@ const $00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82 ); + { icons8-select-all-90.png, 24 на 24 точек } + IconSelectClipsData: array[0..509] 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, $09, $1D, $14, $21, $19, $27, + $B3, $C9, $F8, $00, $00, $01, $63, $49, $44, $41, $54, $38, $CB, $B5, $D3, $BD, + $6E, $13, $41, $14, $05, $E0, $6F, $76, $13, $7B, $17, $21, $A4, $98, $48, $34, + $3C, $00, $05, $0A, $52, $78, $05, $AA, $50, $24, $8F, $C1, $1B, $E4, $1D, $D2, + $51, $41, $49, $43, $EB, $CE, $A9, $90, $A5, $28, $D6, $26, $15, $55, $8A, $3C, + $00, $42, $32, $08, $2A, $62, $25, $CE, $7A, $87, $C2, $E3, $3F, $61, $0A, $17, + $39, $53, $CC, $E8, $9E, $39, $33, $F7, $DC, $99, $CB, $43, $23, $A4, $79, $CB, + $36, $EE, $4D, $B4, $05, $DC, $A1, $8D, $E8, $4E, $6E, $1B, $63, $93, $85, $2C, + $77, $EC, $5C, $E5, $C8, $AE, $AE, $4A, $DF, $9E, $3D, $7D, $95, $AE, $5D, $47, + $2A, $E7, $8E, $E5, $90, $81, $96, $03, $FB, $32, $71, $6D, $16, $51, $66, $DF, + $81, $D6, $22, $54, $1A, $B8, $B0, $23, $17, $14, $4A, $A5, $4C, $A6, $54, $2A, + $04, $B9, $1D, $17, $06, $CA, $69, $32, $53, $FC, $34, $70, $65, $82, $5A, $AD, + $16, $C5, $B4, $22, $AA, $FD, $50, $B9, $D6, $CC, $4C, $07, $4F, $F1, $4B, $14, + $74, $3C, $9E, $A7, $16, $FC, $F1, $5B, $5C, $E2, $13, $0A, $5D, $5D, $05, $3A, + $4E, $0D, $7D, $4F, $63, $E8, $54, $67, $85, $B7, $95, $4E, $7A, $96, $4A, $7C, + $EB, $8B, $6F, $9A, $14, $0D, $AE, $DD, $AE, $F0, $49, $30, $AD, $3B, $8C, $BC, + $9F, $C7, $EE, $93, $70, $99, $4F, $1E, $32, $2F, $71, $A5, $F1, $C4, $89, $57, + $1A, $44, $1F, $7C, $4E, $BB, $16, $7C, $7A, $87, $8D, $51, $EA, $EB, $4F, $EB, + $2C, $D7, $4E, $23, $5B, $C7, $CF, $F2, $6D, $A7, $F9, $91, $77, $5E, $AC, $98, + $FE, $68, $B4, $C4, $27, $41, $34, $44, $44, $E1, $8D, $D7, $F3, $6F, $96, $7B, + $EE, $93, $D1, $12, $BF, $F9, $C3, $85, $74, $CF, $5B, $F4, $D4, $FF, $F1, $F8, + $0F, $BF, $C1, $E7, $9B, $09, $CE, $DC, $B8, $74, $B8, $B6, $1F, $0E, $5D, $BA, + $71, $B6, $5C, $A5, $B1, $9E, $4C, $3E, $EF, $BF, $55, $04, $8D, $AF, $7A, $C6, + $0B, $D3, $E4, $5A, $36, $68, $D1, $87, $C4, $5F, $C8, $81, $83, $A2, $85, $86, + $DA, $DD, $00, $00, $00, $00, $49, $45, $4E, $44, $AE, $42, $60, $82 + ); + + { icons8-width-90.png, 24 на 24 точек } + IconSelectRangeData: array[0..473] 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, $09, $1D, $14, $21, $19, $27, + $B3, $C9, $F8, $00, $00, $01, $3F, $49, $44, $41, $54, $38, $CB, $CD, $D2, $BF, + $4A, $1C, $51, $18, $05, $F0, $DF, $8C, $3B, $8E, $53, $64, $70, $94, $2D, $5D, + $B0, $90, $B0, $C5, $DA, $A6, $14, $C1, $28, $E8, $1B, $F8, $0C, $61, $FB, $7D, + $02, $5F, $40, $D8, $07, $91, $6D, $92, $52, $2D, $6C, $BD, $45, $08, $29, $84, + $ED, $C4, $C2, $90, $26, $8C, $6E, $66, $53, $B8, $7F, $26, $82, $85, $0A, $E2, + $B9, $C5, $2D, $CE, $3D, $DF, $F7, $9D, $F3, $5D, $DE, $1D, $16, $26, $77, $2E, + $55, $CA, $7C, $50, $4A, $14, $4A, $91, $15, $23, $63, $85, $C8, $68, $C2, $CF, + $D0, $D0, $D7, $17, $EB, $3A, $91, $DB, $73, $66, $C3, $86, $33, $7B, $72, $27, + $BA, $62, $7D, $7D, $8D, $87, $A7, $90, $D8, $44, $22, $A0, $34, $34, $F0, $0B, + $03, $43, $A5, $6F, $C2, $8C, $1F, $4D, $3B, $64, $CE, $9D, $CB, $9E, $1C, $BC, + $C6, $C7, $CF, $35, $FD, $2A, $C1, $58, $C7, $BE, $C5, $FF, $F8, $45, $FB, $3A, + $C6, $F5, $7C, $A6, $A8, $7C, $72, $AC, $12, $DC, $88, $66, $25, $9A, $8E, $C4, + $BE, $A8, $A6, $A5, $A3, $89, $A9, $AF, $D6, $FD, $D6, $76, $EB, $87, $BF, $35, + $C1, $82, $8F, $0A, $DF, $E5, $AE, $EC, $FA, $F3, $02, $0F, $F3, $D8, $4E, $6D, + $09, $2E, $AD, $59, $92, $4D, $CE, $92, $35, $97, $82, $2D, $A7, $D3, $58, $E7, + $1E, $62, $17, $0E, $B5, $5C, $BB, $AB, $95, $BA, $D6, $33, $F4, $73, $3E, $49, + $A3, $46, $46, $82, $F0, $A8, $F7, $9D, $01, $F5, $95, $BE, $ED, $E2, $2A, $DB, + $BA, $52, $6D, $3D, $4D, $4D, $3D, $6D, $A9, $AE, $6D, $D5, $63, $C1, $48, $10, + $DC, $EB, $D8, $91, $6A, $39, $B0, $6C, $D9, $81, $96, $D4, $67, $1D, $F7, $82, + $30, $FF, $AB, $90, $CB, $91, $29, $44, $12, $AB, $62, $B1, $55, $89, $48, $21, + $9B, $F1, $EF, $13, $FF, $00, $8F, $00, $4E, $D0, $9E, $28, $A1, $C0, $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 f9902ae..578948e 100644 --- a/src/uMainForm.lfm +++ b/src/uMainForm.lfm @@ -97,11 +97,21 @@ object MainForm: TMainForm OnClick = btnRippleMenuClick TabStop = False end + object btnSelectMode: TThemedButton + Left = 448 + Height = 28 + Top = 6 + Width = 28 + Icon = tiSelectClips + OnClick = btnSelectModeClick + ShowHint = True + TabStop = False + end object lblStatus: TLabel - Left = 488 + Left = 518 Height = 20 Top = 10 - Width = 780 + Width = 750 Anchors = [akTop, akLeft, akRight] AutoSize = False Caption = 'Готов.' diff --git a/src/uMainForm.pas b/src/uMainForm.pas index 7e6856c..06a6ab7 100644 --- a/src/uMainForm.pas +++ b/src/uMainForm.pas @@ -51,6 +51,7 @@ type btnCenter: TThemedButton; btnRipple: TThemedButton; btnRippleMenu: TThemedButton; + btnSelectMode: TThemedButton; lblPoolInfo: TLabel; lblStatus: TLabel; lblTime: TLabel; @@ -72,6 +73,7 @@ type procedure btnCenterClick(Sender: TObject); procedure btnRippleClick(Sender: TObject); procedure btnRippleMenuClick(Sender: TObject); + procedure btnSelectModeClick(Sender: TObject); procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean); procedure FormCreate(Sender: TObject); procedure FormDestroy(Sender: TObject); @@ -156,6 +158,7 @@ type { Пункт «Ключ и прозрачность в предпросмотре»: галку в нём приходится держать за руку — она не следует за настройкой сама. } FKeyPreviewItem: TMenuItem; + FSelectModeItem: TMenuItem; { Показанная доля выполнения. Отдельно от настоящей, потому что растёт она по своим правилам — см. AdvanceJobBar. } FJobShown: Double; @@ -240,6 +243,8 @@ type procedure TrackMenuDown(Sender: TObject); procedure UpdateSnapButton; procedure UpdateGlueButton; + procedure UpdateSelectModeButton; + procedure TimelineSelectModeChange(Sender: TObject); procedure UpdateCenterButton; procedure ToggleCenterPlayhead; procedure RippleModeChosen(Sender: TObject); @@ -372,6 +377,7 @@ type { Что и откуда собирать: с учётом отмеченного участка. } procedure DescribeRegion(out AFrom, ATo: Double); procedure MenuSelectAll(Sender: TObject); + procedure MenuSelectMode(Sender: TObject); procedure MenuRipple(Sender: TObject); procedure MenuToggleRipple(Sender: TObject); procedure MenuGoStart(Sender: TObject); @@ -525,6 +531,7 @@ begin UpdateRippleButton; UpdateSnapButton; UpdateGlueButton; + UpdateSelectModeButton; UpdateCenterButton; UpdateCaption; SetStatus('Готов. Добавьте файлы в проект.'); @@ -662,6 +669,7 @@ begin FTimeline.OnTrackMenu := @TimelineTrackMenu; FTimeline.OnTrackRename := @TimelineTrackRename; FTimeline.OnRegionChange := @TimelineRegionChange; + FTimeline.OnSelectionModeChange := @TimelineSelectModeChange; FTimeline.OnActiveTrackChange := @TimelineActiveTrackChange; end; @@ -768,6 +776,10 @@ begin AddItem(MEdit, 'Разрезать по курсору', @MenuSplit, ShortCut(VK_S, [])); AddItem(MEdit, 'Удалить выделенное', @MenuDelete, ShortCut(VK_DELETE, [])); AddItem(MEdit, 'Выделить всё', @MenuSelectAll, ShortCut(VK_A, [ssCtrl])); + { Пункт держит и подпись режима: по одному значку на кнопке не угадать, + что сейчас делает протяжка мышью. } + FSelectModeItem := AddItem(MEdit, 'Режим выделения', @MenuSelectMode, + ShortCut(VK_D, [])); AddItem(MEdit, 'Цветовой ключ клипа...', @MenuColorKey); AddSep(MEdit); AddItem(MEdit, 'Копировать клипы', @MenuCopyClips, ShortCut(VK_C, [ssCtrl])); @@ -900,7 +912,9 @@ procedure TMainForm.BuildJobIndicator; const JobWidth = 330; LogButtonWidth = 28; - LogButtonLeft = 448; + { Журнал стоит после переключателя выделения: сперва то, что меняет + работу с монтажом, потом то, что показывает сообщения. } + LogButtonLeft = 478; begin FJobLabel := TLabel.Create(Self); FJobLabel.Parent := pnlBottom; @@ -2575,6 +2589,60 @@ begin UpdateGlueButton; end; +{ Кнопка режима выделения. + + Режима два, и кнопка показывает не «включено-выключено», а какой сейчас: + значок рамки — выделение клипов, значок промежутка — выделение по + времени. Поэтому она не утапливается, а меняет картинку. } +procedure TMainForm.UpdateSelectModeButton; +begin + if FSelectModeItem <> nil then + if FTimeline.SelectionMode = smRange then + FSelectModeItem.Caption := 'Режим выделения: по времени (сейчас)' + else + FSelectModeItem.Caption := 'Режим выделения: рамкой по клипам (сейчас)'; + + if FTimeline.SelectionMode = smRange then + begin + btnSelectMode.Icon := tiSelectRange; + btnSelectMode.Hint := 'Выделение по времени: протяжка отмечает ' + + 'промежуток по всем дорожкам.' + LineEnding + + 'S режет все дорожки по обоим краям промежутка.' + LineEnding + + 'Переключить (D) на выделение клипов рамкой.'; + end + else + begin + btnSelectMode.Icon := tiSelectClips; + btnSelectMode.Hint := 'Выделение клипов: рамка выделяет всё, что ' + + 'задела.' + LineEnding + + 'S режет по курсору.' + LineEnding + + 'Переключить (D) на выделение промежутка по времени.'; + end; +end; + +procedure TMainForm.btnSelectModeClick(Sender: TObject); +begin + FTimeline.ToggleSelectionMode; +end; + +procedure TMainForm.MenuSelectMode(Sender: TObject); +begin + FTimeline.ToggleSelectionMode; +end; + +{ Режим сменили — сказать об этом словами: по одному значку не угадать, + что теперь делает протяжка мышью. } +procedure TMainForm.TimelineSelectModeChange(Sender: TObject); +begin + UpdateSelectModeButton; + if FTimeline.SelectionMode = smRange then + SetStatus('Выделение по времени: протяжка отмечает промежуток, ' + + 'S режет по его краям.', True) + else + SetStatus('Выделение клипов: рамка выделяет всё, что задела, ' + + 'S режет по курсору.', True); +end; + { Держать ли курсор посередине при воспроизведении. Включено — монтаж едет под курсором, и всегда видно, что будет дальше. diff --git a/src/uTheme.pas b/src/uTheme.pas index 08c9e0e..f531cd9 100644 --- a/src/uTheme.pas +++ b/src/uTheme.pas @@ -70,7 +70,9 @@ type tiRipple, // схлопывание: дыра между кусками закрывается tiLog, // журнал: полоса с сообщениями tiGlue, // курсор липнет к границам клипов - tiTarget // курсор держится середины монтажа + tiTarget, // курсор держится середины монтажа + tiSelectClips, // выделение рамкой: что задело, то и выделено + tiSelectRange // выделение по времени: от и до, по всем дорожкам ); { Полоска выполнения, которая рисует себя сама. @@ -460,6 +462,10 @@ begin tiLog: Stream.WriteBuffer(IconLogData[0], Length(IconLogData)); tiGlue: Stream.WriteBuffer(IconGlueData[0], Length(IconGlueData)); tiTarget: Stream.WriteBuffer(IconTargetData[0], Length(IconTargetData)); + tiSelectClips: Stream.WriteBuffer(IconSelectClipsData[0], + Length(IconSelectClipsData)); + tiSelectRange: Stream.WriteBuffer(IconSelectRangeData[0], + Length(IconSelectRangeData)); else Exit; end; diff --git a/src/uTimelineCtrl.pas b/src/uTimelineCtrl.pas index dd53de1..9f51c17 100644 --- a/src/uTimelineCtrl.pas +++ b/src/uTimelineCtrl.pas @@ -45,6 +45,16 @@ type taRubberBand // обтягиваем рамкой клипы по пустому месту ); + { Как понимать протяжку мышью по пустому месту. + + smClips — что рамка задела, то и выделено: привычное выделение + прямоугольником. + + smRange — отмечается промежуток времени по всем дорожкам сразу, от и + до. Нужен, чтобы вырезать кусок фильма целиком: отметил промежуток, + нажал S — и все дорожки разрезаны в двух местах, по краям промежутка. } + TSelectionMode = (smClips, smRange); + { Что находится под указателем в полосе заголовков дорожек. } THeaderPart = (hpNone, hpBody, hpMute, hpSolo, hpUp, hpDown, hpName); @@ -139,6 +149,14 @@ type FBandToTime: Double; FBandFromY: Integer; FBandToY: Integer; + { Режим выделения и отмеченный им промежуток времени. + + Промежуток живёт до следующего выделения или щелчка: по нему режет + клавиша S, и человек должен видеть, где именно это произойдёт. } + FSelectionMode: TSelectionMode; + FRangeFrom: Double; + FRangeTo: Double; + FOnSelectionModeChange: TNotifyEvent; FGroupClips: array of TClip; FGroupOrigStarts: array of Double; @@ -251,6 +269,9 @@ type const ATime, ATolerance: Double; out AFrom, ATo: Double): Boolean; { Допуск прилипания в секундах: те же экранные точки, что у краёв. } function SnapTolerance: Double; + procedure SetSelectionMode(AValue: TSelectionMode); + { Рез в указанном месте. Возвращает, сколько клипов разрезано. } + function SplitAt(const ATime: Double; APrecise: Boolean): Integer; protected procedure Paint; override; procedure DragOver(Source: TObject; X, Y: Integer; State: TDragState; @@ -332,6 +353,22 @@ type procedure SelectInRange(const AFrom, ATo: Double; ATrackFrom, ATrackTo: Integer; AAdd: Boolean); + { Режим выделения: рамкой по клипам или промежутком времени по всем + дорожкам. Переключается клавишей D и кнопкой внизу. } + property SelectionMode: TSelectionMode read FSelectionMode + write SetSelectionMode; + procedure ToggleSelectionMode; + { Отмечен ли промежуток времени и какой. } + function HasSelectionRange: Boolean; + property RangeFrom: Double read FRangeFrom; + property RangeTo: Double read FRangeTo; + { Отметить промежуток. Мышь зовёт это же, и проверки тоже: так + поведение реза проверяется числами, без беготни указателем. } + procedure SetSelectionRange(const AFrom, ATo: Double); + procedure ClearSelectionRange; + property OnSelectionModeChange: TNotifyEvent read FOnSelectionModeChange + write FOnSelectionModeChange; + { Номер дорожки под указателем мыши, минус один если ни одной. } property HoverTrack: Integer read FHoverTrack; { Дорожка, выбранная щелчком по заголовку. } @@ -1381,14 +1418,45 @@ end; procedure TTimelineControl.DrawRubberBand(ACanvas: TCanvas); var L, R, T, B: Integer; + Pulling: Boolean; begin - if (FAction <> taRubberBand) or (not FMovedWhileDown) then + { Имя не Dragging: так называется метод самого элемента управления. } + Pulling := (FAction = taRubberBand) and FMovedWhileDown; + + { Отмеченный промежуток виден и после протяжки: по нему режет клавиша S, + и человек должен знать, где это произойдёт. } + if (not Pulling) and HasSelectionRange then + begin + L := Max(TimeToX(FRangeFrom), FHeaderWidth); + R := Min(TimeToX(FRangeTo), ClientWidth); + if R <= L then + Exit; + ACanvas.Pen.Color := ColorBand; + ACanvas.Pen.Style := psDot; + ACanvas.Brush.Style := bsClear; + ACanvas.Rectangle(L, FRulerHeight, R, ClientHeight); + ACanvas.Pen.Style := psSolid; + Exit; + end; + + if not Pulling then Exit; L := Min(TimeToX(FBandFromTime), TimeToX(FBandToTime)); R := Max(TimeToX(FBandFromTime), TimeToX(FBandToTime)); - T := Min(FBandFromY, FBandToY) - FScrollY; - B := Max(FBandFromY, FBandToY) - FScrollY; + + if FSelectionMode = smRange then + begin + { По времени отмечают промежуток, а не прямоугольник: рамка идёт во + всю высоту дорожек, сколько бы их ни было. } + T := FRulerHeight; + B := ClientHeight; + end + else + begin + T := Min(FBandFromY, FBandToY) - FScrollY; + B := Max(FBandFromY, FBandToY) - FScrollY; + end; L := Max(L, FHeaderWidth); R := Min(R, ClientWidth); @@ -2618,10 +2686,22 @@ begin begin FAction := taNone; if FMovedWhileDown then - SelectInRange(FBandFromTime, FBandToTime, - (FBandFromY - FRulerHeight) div FTrackHeight, - (FBandToY - FRulerHeight) div FTrackHeight, - ssCtrl in Shift) + begin + if FSelectionMode = smRange then + begin + { Промежуток времени: по всем дорожкам, от и до. Края кладутся на + кадровую сетку — по ним потом режет клавиша S. } + SetSelectionRange(FBandFromTime, FBandToTime); + if (FProject <> nil) and (FRangeTo > FRangeFrom) then + SelectInRange(FRangeFrom, FRangeTo, 0, FProject.TrackCount - 1, + ssCtrl in Shift); + end + else + SelectInRange(FBandFromTime, FBandToTime, + (FBandFromY - FRulerHeight) div FTrackHeight, + (FBandToY - FRulerHeight) div FTrackHeight, + ssCtrl in Shift); + end else begin if (FProject <> nil) and not (ssCtrl in Shift) then @@ -2630,6 +2710,8 @@ begin if Assigned(FOnSelectionChange) then FOnSelectionChange(Self); end; + { Щелчок снимает и отметку промежутка: она про прошлое выделение. } + ClearSelectionRange; Playhead := Max(0, SnapPlayhead(XToTime(X))); end; FMovedWhileDown := False; @@ -2720,12 +2802,18 @@ end; { --- Правки --- } -function TTimelineControl.SplitAtPlayhead(APrecise: Boolean): Integer; +{ Рез в указанном месте. + + Место кладётся на кадровую сетку: между кадрами времени нет, и рез на + доле кадра оставил бы в монтаже время, которого не бывает. Сдвиг не + больше полкадра. } +function TTimelineControl.SplitAt(const ATime: Double; + APrecise: Boolean): Integer; var T: Integer; Track: TTrack; Clip, Tail: TClip; - Offset: Double; + Offset, At: Double; { Старый номер связки — новый. Одна связка при резе становится двумя: голова со своей парой и хвост со своей. } NewGroups: TStringList; @@ -2735,6 +2823,7 @@ begin if FProject = nil then Exit; + At := QuantizeTime(ATime); FProject.BeginChange; NewGroups := TStringList.Create; try @@ -2745,11 +2834,11 @@ begin for T := 0 to FProject.TrackCount - 1 do begin Track := FProject.Tracks[T]; - Clip := Track.ClipAt(FPlayhead); + Clip := Track.ClipAt(At); if Clip = nil then Continue; - Offset := FPlayhead - Clip.TlStart; + Offset := At - Clip.TlStart; { Рез вплотную к краю ничего не даёт, только плодит клипы нулевой длины. } if (Offset < 0.001) or (Offset > Clip.Duration - 0.001) then Continue; @@ -2758,7 +2847,7 @@ begin Tail.Id := Clip.Id + '_b'; Tail.MediaId := Clip.MediaId; Tail.SrcIn := Clip.SrcIn + Offset; - Tail.TlStart := FPlayhead; + Tail.TlStart := At; Tail.Duration := Clip.Duration - Offset; Tail.EdgeOut := Clip.EdgeOut; { Хвост — тот же клип, только вторая его половина. Всё настроенное @@ -2819,6 +2908,90 @@ begin FProject.Undo; end; +{ Режим выделения: рамкой по клипам или промежутком по времени. + + Смена режима снимает отмеченный промежуток: он принадлежит режиму «по + времени», и оставлять его в другом режиме значило бы резать по невидимой + метке. } +procedure TTimelineControl.SetSelectionMode(AValue: TSelectionMode); +begin + if FSelectionMode = AValue then + Exit; + FSelectionMode := AValue; + ClearSelectionRange; + Invalidate; + if Assigned(FOnSelectionModeChange) then + FOnSelectionModeChange(Self); +end; + +procedure TTimelineControl.ToggleSelectionMode; +begin + if FSelectionMode = smClips then + SelectionMode := smRange + else + SelectionMode := smClips; +end; + +function TTimelineControl.HasSelectionRange: Boolean; +begin + Result := (FSelectionMode = smRange) and (FRangeTo - FRangeFrom > 0.0005); +end; + +procedure TTimelineControl.SetSelectionRange(const AFrom, ATo: Double); +begin + FRangeFrom := Max(0, QuantizeTime(Min(AFrom, ATo))); + FRangeTo := Max(0, QuantizeTime(Max(AFrom, ATo))); + Invalidate; +end; + +procedure TTimelineControl.ClearSelectionRange; +begin + if (FRangeFrom = 0) and (FRangeTo = 0) then + Exit; + FRangeFrom := 0; + FRangeTo := 0; + Invalidate; +end; + +{ Рез по команде человека. + + Обычно — по курсору. Если отмечен промежуток времени (режим выделения + «по времени»), режем по обоим его краям: это и есть «вырезать кусок + фильма» — два реза, между ними то, что уйдёт. + + Курсор после реза встаёт ровно на место реза: дальше с этим местом и + работают — удаляют кусок, схлопывают дыру, ставят следующий рез. } +function TTimelineControl.SplitAtPlayhead(APrecise: Boolean): Integer; +var + At: Double; +begin + Result := 0; + if FProject = nil then + Exit; + + if HasSelectionRange then + begin + { Сперва дальний край: рез не двигает то, что левее, но порядок + всё равно понятнее от конца к началу. } + Result := SplitAt(FRangeTo, APrecise); + Result := Result + SplitAt(FRangeFrom, APrecise); + Playhead := Max(0, QuantizeTime(FRangeFrom)); + + { После реза выделенной остаётся ровно середина — то, что между + краями. Иначе Delete убрал бы не её: выделение осталось бы на + клипах, какими они были до реза, а середина оказалась бы новой, + невыделенной половиной. } + if Result > 0 then + SelectInRange(FRangeFrom, FRangeTo, 0, FProject.TrackCount - 1, False); + Exit; + end; + + At := QuantizeTime(FPlayhead); + Result := SplitAt(At, APrecise); + if Result > 0 then + Playhead := Max(0, At); +end; + { Расширяет выделение на всю связку: клипы, связанные с уже выделенными, тоже считаются выделенными. Иначе удаление или перенос разорвали бы связку пополам. } @@ -2974,7 +3147,7 @@ var T, C: Integer; Track: TTrack; AnyChange: Boolean; - HoleEnd: Double; + HoleEnd, HoleStart: Double; Touched: TStringList; begin if FProject = nil then @@ -2986,6 +3159,7 @@ begin AnyChange := False; HoleEnd := 0; + HoleStart := -1; FProject.BeginChange; Touched := TStringList.Create; try @@ -2997,6 +3171,9 @@ begin begin { Правый край дыры: дальше него всё поедет влево. } HoleEnd := Max(HoleEnd, Track[C].TlEnd); + { Левый край — место будущего стыка: туда встанет курсор. } + if (HoleStart < 0) or (Track[C].TlStart < HoleStart) then + HoleStart := Track[C].TlStart; if Touched.IndexOf(IntToStr(T)) < 0 then Touched.Add(IntToStr(T)); Track.Remove(Track[C]); @@ -3014,7 +3191,17 @@ begin end; if AnyChange then - StructureChanged + begin + { Курсор — на место, где был вырезанный кусок: при схлопывании там + теперь стык, без схлопывания — начало дыры. С этим местом и работают + дальше, и искать его мышью заново незачем. } + if HoleStart >= 0 then + Playhead := Max(0, HoleStart); + { Отметка промежутка пережила бы удаление и резала бы потом по + пустому месту. } + ClearSelectionRange; + StructureChanged; + end else FProject.Undo; end; @@ -3095,6 +3282,9 @@ begin if AnyChange then begin + { Курсор — на стык: дыры больше нет, а работать дальше будут именно с + этим местом. } + Playhead := Max(0, GapStart); StructureChanged; Result := True; end @@ -3120,7 +3310,7 @@ procedure TTimelineControl.SelectInRange(const AFrom, ATo: Double; ATrackFrom, ATrackTo: Integer; AAdd: Boolean); var T, C, T1, T2: Integer; - From_, To_: Double; + From_, To_, Eps: Double; Clip: TClip; begin if FProject = nil then @@ -3129,6 +3319,14 @@ begin { Рамку тянут в любую сторону — приводим её к возрастающему виду. } From_ := Min(AFrom, ATo); To_ := Max(AFrom, ATo); + + { Нужно настоящее перекрытие, а не касание краями. Сосед, кончающийся + ровно там, где рамка начинается, в неё не попал — но сложение долей + даёт разницу в последнем знаке, и «больше» срабатывает. Порог — + полкадра: перекрытия короче кадра не бывает. } + Eps := 0.0005; + if (FProject.Master.FpsNum > 0) and (FProject.Master.FpsDen > 0) then + Eps := FProject.Master.FpsDen / FProject.Master.FpsNum / 2; T1 := Max(0, Min(ATrackFrom, ATrackTo)); T2 := Min(FProject.TrackCount - 1, Max(ATrackFrom, ATrackTo)); @@ -3139,7 +3337,7 @@ begin for C := 0 to FProject.Tracks[T].Count - 1 do begin Clip := FProject.Tracks[T][C]; - if (Clip.TlEnd > From_) and (Clip.TlStart < To_) then + if (Clip.TlEnd > From_ + Eps) and (Clip.TlStart < To_ - Eps) then Clip.Selected := True; end; @@ -3189,6 +3387,7 @@ begin SelectAll; Key := 0; end; + VK_HOME: begin { К началу самого раннего клипа, а не просто в ноль: если монтаж diff --git a/tests/lfmcheck/lfmcheck.lpr b/tests/lfmcheck/lfmcheck.lpr index aaa3fea..0765eef 100644 --- a/tests/lfmcheck/lfmcheck.lpr +++ b/tests/lfmcheck/lfmcheck.lpr @@ -631,6 +631,128 @@ begin end; end; +{ Рез и курсор: где режет S и куда после этого встаёт курсор. + + Раньше курсор оставался там, где был, а рез мог попасть на долю кадра. + Теперь рез кладётся на кадровую сетку, а курсор встаёт ровно на место + реза — с ним и работают дальше. } +procedure CheckSplitAndPlayhead; +var + Proj: TProject; + Info: TMediaInfo; + Media: TMediaItem; + Timeline: TTimelineControl; + Step: Double; + I, N: Integer; +begin + WriteLn; + WriteLn('Рез, промежуток и курсор'); + WriteLn; + + Info := TestInfo; + Info.FpsNum := 60000; + Info.FpsDen := 1001; + Step := Info.FpsDen / Info.FpsNum; + + Proj := TProject.Create; + Timeline := TTimelineControl.Create(nil); + try + Proj.Clear; + Proj.AddTrack('V1'); + Proj.AddTrack('A1'); + Media := Proj.AddMedia('/несуществующий/видео.mp4', Info); + Proj.Master := Info; + { По два клипа на каждой дорожке, встык: 0..10 и 10..20. } + Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 0); + Proj.AddClip(Proj.Tracks[0], Media, 20, 10, 10); + Proj.AddClip(Proj.Tracks[1], Media, 0, 10, 0); + Proj.AddClip(Proj.Tracks[1], Media, 20, 10, 10); + + Timeline.Project := Proj; + Timeline.PixelsPerSecond := 100; + + { Рез по курсору: место кладётся на кадр, курсор встаёт на него. } + Timeline.Playhead := 3.3177; + CheckTrue('разрезаны обе дорожки', Timeline.SplitAtPlayhead(False) = 2); + CheckTrue('курсор встал на место реза', + Abs(Timeline.Playhead - Round(3.3177 / Step) * Step) < 0.0001); + CheckTrue('курсор на кадровой сетке', + Abs(Timeline.Playhead / Step - Round(Timeline.Playhead / Step)) < 0.001); + N := 0; + for I := 0 to Proj.Tracks[0].Count - 1 do + if Abs(Proj.Tracks[0][I].TlStart - Timeline.Playhead) < 0.0001 then + Inc(N); + CheckTrue('рез пришёлся ровно туда, где курсор', N = 1); + + { Промежуток по времени: рез по обоим краям, курсор — на начало. } + Timeline.SelectionMode := smRange; + Timeline.SetSelectionRange(12.13, 16.77); + CheckTrue('промежуток отмечен', Timeline.HasSelectionRange); + CheckTrue('разрезано по два клипа на дорожке', + Timeline.SplitAtPlayhead(False) = 4); + CheckTrue('курсор — на начале промежутка', + Abs(Timeline.Playhead - Timeline.RangeFrom) < 0.0001); + + N := 0; + for I := 0 to Proj.Tracks[1].Count - 1 do + if (Abs(Proj.Tracks[1][I].TlStart - Timeline.RangeFrom) < 0.0001) or + (Abs(Proj.Tracks[1][I].TlStart - Timeline.RangeTo) < 0.0001) then + Inc(N); + CheckTrue('на звуковой дорожке резы по обоим краям', N = 2); + + { После реза выделена середина — то, что между краями: следующим + действием её обычно удаляют. } + N := 0; + for I := 0 to Proj.Tracks[0].Count - 1 do + if Proj.Tracks[0][I].Selected then + begin + Inc(N); + CheckTrue('выделенное лежит внутри промежутка', + (Proj.Tracks[0][I].TlStart >= Timeline.RangeFrom - 0.0001) and + (Proj.Tracks[0][I].TlEnd <= Timeline.RangeTo + 0.0001)); + end; + CheckTrue('на дорожке картинки выделена одна середина', N = 1); + + { Смена режима снимает промежуток: резать по невидимой метке нельзя. } + Timeline.SelectionMode := smClips; + CheckTrue('смена режима сняла промежуток', + not Timeline.HasSelectionRange); + + { Удаление со схлопыванием: курсор встаёт на место стыка. } + Proj.Clear; + Proj.AddTrack('V1'); + Media := Proj.AddMedia('/несуществующий/видео.mp4', Info); + Proj.Master := Info; + Proj.RippleEnabled := True; + Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0); + Proj.AddClip(Proj.Tracks[0], Media, 10, 5, 5); + Proj.AddClip(Proj.Tracks[0], Media, 20, 5, 10); + Timeline.Playhead := 0; + Proj.Tracks[0][1].Selected := True; + Timeline.DeleteSelected; + CheckTrue('после удаления клипов стало два', Proj.Tracks[0].Count = 2); + CheckTrue('курсор — на месте вырезанного куска', + Abs(Timeline.Playhead - 5) < 0.0001); + CheckTrue('хвост придвинут к стыку', + Abs(Proj.Tracks[0][1].TlStart - 5) < 0.0001); + + { Схлопывание дыры с клавиатуры: курсор тоже на стык. } + Proj.Clear; + Proj.AddTrack('V1'); + Media := Proj.AddMedia('/несуществующий/видео.mp4', Info); + Proj.Master := Info; + Proj.AddClip(Proj.Tracks[0], Media, 0, 5, 0); + Proj.AddClip(Proj.Tracks[0], Media, 20, 5, 12); + Timeline.ActiveTrack := 0; + Timeline.Playhead := 8; + CheckTrue('дыра схлопнута', Timeline.RippleCloseGap); + CheckTrue('курсор — на стыке', Abs(Timeline.Playhead - 5) < 0.0001); + finally + Timeline.Free; + Proj.Free; + end; +end; + begin WriteLn('Свойства из файлов форм'); WriteLn; @@ -663,6 +785,7 @@ begin CheckClipCopy; CheckRubberBand; CheckFrameGridRepair; + CheckSplitAndPlayhead; CheckSplitAllTracks; CheckGapTolerance; diff --git a/tools/make_icons.py b/tools/make_icons.py index 7ccf0a8..a26d691 100644 --- a/tools/make_icons.py +++ b/tools/make_icons.py @@ -31,6 +31,8 @@ ICONS = [ ('Log', 'icons8-property-30.png', 24), ('Glue', 'icons8-клей-30.png', 24), ('Target', 'icons8-цель-30.png', 24), + ('SelectClips', 'icons8-select-all-90.png', 24), + ('SelectRange', 'icons8-width-90.png', 24), ] # Значок самой программы: отдельно, потому что окну нужен формат ico —