Выделение по времени, рез по краям промежутка, курсор идёт за резом

Режимов выделения стало два, переключаются клавишей D и кнопкой внизу,
режим показан её значком.

Рамкой по клипам — как было. По времени — протяжка отмечает промежуток по
всем дорожкам сразу, он остаётся виден пунктиром, и S режет все дорожки по
обоим его краям. Выделенной после этого остаётся ровно середина: следующим
действием её обычно удаляют, а выделение на старых клипах убрало бы не то.

Курсор теперь идёт за резом: после S встаёт на место реза (в режиме по
времени — на начало промежутка), после удаления куска — на место стыка,
после схлопывания дыры — туда же. Раньше в это место приходилось попадать
мышью заново.

Рез кладётся на кадровую сетку: между кадрами времени нет, и рез на доле
кадра оставлял бы в монтаже время, которого не бывает.

Выделение рамкой требует настоящего перекрытия, а не касания краями:
сосед, кончающийся ровно на краю рамки, попадал в выделение из-за разницы
в последнем знаке дробей.

Кнопка журнала и строка состояния сдвинуты вправо: переключатель выделения
встал в один ряд с прилипанием и схлопыванием.

Значки — icons8, набор ios-glyphs, авторство дописано в attribution.txt.
Набора «ios26» на их раздаче нет (404), взят ближайший по виду к нынешним.

Проверки: рез по курсору и по краям промежутка, кадровая сетка, выделение
середины, курсор после удаления и после схлопывания.
This commit is contained in:
4aiman 2026-09-29 23:58:30 +03:00
commit 6617e29514
11 changed files with 538 additions and 31 deletions

View file

@ -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` он встаёт ровно на место реза (в
режиме по времени — на начало промежутка), после удаления куска и
схлопывания — на место стыка. Дальше с этим местом и работают: следующий
рез, схлопывание дыры, просмотр перехода. Раньше курсор оставался там, где
был, и в место реза приходилось попадать мышью заново.
## Починка проекта
Меню «Проект» → **Проверить и починить проект...**. Показывает, что нашла,

View file

@ -3,3 +3,5 @@ Ripple gaps: <a target="_blank" href="https://icons8.com/icon/2EQegp8tbR6Q/merge
Magnet: <a target="_blank" href="https://icons8.com/icon/79942/magnet">Magnet</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
Playhead snapping: <a target="_blank" href="https://icons8.com/icon/111296/glue">Клей</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
App icon: <a target="_blank" href="https://icons8.com/icon/69093/showing-video-frames">Showing Video Frames</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
Selection mode, clips: <a target="_blank" href="https://icons8.com/icon/set/select-all/ios-glyphs">Select All</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>
Selection mode, time range: <a target="_blank" href="https://icons8.com/icon/set/width/ios-glyphs">Width</a> icon by <a target="_blank" href="https://icons8.com">Icons8</a>

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 656 B

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

View file

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

View file

@ -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;
{ Держать ли курсор посередине при воспроизведении.
Включено — монтаж едет под курсором, и всегда видно, что будет дальше.

View file

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

View file

@ -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));
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
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)
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
{ К началу самого раннего клипа, а не просто в ноль: если монтаж

View file

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

View file

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