Работа не теряется: автосохранение, видимое сохранение, запасной файл

Сохранение теперь отвечает в строке состояния: «Проект сохранён: имя,
время». Раньше об этом знал только журнал, а он по умолчанию закрыт —
нажал Ctrl+S и не знаешь, дошло ли до файла.

Проект пишется во временный файл рядом и подставляется целиком; прежний
остаётся как .bak. Оборванная запись больше не может оставить огрызок
вместо проекта.

Автосохранение: пока есть несохранённое, раз в полминуты рядом с
проектом пишется копия .autosave. При открытии программа сравнивает её
время с временем проекта и, если копия новее, показывает оба времени и
предлагает её открыть. После сохранения проекта копия убирается.

Журнал рядом с программой больше не переписывается при каждом запуске:
запуски дописываются с заголовком, файл урезается при переросте. Прошлый
запуск — как раз тот, в котором обычно и надо разбираться.

Клавиша \ подтягивает окно к курсору и ставит его посередине: её жмут
после реза или выделения. Переключатель «курсор посередине» остался
кнопкой с целью.
This commit is contained in:
4aiman 2026-09-14 16:06:40 +03:00
commit 18e55d466e
3 changed files with 319 additions and 20 deletions

View file

@ -76,6 +76,7 @@ xdg-mime query default application/x-nlve-project
|---|---| |---|---|
| Пуск и пауза | Пробел | | Пуск и пауза | Пробел |
| В начало / конец монтажа | Home / End | | В начало / конец монтажа | Home / End |
| Показать, где курсор (окно едет к нему) | \ |
| Разрезать по курсору (все дорожки) | S | | Разрезать по курсору (все дорожки) | S |
| Разрезать точно (перекодирует стык) | Shift+S | | Разрезать точно (перекодирует стык) | Shift+S |
| Удалить выделенное | Delete | | Удалить выделенное | Delete |
@ -144,7 +145,7 @@ xdg-mime query default application/x-nlve-project
липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без липнут при перетаскивании, курсор — при щелчке, и одно бывает нужно без
другого. другого.
**Курсор посередине** (кнопка с целью, клавиша `\`). Включено — при **Курсор посередине** (кнопка с целью). Включено — при
воспроизведении монтаж едет под курсором, и всегда видно, что будет воспроизведении монтаж едет под курсором, и всегда видно, что будет
дальше. Выключено — окно не двигается вовсе, даже когда курсор ушёл за дальше. Выключено — окно не двигается вовсе, даже когда курсор ушёл за
край: отмотал Shift с колесом — там и останешься. Раньше в выключенном край: отмотал Shift с колесом — там и останешься. Раньше в выключенном
@ -251,6 +252,27 @@ Ctrl+Shift+R, который схлопывает дыру под курсоро
неправильно. Усиление меняет только рисунок, самого звука не касается, и неправильно. Усиление меняет только рисунок, самого звука не касается, и
запоминается между запусками. запоминается между запусками.
## Сохранение и автосохранение
**Сохранение видно.** После Ctrl+S внизу появляется «Проект сохранён:
имя, время». Раньше об этом говорила только строчка в журнале, а журнал
по умолчанию закрыт: нажал — и непонятно, дошло до файла или нет.
**Запись не оставляет огрызков.** Проект пишется во временный файл рядом
и подставляется на место настоящего, только когда записан целиком.
Прежний файл при этом становится запасным — `имя.nlve.bak`. Падение или
кончившееся место посреди записи больше не оставляют половину проекта.
**Копия пишется сама.** Пока в проекте есть несохранённое, раз в полминуты
рядом появляется `имя.nlve.autosave`. Это не сохранение: файл проекта не
трогается, признак «изменён» остаётся.
При открытии проекта программа смотрит, нет ли рядом копии новее его
самого. Есть — значит прошлая работа до файла не дошла, и программа
показывает оба времени и предлагает открыть копию. Отказ копию не
удаляет: она лежит, пока проект не сохранят заново. После сохранения
копия не нужна и убирается.
## Файл не совпадает с проектом ## Файл не совпадает с проектом
Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не Проект имеет одно разрешение и один набор кодеков на всё — иначе куски не

View file

@ -95,6 +95,10 @@ type
FStarted: Boolean; FStarted: Boolean;
FPreviewTimer: TTimer; FPreviewTimer: TTimer;
{ Автосохранение: копия проекта пишется сама собой, пока человек
работает. Нужна ровно на один случай — работа пропала, а сохранить
её забыли или сохранение не дошло до файла. }
FAutoSaveTimer: TTimer;
FPreviewDirty: Boolean; FPreviewDirty: Boolean;
FUpdatingFromPlayer: Boolean; FUpdatingFromPlayer: Boolean;
FLastLoadedEdl: string; FLastLoadedEdl: string;
@ -217,6 +221,8 @@ type
FSeekWaitTicks: Integer; FSeekWaitTicks: Integer;
{ До какого времени ответ на команду держится в строке состояния. } { До какого времени ответ на команду держится в строке состояния. }
FStatusHoldUntil: TDateTime; FStatusHoldUntil: TDateTime;
{ Автосохранение не удалось — жалуемся один раз, а не каждые полминуты. }
FAutoSaveBroken: Boolean;
procedure ApplyVolumeAt(const ATime: Double); procedure ApplyVolumeAt(const ATime: Double);
{ Переписывает файл субтитров и отдаёт его плееру. } { Переписывает файл субтитров и отдаёт его плееру. }
@ -292,9 +298,16 @@ type
procedure RemindAboutDeps; procedure RemindAboutDeps;
procedure Log(const AText: string); procedure Log(const AText: string);
procedure LogToFile(const AText: string); procedure LogToFile(const AText: string);
{ Урезает файл журнала, когда он перерос предел. }
procedure TrimLogFile;
{ AAnswer — это ответ на команду человека, а не проходное сообщение: { AAnswer — это ответ на команду человека, а не проходное сообщение:
такой ответ держится в строке несколько секунд. } такой ответ держится в строке несколько секунд. }
procedure SetStatus(const AText: string; AAnswer: Boolean = False); procedure SetStatus(const AText: string; AAnswer: Boolean = False);
{ Автосохранение: копия рядом с проектом. }
function AutoSavePath: string;
procedure DropAutoSave;
procedure AutoSaveTick(Sender: TObject);
procedure OfferAutoSave;
procedure UpdateCaption; procedure UpdateCaption;
procedure UpdateTimeLabel; procedure UpdateTimeLabel;
procedure RefreshPool; procedure RefreshPool;
@ -415,6 +428,18 @@ const
затереть проходным сообщением. Четыре секунды — успеть прочесть. } затереть проходным сообщением. Четыре секунды — успеть прочесть. }
StatusHoldTime = 4 / (24 * 60 * 60); StatusHoldTime = 4 / (24 * 60 * 60);
{ Как часто пишется копия проекта для восстановления. Полминуты — это
потолок потерь: больше получаса работы человек в неё не вложит, а
запись проекта стоит миллисекунды. }
AutoSaveIntervalMs = 30 * 1000;
{ Приставка к имени файла проекта у автосохранённой копии. }
AutoSaveSuffix = '.autosave';
{ Предел для файла журнала. Больше двух мегабайт читать всё равно
никто не станет, а место жалко. }
MaxLogFileSize = 2 * 1024 * 1024;
function FormatTimecode(const Seconds: Double): string; function FormatTimecode(const Seconds: Double): string;
var var
Total, H, M, S, Ms: Integer; Total, H, M, S, Ms: Integer;
@ -460,6 +485,11 @@ begin
FPreviewTimer.Interval := PreviewDebounceMs; FPreviewTimer.Interval := PreviewDebounceMs;
FPreviewTimer.OnTimer := @PreviewTimerTick; FPreviewTimer.OnTimer := @PreviewTimerTick;
FAutoSaveTimer := TTimer.Create(Self);
FAutoSaveTimer.Interval := AutoSaveIntervalMs;
FAutoSaveTimer.OnTimer := @AutoSaveTick;
FAutoSaveTimer.Enabled := True;
{ Список изображений держит миниатюры. Размер задан жёстко, и ffmpeg { Список изображений держит миниатюры. Размер задан жёстко, и ffmpeg
делает их ровно такими: список изображений в LCL требует одинакового делает их ровно такими: список изображений в LCL требует одинакового
размера у всех элементов. } размера у всех элементов. }
@ -1245,11 +1275,14 @@ begin
lstPool.ItemIndex := 0; lstPool.ItemIndex := 0;
end; end;
{ Спрашивать про клипы на чужих дорожках можно только теперь: окно { Спрашивать можно только теперь: окно показано, и вопрос не окажется
показано, и вопрос не окажется за кадром. } за кадром. Автосохранённая копия — первым делом: если прошлая работа
не дошла до файла, человек должен узнать об этом до того, как начнёт
правку поверх старого. }
if FCheckMisplacedOnShow then if FCheckMisplacedOnShow then
begin begin
FCheckMisplacedOnShow := False; FCheckMisplacedOnShow := False;
OfferAutoSave;
CheckMisplacedClips; CheckMisplacedClips;
end; end;
end; end;
@ -1334,12 +1367,57 @@ end;
{ --- Мелочи интерфейса --- } { --- Мелочи интерфейса --- }
{ Урезание журнала до последних строк, когда файл перерос предел.
Читается целиком: предел небольшой, и возиться с чтением с конца ради
пары мегабайт незачем. }
procedure TMainForm.TrimLogFile;
var
L: TStringList;
Keep: Integer;
Found: TSearchRec;
Size: Int64;
begin
if not FileExists(FLogFilePath) then
Exit;
Size := 0;
if FindFirst(FLogFilePath, faAnyFile, Found) = 0 then
begin
Size := Found.Size;
FindClose(Found);
end;
if Size <= MaxLogFileSize then
Exit;
L := TStringList.Create;
try
try
L.LoadFromFile(FLogFilePath);
Keep := L.Count div 2;
while L.Count > Keep do
L.Delete(0);
L.Insert(0, '=== начало журнала урезано ===');
L.SaveToFile(FLogFilePath);
except
{ Не вышло урезать — не беда: журнал не работа, ради которой сюда
пришли. }
end;
finally
L.Free;
end;
end;
{ Дублирование журнала в файл рядом с программой. { Дублирование журнала в файл рядом с программой.
Нужно ровно для одного случая: программа падает до того, как окно Нужно ровно для одного случая: программа падает до того, как окно
успело появиться. Тогда всё, что она о себе рассказала, пропадает успело появиться. Тогда всё, что она о себе рассказала, пропадает
вместе с окном, и причина остаётся неизвестной. Файл переписывается вместе с окном, и причина остаётся неизвестной.
при каждом запуске: интересен последний.
Файл не переписывается при запуске, а дописывается: разбираться обычно
приходится не с тем запуском, который идёт сейчас, а с прошлым, и
затирать его нельзя — второй запуск программы уносил бы единственное
свидетельство. Каждый запуск отделён строкой с датой и временем. Файл
урезается, когда перерастёт предел: бесконечно растущий журнал никому
не нужен.
Неудача записи ничего не значит — каталог может быть закрыт на запись, Неудача записи ничего не значит — каталог может быть закрыт на запись,
и работать это мешать не должно. } и работать это мешать не должно. }
@ -1350,14 +1428,19 @@ begin
if FLogFileBroken then if FLogFileBroken then
Exit; Exit;
try try
if not FLogFileStarted then
begin
TrimLogFile;
FLogFileStarted := True;
LogToFile('');
LogToFile('=== запуск ' + FormatDateTime('dd.mm.yyyy hh:nn:ss', Now) +
' ===');
end;
AssignFile(F, FLogFilePath); AssignFile(F, FLogFilePath);
if FLogFileStarted then if FileExists(FLogFilePath) then
Append(F) Append(F)
else else
begin
Rewrite(F); Rewrite(F);
FLogFileStarted := True;
end;
try try
WriteLn(F, AText); WriteLn(F, AText);
finally finally
@ -2363,6 +2446,9 @@ begin
FSettings.NoteRecentProject(FProject.FileName); FSettings.NoteRecentProject(FProject.FileName);
RefreshRecentMenu; RefreshRecentMenu;
Log('Открыт проект: ' + FProject.FileName); Log('Открыт проект: ' + FProject.FileName);
{ Если рядом лежит копия новее проекта — значит прошлая работа не дошла
до файла, и об этом надо сказать сразу при открытии. }
OfferAutoSave;
CheckMisplacedClips; CheckMisplacedClips;
end; end;
@ -3322,12 +3408,21 @@ begin
Exit; Exit;
end; end;
{ Обратная косая: пункта меню для неё нет намеренно — сочетание из { Обратная косая — «покажи, где курсор»: окно едет к нему и ставит его
одной такой клавиши в меню выглядит как опечатка, а работать должно. } посередине. Жмут её чаще всего после реза или выделения, когда курсор
стоит на границе и надо к ней вернуться.
Раньше она переключала режим «курсор посередине». Переключатель
остался кнопкой с целью внизу: включают его раз и надолго, а
подтягивать окно к курсору нужно постоянно.
Пункта меню для неё нет намеренно — сочетание из одной такой клавиши
в меню выглядит как опечатка, а работать должно. }
if (Key = VK_OEM_5) and (Shift = []) then if (Key = VK_OEM_5) and (Shift = []) then
begin begin
Key := 0; Key := 0;
ToggleCenterPlayhead; FTimeline.ScrollToPlayhead(True);
SetStatus('Окно у курсора: ' + FormatTimecode(FTimeline.Playhead), True);
end; end;
end; end;
@ -3391,10 +3486,134 @@ begin
else else
begin begin
UpdateCaption; UpdateCaption;
Log('Проект сохранён.'); { Сохранение должно быть видно. Раньше о нём говорила только строчка
в журнале, а журнал по умолчанию закрыт: человек нажимал Ctrl+S,
ничего не происходило, и понять, дошло сохранение до файла или нет,
было не по чему. }
SetStatus('Проект сохранён: ' + ExtractFileName(FProject.FileName) +
', ' + FormatDateTime('hh:nn:ss', Now), True);
Log('Проект сохранён: ' + FProject.FileName);
{ Копия для восстановления больше не нужна: в файле то же самое. }
DropAutoSave;
end; end;
end; end;
{ Где лежит автосохранённая копия проекта. Рядом с самим проектом, чтобы
она нашлась там же, где его ищут. У безымянного проекта её нет: некуда
положить, и восстанавливать потом нечего. }
function TMainForm.AutoSavePath: string;
begin
if FProject.FileName = '' then
Result := ''
else
Result := FProject.FileName + AutoSaveSuffix;
end;
procedure TMainForm.DropAutoSave;
var
Path: string;
begin
Path := AutoSavePath;
if (Path <> '') and FileExists(Path) then
DeleteFile(Path);
end;
{ Копия проекта пишется, пока в нём есть несохранённое.
Это не замена сохранению: файл проекта не трогается, имя не меняется,
признак «изменён» остаётся. Копия нужна на случай, когда работа всё же
пропала — программу закрыли, она упала, сохранение не дошло до файла. }
procedure TMainForm.AutoSaveTick(Sender: TObject);
var
Path, Err: string;
begin
if FProject = nil then
Exit;
Path := AutoSavePath;
if Path = '' then
Exit;
{ Несохранённого нет — писать нечего. Копию при этом не трогаем: она
может быть единственным следом прошлой работы, а спросить о ней ещё
не успели. Убирается она там, где точно больше не нужна: после
сохранения проекта и после ответа на вопрос при открытии. }
if not FProject.Modified then
Exit;
if not FProject.SaveCopyTo(Path, Err) then
begin
{ Жалуемся один раз и больше не мешаем: автосохранение — подстраховка,
а не работа, ради которой человек сюда пришёл. }
if not FAutoSaveBroken then
begin
FAutoSaveBroken := True;
Log('Автосохранение не работает: ' + Err);
end;
end
else
FAutoSaveBroken := False;
end;
{ Проверка при открытии: не осталась ли копия новее самого проекта.
Так бывает ровно в одном случае — работа не дошла до файла проекта.
Молча подменять файл нельзя, поэтому спрашиваем и показываем время. }
procedure TMainForm.OfferAutoSave;
var
Path, Err: string;
CopyStamp, ProjStamp: LongInt;
CopyTime, ProjTime: TDateTime;
begin
Path := AutoSavePath;
if (Path = '') or (not FileExists(Path)) then
Exit;
CopyStamp := FileAge(Path);
ProjStamp := FileAge(FProject.FileName);
if (CopyStamp < 0) or (ProjStamp < 0) then
Exit;
CopyTime := FileDateToDateTime(CopyStamp);
ProjTime := FileDateToDateTime(ProjStamp);
{ Копия старше проекта — значит проект сохранён позже неё, и она уже
ни о чём не говорит. }
if CopyTime <= ProjTime + 1 / (24 * 60 * 60) then
begin
DeleteFile(Path);
Exit;
end;
Log(Format('Найдена автосохранённая копия от %s, проект сохранён %s.',
[FormatDateTime('dd.mm hh:nn:ss', CopyTime),
FormatDateTime('dd.mm hh:nn:ss', ProjTime)]));
if ThemedMessageDlg('nlve',
Format('Рядом с проектом лежит автосохранённая копия от %s, ' +
'а сам проект сохранён %s.' + LineEnding + LineEnding +
'Похоже, последние правки не дошли до файла проекта. ' +
'Открыть копию?' + LineEnding + LineEnding +
'Проект при этом не перезаписывается: копия просто откроется, и ' +
'сохранить её поверх проекта можно будет самому. Откажетесь — ' +
'копия останется лежать рядом, пока проект не сохранят заново.',
[FormatDateTime('dd.mm.yyyy hh:nn:ss', CopyTime),
FormatDateTime('dd.mm.yyyy hh:nn:ss', ProjTime)]),
mtConfirmation, [mbYes, mbNo], 0) <> mrYes then
Exit;
if not FProject.LoadFromFile(Path, Err) then
begin
ThemedMessageDlg('nlve', Err, mtError, [mbOK], 0);
Exit;
end;
{ Имя возвращаем проектное: копия открыта, но работать человек
продолжает с проектом, и Ctrl+S должен писать в него. }
FProject.FileName := Copy(Path, 1, Length(Path) - Length(AutoSaveSuffix));
FProject.Modified := True;
RefreshPool;
UpdateCaption;
FLastLoadedEdl := '';
SchedulePreview;
SetStatus('Открыта автосохранённая копия. Проверьте и сохраните: Ctrl+S.',
True);
end;
procedure TMainForm.MenuSaveProject(Sender: TObject); procedure TMainForm.MenuSaveProject(Sender: TObject);
begin begin
DoSaveProject; DoSaveProject;

View file

@ -377,6 +377,9 @@ type
{ --- Файл проекта --- } { --- Файл проекта --- }
function SaveToFile(const APath: string; out AError: string): Boolean; function SaveToFile(const APath: string; out AError: string): Boolean;
{ Копия проекта в другой файл: имя проекта и признак «изменён» не
трогаются. Для автосохранения. }
function SaveCopyTo(const APath: string; out AError: string): Boolean;
function LoadFromFile(const APath: string; out AError: string): Boolean; function LoadFromFile(const APath: string; out AError: string): Boolean;
property Pool[AIndex: Integer]: TMediaItem read GetPoolItem; property Pool[AIndex: Integer]: TMediaItem read GetPoolItem;
@ -1843,7 +1846,10 @@ begin
end; end;
end; end;
function TProject.SaveToFile(const APath: string; out AError: string): Boolean; { Запись проекта в произвольный файл, не трогая ни имя проекта, ни признак
«изменён». Нужна для автосохранения: копия пишется сама собой, и проект
от этого не должен считаться сохранённым. }
function TProject.SaveCopyTo(const APath: string; out AError: string): Boolean;
var var
Text: TStringList; Text: TStringList;
begin begin
@ -1851,23 +1857,75 @@ begin
Result := False; Result := False;
Text := TStringList.Create; Text := TStringList.Create;
try try
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
попадает внутрь проекта косвенно — через сгенерированные клипы. }
FFileName := ExpandFileName(APath);
Text.Text := Serialize; Text.Text := Serialize;
try try
Text.SaveToFile(FFileName); Text.SaveToFile(APath);
FModified := False;
Result := True; Result := True;
except except
on E: Exception do on E: Exception do
AError := 'Не удалось сохранить проект: ' + E.Message; AError := 'Не удалось записать ' + APath + ': ' + E.Message;
end; end;
finally finally
Text.Free; Text.Free;
end; end;
end; end;
{ Сохранение проекта.
Пишем сперва во временный файл рядом, и только когда он записан целиком,
подставляем его на место настоящего. Прежний файл при этом становится
запасным (.bak). Так падение или полный диск посреди записи не оставляют
от проекта огрызок, а испорченное сохранение всегда можно откатить.
Признак «изменён» снимается только после того, как файл на месте: если
запись не удалась, проект остаётся несохранённым и программа спросит
о нём при выходе. }
function TProject.SaveToFile(const APath: string; out AError: string): Boolean;
var
Full, Temp, Backup: string;
begin
AError := '';
Result := False;
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
попадает внутрь проекта косвенно — через сгенерированные клипы. }
Full := ExpandFileName(APath);
FFileName := Full;
Temp := Full + '.tmp';
Backup := Full + '.bak';
if not SaveCopyTo(Temp, AError) then
Exit;
try
if FileExists(Full) then
begin
if FileExists(Backup) then
DeleteFile(Backup);
if not RenameFile(Full, Backup) then
begin
AError := 'Не удалось отложить прежний файл проекта: ' + Backup;
Exit;
end;
end;
if not RenameFile(Temp, Full) then
begin
{ Подстановка не удалась — возвращаем прежний файл на место, чтобы
не остаться вообще без проекта. }
if FileExists(Backup) and (not FileExists(Full)) then
RenameFile(Backup, Full);
AError := 'Не удалось записать проект: ' + Full;
Exit;
end;
FModified := False;
Result := True;
except
on E: Exception do
AError := 'Не удалось сохранить проект: ' + E.Message;
end;
end;
function TProject.LoadFromFile(const APath: string; out AError: string): Boolean; function TProject.LoadFromFile(const APath: string; out AError: string): Boolean;
var var
Text: TStringList; Text: TStringList;