Щелчок в месте наезда перебирает клипы

Наезд двух клипов на одной дорожке — заказ на переход, то есть обычное
дело. Нажатие же всегда попадало в верхний по списку, и нижний клип
нельзя было ни выделить, ни потащить: чтобы до него добраться,
приходилось сперва убирать верхний.

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

Строка состояния говорит, сколько клипов под указателем: перебор, о
котором не сказали, — тайное знание.

Отбор вынесен в TTrack.ClipsAt, числовая проверка — в lfmcheck.
tests/pickcycle.sh щёлкает дважды и тащит: на прежней сборке уезжает
верхний клип, а нижний едет за ним схлопыванием.
This commit is contained in:
4aiman 2026-10-16 15:43:01 +03:00
commit ffd105a8c0
6 changed files with 363 additions and 11 deletions

View file

@ -529,6 +529,12 @@ Linux потом искала по нему `.dll`.
не переход, а наложение — там по-прежнему побеждает верхний, если он
непрозрачен.
В месте наезда под указателем два клипа. Первый щелчок берёт верхний,
повторный по тому же месту переводит выделение на следующий, после
последнего снова на верхний; строка состояния говорит, сколько их там.
Нажатие для перетаскивания берёт именно выделенный — иначе нижний клип
нельзя было бы ни выделить, ни потащить, не убрав сперва верхний.
Различие не косметическое. Если бы кроссфейдом считалось любое
перекрытие, то в проекте, где клипы на разных дорожках перекрываются
постоянно, каждое такое место пришлось бы пересобирать.
@ -701,6 +707,12 @@ Linux потом искала по нему `.dll`.
кусков нельзя, он на этом зависает. Заготовки делаются готовых длин
(15 с, 60 с, 5 мин и так далее), а нужный кусок из них вырезается —
тогда любое движение клипа не требует новой генерации.
Своё кадрирование картинки — увеличение, смещение, поворот — запекается
прямо в эту заготовку, той же цепочкой фильтров, что показывает окно
«Кадрирование и поворот». Поэтому в кэше лежит столько заготовок,
сколько разных кадрирований у этой картинки, и предпросмотр с роликом
показывают одно и то же.
3. Границы кусков кладутся на кадровую сетку проекта: между кадрами
времени нет, а звук набирается с точностью до отсчёта, и доли кадра
разводят их в стороны.
@ -1020,7 +1032,7 @@ Wine с новой библиотекой не грузилось ничего.
уход выглядит как накапливающийся рассинхрон, которого нет. На это уже
попались один раз.
Ещё девять проверок гоняют саму программу на невидимом экране
Ещё десять проверок гоняют саму программу на невидимом экране
(`Xvfb :99`). Рабочий экран они не занимают:
```
@ -1033,9 +1045,15 @@ Wine с новой библиотекой не грузилось ничего.
./tests/genquiet.sh [<путь к nlve>] тяжёлая сборка ждёт конца правки
./tests/dropgap.sh [<путь к nlve>] клип садится в дыру без наезда
./tests/dragorder.sh [<путь к nlve>] связка переехала — порядок цел
./tests/pickcycle.sh [<путь к nlve>] щелчок перебирает наехавшие клипы
```
Последним семи нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один
Отдельно от них `tests/imagecrop.sh` — сквозная, без окна: делает
картинку, кладёт её клипом с уменьшением и смещением, собирает секунду
ролика и смотрит на точки готового кадра. Ей нужны `tests/nlvetool`
(`./build.sh test`), ffmpeg и python с PIL.
Последним восьми нужен монтаж в `test/поток/`: `длинный-фейд.nlve` — один
клип с затуханием секунд на семьдесят (такой кусок сводится около
полутора минут, и всё это время человек продолжает работать),
`пара.nlve` — два клипа без затуханий. Путь к первому задаётся
@ -1052,12 +1070,13 @@ xdotool не видит ни одного окна и проверка «про
экран шире: `Xvfb :98 -screen 0 1600x1000x24` и `ЭКРАН=:98`.
Каждую из них стоит прогонять и на прежней сборке: проверка, которая
проходит везде, не проверяет ничего. Все девять на сборке до своей правки
проходит везде, не проверяет ничего. Все десять на сборке до своей правки
падают. Замерено: клип у кромки уезжает на 0.7 с против 4.2 с, файл из
списка падает в конец дорожки вместо курсора, сборка начинается через
0.2 с вместо 1.6 с, клип в дыру садится с наездом 0.007383 с, а дорожка
0.2 с вместо 1.6 с, клип в дыру садится с наездом 0.007383 с, дорожка
картинки после переноса связки остаётся в порядке 0, 14, 7.991 — не по
времени.
времени, второй щелчок по наезду тащит верхний клип вместо нижнего, а
картинка с уменьшением 50% выходит в ролик во весь кадр.
## Структура

View file

@ -3919,9 +3919,19 @@ begin
end;
procedure TMainForm.TimelineSelectionChange(Sender: TObject);
var
Line: string;
begin
SetStatus(Format('Выделено %d %s', [FProject.SelectedCount,
Plural(FProject.SelectedCount, 'клип', 'клипа', 'клипов')]), True);
Line := Format('Выделено %d %s', [FProject.SelectedCount,
Plural(FProject.SelectedCount, 'клип', 'клипа', 'клипов')]);
{ В месте наезда под указателем несколько клипов, и щелчок перебирает
их по очереди. Молчать об этом нельзя: человек видит, что выделился
не тот клип, и не знает, что второй щелчок возьмёт следующий. }
if FTimeline.PickedCount > 1 then
Line := Line + Format('. Под указателем %d %s — щелчок берёт следующий',
[FTimeline.PickedCount,
Plural(FTimeline.PickedCount, 'клип', 'клипа', 'клипов')]);
SetStatus(Line, True);
end;
procedure TMainForm.PlayerLog(Sender: TObject; const APrefix, ALevel, AText: string);

View file

@ -221,6 +221,8 @@ type
property Selected: Boolean read FSelected write FSelected;
end;
TClipArray = array of TClip;
{ Что живёт на дорожке. Картинка и звук разделены, потому что их можно
двигать порознь: звук из одного места монтажа под картинкой из
другого — обычное дело. }
@ -244,6 +246,13 @@ type
procedure Extract(AClip: TClip);
procedure Sort;
function ClipAt(const ATime: Double): TClip;
{ Все клипы дорожки, накрывающие это время, сверху вниз.
Обычно он один: клипы дорожки стоят встык. Но наезд двух клипов —
это заказ на переход, и в месте наезда их два. Щелчок по такому
месту должен уметь добраться до каждого, иначе нижний не взять ни
выделением, ни перетаскиванием. }
function ClipsAt(const ATime: Double): TClipArray;
function IndexOf(AClip: TClip): Integer;
function EndTime: Double;
property Clips[AIndex: Integer]: TClip read GetClip; default;
@ -632,15 +641,30 @@ function TTrack.ClipAt(const ATime: Double): TClip;
var
I: Integer;
begin
{ Клипы отсортированы и не перекрываются в пределах дорожки, так что
достаточно линейного прохода. Дорожка редко содержит столько клипов,
чтобы двоичный поиск дал заметную выгоду. }
{ Клипы отсортированы, так что достаточно линейного прохода. Дорожка
редко содержит столько клипов, чтобы двоичный поиск дал заметную
выгоду. Верхним считается первый по списку: в наезде побеждает он. }
for I := 0 to FClips.Count - 1 do
if TClip(FClips[I]).Covers(ATime) then
Exit(TClip(FClips[I]));
Result := nil;
end;
function TTrack.ClipsAt(const ATime: Double): TClipArray;
var
I, N: Integer;
begin
Result := nil;
N := 0;
for I := 0 to FClips.Count - 1 do
if TClip(FClips[I]).Covers(ATime) then
begin
SetLength(Result, N + 1);
Result[N] := TClip(FClips[I]);
Inc(N);
end;
end;
function TTrack.IndexOf(AClip: TClip): Integer;
begin
Result := FClips.IndexOf(AClip);

View file

@ -159,6 +159,14 @@ type
FOnSelectionModeChange: TNotifyEvent;
FGroupClips: array of TClip;
FGroupOrigStarts: array of Double;
{ Клипы, накрытые последним нажатием, и выбранный из них. По ним
щелчок по месту наезда переходит к следующему клипу. }
FPickCands: TClipArray;
FPickClip: TClip;
FPickCount: Integer;
{ Нажатие попало в клип, который уже был выделен. Значит это второй
щелчок по тому же месту, и он означает «покажи следующий». }
FPickWasSelected: Boolean;
{ Дорожки, чьи клипы поехали вместе с перетаскиваемым: связка ведёт
звук за картинкой, схлопывание ведёт соседей, и лежат они на чужих
дорожках. Список нужен, чтобы после каждого сдвига вернуть им
@ -197,6 +205,13 @@ type
function TrackTop(AIndex: Integer): Integer;
function TrackIndexAt(const AY: Integer): Integer;
function ClipAtPoint(const AX, AY: Integer; out ATrack: TTrack): TClip;
{ Тот же выбор, но с перебором: повторный щелчок в то же место берёт
следующий клип из накрывающих эту точку. Только для нажатия — от
наведения перебирать нельзя. }
function PickClipAt(const AX, AY: Integer; out ATrack: TTrack): TClip;
{ Перейти к следующему из наехавших клипов. Зовётся при отпускании,
когда стало ясно, что это щелчок, а не протяжка. }
procedure CycleToNextPicked;
function EdgeAtPoint(const AX: Integer; AClip: TClip): Integer;
{ Что под указателем в полосе заголовков и на какой дорожке. }
function HeaderPartAt(const AX, AY: Integer; out ATrackIndex: Integer): THeaderPart;
@ -452,6 +467,10 @@ type
для строки состояния: дыра могла найтись не на той дорожке, где
стоял указатель, и молча двигать чужую дорожку нельзя. }
property LastGapTrack: string read FLastGapTrack;
{ Сколько клипов накрывал последний щелчок. Больше одного — значит
там наезд, и следующий щелчок в то же место возьмёт следующий клип.
Нужно окну: перебор, о котором не сказали, — тайное знание. }
property PickedCount: Integer read FPickCount;
{ Правка идёт прямо сейчас: клип держат мышью, тянут за край или за
ручку. Окно по этому признаку откладывает тяжёлую сборку — считать
наложение, пока расстановка меняется каждым движением руки, значит
@ -694,6 +713,87 @@ begin
Result := ATrack.ClipAt(XToTime(AX));
end;
{ Какой из наехавших клипов берёт нажатие.
Наезд двух клипов на одной дорожке — это заказ на переход, то есть
обычное дело. Нажатие всегда попадало в верхний по списку, и нижний
нельзя было ни выделить, ни потащить: чтобы до него добраться,
приходилось сперва убирать верхний.
Правило простое: если один из накрытых клипов уже выделен — берём его.
Тогда протяжка тащит именно то, что человек перед этим выбрал. Иначе
берём верхний, как и раньше.
Переход к следующему происходит не здесь, а при отпускании
(`CycleToNextPicked`): пока кнопка не отпущена, неизвестно, щелчок это
или начало протяжки, а менять клип под рукой посреди протяжки нельзя. }
function TTimelineControl.PickClipAt(const AX, AY: Integer;
out ATrack: TTrack): TClip;
var
I, Idx: Integer;
begin
Result := nil;
ATrack := nil;
FPickCands := nil;
FPickClip := nil;
FPickCount := 0;
FPickWasSelected := False;
if (FProject = nil) or (AX < FHeaderWidth) then
Exit;
Idx := TrackIndexAt(AY);
if Idx < 0 then
Exit;
ATrack := FProject.Tracks[Idx];
FPickCands := ATrack.ClipsAt(XToTime(AX));
FPickCount := Length(FPickCands);
if FPickCount = 0 then
Exit;
Result := FPickCands[0];
for I := 0 to High(FPickCands) do
if FPickCands[I].Selected then
begin
Result := FPickCands[I];
FPickWasSelected := True;
Break;
end;
FPickClip := Result;
end;
{ Щелчок по уже выделенному клипу в месте наезда показывает следующий.
Второй щелчок по тому же месту означает «не этот, покажи другой». После
последнего снова верхний — так перебираются хоть три клипа.
Делается на отпускании и только если мышь не двигали: иначе это была
протяжка, и клип менять нельзя. }
procedure TTimelineControl.CycleToNextPicked;
var
I: Integer;
Next: TClip;
begin
if (FProject = nil) or (FPickCount < 2) or (not FPickWasSelected) then
Exit;
Next := nil;
for I := 0 to High(FPickCands) do
if FPickCands[I] = FPickClip then
begin
Next := FPickCands[(I + 1) mod FPickCount];
Break;
end;
if (Next = nil) or (Next = FPickClip) then
Exit;
FProject.ClearSelection;
Next.Selected := True;
FPickClip := Next;
if Assigned(FOnSelectionChange) then
FOnSelectionChange(Self);
Invalidate;
end;
{ Минус один — левый край, плюс один — правый, ноль — середина. }
function TTimelineControl.EdgeAtPoint(const AX: Integer; AClip: TClip): Integer;
var
@ -2337,7 +2437,7 @@ begin
Exit;
end;
Clip := ClipAtPoint(X, Y, Track);
Clip := PickClipAt(X, Y, Track);
if Clip = nil then
begin
@ -3070,7 +3170,12 @@ begin
if FProject <> nil then
FProject.CancelChange;
if WasClipPress then
begin
Playhead := Max(0, SnapPlayhead(XToTime(FPressX)));
{ Щёлкнули по тому же клипу второй раз — значит хотят соседний,
который лежит под ним. }
CycleToNextPicked;
end;
Exit;
end;

View file

@ -736,6 +736,58 @@ begin
end;
end;
{ В месте наезда под указателем несколько клипов.
Наезд двух клипов на одной дорожке — заказ на переход, то есть обычное
дело. Пока отбор возвращал только первый по списку, нижний клип нельзя
было ни выделить, ни потащить. }
procedure CheckClipsAt;
var
Proj: TProject;
Info: TMediaInfo;
Media: TMediaItem;
A, B: TClip;
Got: TClipArray;
begin
WriteLn;
WriteLn('Клипы под одной точкой');
WriteLn;
Info := TestInfo;
Proj := TProject.Create;
try
Proj.Clear;
Proj.AddTrack('V1');
Media := Proj.AddMedia('/несуществующий/видео.mp4', Info);
Proj.Master := Info;
{ Второй наехал на первый на две секунды: 0..10 и 8..18. }
A := Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 0);
B := Proj.AddClip(Proj.Tracks[0], Media, 0, 10, 8);
Got := Proj.Tracks[0].ClipsAt(9);
CheckTrue('в наезде под точкой двое: ' + IntToStr(Length(Got)),
Length(Got) = 2);
if Length(Got) = 2 then
begin
CheckTrue('первым идёт верхний по списку', Got[0] = A);
CheckTrue('вторым — тот, что под ним', Got[1] = B);
end;
Got := Proj.Tracks[0].ClipsAt(3);
CheckTrue('вне наезда клип один: ' + IntToStr(Length(Got)),
(Length(Got) = 1) and (Got[0] = A));
Got := Proj.Tracks[0].ClipsAt(100);
CheckTrue('за концом монтажа никого', Length(Got) = 0);
{ Прежний отбор остаётся прежним: он берёт верхний. }
CheckTrue('ClipAt по-прежнему даёт верхний',
Proj.Tracks[0].ClipAt(9) = A);
finally
Proj.Free;
end;
end;
{ Клип притягивается к границе и хвостом, а не только головой.
Подводят кусок к началу соседа слева: голова встаёт в свободное место,
@ -1155,6 +1207,7 @@ begin
CheckFrameGridRepair;
CheckSplitAndPlayhead;
CheckMakeRoom;
CheckClipsAt;
CheckSnapTail;
CheckSplitAllTracks;
CheckGapTolerance;

141
tests/pickcycle.sh Executable file
View file

@ -0,0 +1,141 @@
#!/bin/sh
# Повторный щелчок в месте наезда берёт следующий клип.
#
# Наезд двух клипов на одной дорожке — это заказ на переход, то есть
# обычное дело. Щелчок же всегда попадал в один и тот же клип, верхний по
# списку, и до нижнего было не добраться: ни выделить, ни потащить. Чтобы
# взять его, приходилось сначала убирать верхний.
#
# Проверяется так: два клипа внахлёст, щелчок по месту наезда, второй
# щелчок туда же, затем протяжка. Уехать обязан ВТОРОЙ клип. Выделение в
# файл проекта не пишется, а позиция пишется, поэтому смотрим по ней.
#
# Запуск (экран :99 поднят):
# tests/pickcycle.sh [<путь к nlve>]
set -e
cd "$(dirname "$0")/.."
BIN=${1:-$PWD/nlve}
DISP=${NLVE_DISPLAY:-:99}
SRC=$PWD/test/поток/пара.nlve
PROJ=$PWD/test/поток/перебор.nlve
[ -f "$SRC" ] || { echo "нет монтажа $SRC"; exit 2; }
export DISPLAY=$DISP
reachable() { timeout 5 xdotool getdisplaygeometry >/dev/null 2>&1; }
if ! reachable; then
for p in $(pgrep -x Xvfb); do
A=$(tr '\0' '\n' < /proc/$p/cmdline | grep -A1 -x -- -auth | tail -1)
[ -f "$A" ] || continue
XAUTHORITY=$A; export XAUTHORITY
reachable && break
done
fi
reachable ||
{ echo "нет доступа к экрану $DISP: подними его (xvfb-run) и повтори"; exit 2; }
# Два клипа внахлёст: 0..10 и 8..18. Наезд с 8 по 10.
python3 - "$SRC" "$PROJ" <<'PY'
import json, sys, copy
d = json.load(open(sys.argv[1]))
V = next(t for t in d['tracks'] if t['name'] == 'V1')
A = next(t for t in d['tracks'] if t['name'] == 'A1')
ов, оа = copy.deepcopy(V['clips'][0]), copy.deepcopy(A['clips'][0])
def клип(обр, ид, группа, старт, длина, вход):
c = copy.deepcopy(обр)
c.update(id=ид, group=группа, tl_start=старт, duration=длина,
src_in=вход, fade_in=0.0, fade_out=0.0)
return c
V['clips'] = [клип(ов, 'a_v', 'ga', 0.0, 10.0, 100.0),
клип(ов, 'b_v', 'gb', 8.0, 10.0, 200.0)]
A['clips'] = [клип(оа, 'a_a', 'ga', 0.0, 10.0, 100.0),
клип(оа, 'b_a', 'gb', 8.0, 10.0, 200.0)]
json.dump(d, open(sys.argv[2], 'w'), ensure_ascii=False)
print(' клипы 0..10 и 8..18, наезд с 8 по 10')
PY
for p in $(pgrep -x nlve); do
tr '\0' '\n' < /proc/$p/environ 2>/dev/null | grep -qx "DISPLAY=$DISP" && kill $p
done
sleep 1
SP=$PWD/test/поток/профиль-перебор
rm -rf "$SP"; mkdir -p "$SP"
rm -rf "$PROJ-cache"
rm -f "$PROJ.autosave"
XDG_CONFIG_HOME=$SP XDG_CACHE_HOME=$SP XDG_DATA_HOME=$SP \
setsid "$BIN" "$PROJ" >/dev/null 2>&1 < /dev/null &
sleep 14
X="timeout 8 xdotool"
WID=
for w in $($X search --name "перебор.nlve" 2>/dev/null); do
xwininfo -id "$w" 2>/dev/null | grep -q IsViewable && { WID=$w; break; }
done
[ -n "$WID" ] || { echo "живое окно редактора не найдено"; exit 2; }
$X windowmove "$WID" 0 0 windowsize "$WID" 1270 990
sleep 2
ROW=${NLVE_ROW:-505}
starts() {
$X key ctrl+s
sleep 2
python3 -c "
import json
d = json.load(open('$PROJ'))
for t in d['tracks']:
if t['name'] != 'V1': continue
print(' '.join('%s=%.3f' % (c['id'], c['tl_start'])
for c in sorted(t['clips'], key=lambda c: c['id'])))
"
}
echo " до правок: $(starts)"
# Место наезда: середина отрезка 8..10 с. Замерено по линейке в окне
# 1270x990 на этом монтаже: ноль в точке 592, в секунде 56.3 точки.
PX=${NLVE_PX:-1098}
# Первый щелчок берёт верхний, второй — переводит выделение на нижний.
# Мышь между щелчками не двигаем.
$X mousemove "$PX" "$ROW" click 1; sleep 1
$X click 1; sleep 1
# Теперь тащим выбранный клип вправо на заметную величину. Нажатие берёт
# именно выделенный — иначе протяжкой нельзя было бы взять то, что
# только что выбрали щелчками.
$X mousedown 1; sleep 0.3
$X mousemove $((PX + 60)) "$ROW"; sleep 0.3
$X mousemove $((PX + 120)) "$ROW"; sleep 0.4
$X mouseup 1; sleep 1.5
AFTER=$(starts)
echo " после второго щелчка и протяжки: $AFTER"
for p in $(pgrep -x nlve); do
tr '\0' '\n' < /proc/$p/environ 2>/dev/null | grep -qx "DISPLAY=$DISP" && kill $p
done
rm -f "$PROJ.autosave"
python3 - "$PROJ" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
V = next(t for t in d['tracks'] if t['name'] == 'V1')
старт = {c['id']: c['tl_start'] for c in V['clips']}
a, b = старт['a_v'], старт['b_v']
# Верхний обязан стоять там, где стоял. Если уехал он — значит второй
# щелчок не переключил, потащили верхний, а нижний уехал следом за ним
# схлопыванием, и выглядит это обманчиво похоже на успех.
if abs(a - 0.0) > 0.05:
print(' ПЛОХО: второй щелчок не переключил — потащили верхний '
'(a_v: 0.000 -> %.3f, b_v: 8.000 -> %.3f)' % (a, b))
raise SystemExit(1)
if abs(b - 8.0) <= 0.05:
print(' ПЛОХО: никто никуда не уехал — проверка ничего не значит')
raise SystemExit(1)
print(' ok: потащили нижний клип (b_v: 8.000 -> %.3f), верхний на месте'
% b)
PY