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

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

View file

@ -95,6 +95,10 @@ type
FStarted: Boolean;
FPreviewTimer: TTimer;
{ Автосохранение: копия проекта пишется сама собой, пока человек
работает. Нужна ровно на один случай — работа пропала, а сохранить
её забыли или сохранение не дошло до файла. }
FAutoSaveTimer: TTimer;
FPreviewDirty: Boolean;
FUpdatingFromPlayer: Boolean;
FLastLoadedEdl: string;
@ -217,6 +221,8 @@ type
FSeekWaitTicks: Integer;
{ До какого времени ответ на команду держится в строке состояния. }
FStatusHoldUntil: TDateTime;
{ Автосохранение не удалось — жалуемся один раз, а не каждые полминуты. }
FAutoSaveBroken: Boolean;
procedure ApplyVolumeAt(const ATime: Double);
{ Переписывает файл субтитров и отдаёт его плееру. }
@ -292,9 +298,16 @@ type
procedure RemindAboutDeps;
procedure Log(const AText: string);
procedure LogToFile(const AText: string);
{ Урезает файл журнала, когда он перерос предел. }
procedure TrimLogFile;
{ AAnswer — это ответ на команду человека, а не проходное сообщение:
такой ответ держится в строке несколько секунд. }
procedure SetStatus(const AText: string; AAnswer: Boolean = False);
{ Автосохранение: копия рядом с проектом. }
function AutoSavePath: string;
procedure DropAutoSave;
procedure AutoSaveTick(Sender: TObject);
procedure OfferAutoSave;
procedure UpdateCaption;
procedure UpdateTimeLabel;
procedure RefreshPool;
@ -415,6 +428,18 @@ const
затереть проходным сообщением. Четыре секунды — успеть прочесть. }
StatusHoldTime = 4 / (24 * 60 * 60);
{ Как часто пишется копия проекта для восстановления. Полминуты — это
потолок потерь: больше получаса работы человек в неё не вложит, а
запись проекта стоит миллисекунды. }
AutoSaveIntervalMs = 30 * 1000;
{ Приставка к имени файла проекта у автосохранённой копии. }
AutoSaveSuffix = '.autosave';
{ Предел для файла журнала. Больше двух мегабайт читать всё равно
никто не станет, а место жалко. }
MaxLogFileSize = 2 * 1024 * 1024;
function FormatTimecode(const Seconds: Double): string;
var
Total, H, M, S, Ms: Integer;
@ -460,6 +485,11 @@ begin
FPreviewTimer.Interval := PreviewDebounceMs;
FPreviewTimer.OnTimer := @PreviewTimerTick;
FAutoSaveTimer := TTimer.Create(Self);
FAutoSaveTimer.Interval := AutoSaveIntervalMs;
FAutoSaveTimer.OnTimer := @AutoSaveTick;
FAutoSaveTimer.Enabled := True;
{ Список изображений держит миниатюры. Размер задан жёстко, и ffmpeg
делает их ровно такими: список изображений в LCL требует одинакового
размера у всех элементов. }
@ -1245,11 +1275,14 @@ begin
lstPool.ItemIndex := 0;
end;
{ Спрашивать про клипы на чужих дорожках можно только теперь: окно
показано, и вопрос не окажется за кадром. }
{ Спрашивать можно только теперь: окно показано, и вопрос не окажется
за кадром. Автосохранённая копия — первым делом: если прошлая работа
не дошла до файла, человек должен узнать об этом до того, как начнёт
правку поверх старого. }
if FCheckMisplacedOnShow then
begin
FCheckMisplacedOnShow := False;
OfferAutoSave;
CheckMisplacedClips;
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
Exit;
try
if not FLogFileStarted then
begin
TrimLogFile;
FLogFileStarted := True;
LogToFile('');
LogToFile('=== запуск ' + FormatDateTime('dd.mm.yyyy hh:nn:ss', Now) +
' ===');
end;
AssignFile(F, FLogFilePath);
if FLogFileStarted then
if FileExists(FLogFilePath) then
Append(F)
else
begin
Rewrite(F);
FLogFileStarted := True;
end;
try
WriteLn(F, AText);
finally
@ -2363,6 +2446,9 @@ begin
FSettings.NoteRecentProject(FProject.FileName);
RefreshRecentMenu;
Log('Открыт проект: ' + FProject.FileName);
{ Если рядом лежит копия новее проекта — значит прошлая работа не дошла
до файла, и об этом надо сказать сразу при открытии. }
OfferAutoSave;
CheckMisplacedClips;
end;
@ -3322,12 +3408,21 @@ begin
Exit;
end;
{ Обратная косая: пункта меню для неё нет намеренно — сочетание из
одной такой клавиши в меню выглядит как опечатка, а работать должно. }
{ Обратная косая — «покажи, где курсор»: окно едет к нему и ставит его
посередине. Жмут её чаще всего после реза или выделения, когда курсор
стоит на границе и надо к ней вернуться.
Раньше она переключала режим «курсор посередине». Переключатель
остался кнопкой с целью внизу: включают его раз и надолго, а
подтягивать окно к курсору нужно постоянно.
Пункта меню для неё нет намеренно — сочетание из одной такой клавиши
в меню выглядит как опечатка, а работать должно. }
if (Key = VK_OEM_5) and (Shift = []) then
begin
Key := 0;
ToggleCenterPlayhead;
FTimeline.ScrollToPlayhead(True);
SetStatus('Окно у курсора: ' + FormatTimecode(FTimeline.Playhead), True);
end;
end;
@ -3391,10 +3486,134 @@ begin
else
begin
UpdateCaption;
Log('Проект сохранён.');
{ Сохранение должно быть видно. Раньше о нём говорила только строчка
в журнале, а журнал по умолчанию закрыт: человек нажимал Ctrl+S,
ничего не происходило, и понять, дошло сохранение до файла или нет,
было не по чему. }
SetStatus('Проект сохранён: ' + ExtractFileName(FProject.FileName) +
', ' + FormatDateTime('hh:nn:ss', Now), True);
Log('Проект сохранён: ' + FProject.FileName);
{ Копия для восстановления больше не нужна: в файле то же самое. }
DropAutoSave;
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);
begin
DoSaveProject;

View file

@ -377,6 +377,9 @@ type
{ --- Файл проекта --- }
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;
property Pool[AIndex: Integer]: TMediaItem read GetPoolItem;
@ -1843,7 +1846,10 @@ begin
end;
end;
function TProject.SaveToFile(const APath: string; out AError: string): Boolean;
{ Запись проекта в произвольный файл, не трогая ни имя проекта, ни признак
«изменён». Нужна для автосохранения: копия пишется сама собой, и проект
от этого не должен считаться сохранённым. }
function TProject.SaveCopyTo(const APath: string; out AError: string): Boolean;
var
Text: TStringList;
begin
@ -1851,23 +1857,75 @@ begin
Result := False;
Text := TStringList.Create;
try
{ Имя файла назначаем до записи: от него зависит путь к кэшу, который
попадает внутрь проекта косвенно — через сгенерированные клипы. }
FFileName := ExpandFileName(APath);
Text.Text := Serialize;
try
Text.SaveToFile(FFileName);
FModified := False;
Text.SaveToFile(APath);
Result := True;
except
on E: Exception do
AError := 'Не удалось сохранить проект: ' + E.Message;
AError := 'Не удалось записать ' + APath + ': ' + E.Message;
end;
finally
Text.Free;
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;
var
Text: TStringList;