Add split/merge sequence feature with fid-based folder tracking

Refactor folder settings from path-keyed to fid-keyed dicts so the same
physical folder can appear multiple times with independent trim, type,
and transition settings.  This enables splitting a MAIN folder into two
sub-sequences at an arbitrary frame boundary using complementary trim
ranges, and merging them back.

- Add split from file list context menu ("Split Sequence After This
  Frame") and source list context menu ("Split Sequence..." dialog)
- Add "Merge with Next" to undo splits on adjacent same-path entries
- Sub-sequences share the base sequence number (seq01-1, seq01-2) with
  continuous file indices so subsequent sequences are not renumbered
- Session save/restore handles duplicate paths via folder_order; restore
  falls back to _refresh_files when split entries are detected
- Export copy_matches now compares file contents when size matches but
  mtime differs, preventing false negatives on re-export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:27:13 +01:00
parent 6e2d6148af
commit 2694a8cba3
6 changed files with 1267 additions and 430 deletions

View File

@@ -1356,21 +1356,19 @@ class TransitionGenerator:
def get_folder_type(
self,
index: int,
overrides: Optional[dict[Path, FolderType]] = None,
folder: Optional[Path] = None
overrides: Optional[dict[int, FolderType]] = None,
) -> FolderType:
"""Determine folder type based on position or override.
Args:
index: 0-based position of folder in list.
overrides: Optional dict of folder path to FolderType overrides.
folder: The folder path for checking overrides.
overrides: Optional dict of position index to FolderType overrides.
Returns:
FolderType.MAIN for odd positions (1, 3, 5...), TRANSITION for even.
FolderType.MAIN for even positions (0, 2, 4...), TRANSITION for odd.
"""
if overrides and folder and folder in overrides:
override = overrides[folder]
if overrides and index in overrides:
override = overrides[index]
if override != FolderType.AUTO:
return override
@@ -1380,9 +1378,9 @@ class TransitionGenerator:
def identify_transition_boundaries(
self,
folders: list[Path],
files_by_folder: dict[Path, list[str]],
folder_overrides: Optional[dict[Path, FolderType]] = None,
per_transition_settings: Optional[dict[Path, PerTransitionSettings]] = None
files_by_idx: dict[int, list[str]],
folder_overrides: Optional[dict[int, FolderType]] = None,
per_transition_settings: Optional[dict[int, PerTransitionSettings]] = None
) -> list[TransitionSpec]:
"""Identify boundaries where transitions should occur.
@@ -1391,9 +1389,9 @@ class TransitionGenerator:
Args:
folders: List of folders in order.
files_by_folder: Dict mapping folders to their file lists.
folder_overrides: Optional folder type overrides.
per_transition_settings: Optional per-transition overlap settings.
files_by_idx: Dict mapping position index to file lists.
folder_overrides: Optional position-index-keyed folder type overrides.
per_transition_settings: Optional position-index-keyed per-transition overlap settings.
Returns:
List of TransitionSpec objects describing each transition.
@@ -1403,33 +1401,33 @@ class TransitionGenerator:
transitions = []
cumulative_idx = 0
folder_start_indices = {}
folder_start_indices: dict[int, int] = {}
# Calculate start indices for each folder
for folder in folders:
folder_start_indices[folder] = cumulative_idx
cumulative_idx += len(files_by_folder.get(folder, []))
# Calculate start indices for each folder position
for i in range(len(folders)):
folder_start_indices[i] = cumulative_idx
cumulative_idx += len(files_by_idx.get(i, []))
# Look for transition boundaries (MAIN->TRANSITION and TRANSITION->MAIN)
for i in range(len(folders) - 1):
folder_a = folders[i]
folder_b = folders[i + 1]
type_a = self.get_folder_type(i, folder_overrides, folder_a)
type_b = self.get_folder_type(i + 1, folder_overrides, folder_b)
type_a = self.get_folder_type(i, folder_overrides)
type_b = self.get_folder_type(i + 1, folder_overrides)
# Create transition when types differ (MAIN->TRANS or TRANS->MAIN)
if type_a != type_b:
files_a = files_by_folder.get(folder_a, [])
files_b = files_by_folder.get(folder_b, [])
files_a = files_by_idx.get(i, [])
files_b = files_by_idx.get(i + 1, [])
if not files_a or not files_b:
continue
# Get per-transition overlap settings if available
# Use folder_b as the key (the "incoming" folder)
if per_transition_settings and folder_b in per_transition_settings:
pts = per_transition_settings[folder_b]
# Use i+1 as the key (the "incoming" folder position)
if per_transition_settings and (i + 1) in per_transition_settings:
pts = per_transition_settings[i + 1]
left_overlap = pts.left_overlap
right_overlap = pts.right_overlap
else:
@@ -1451,8 +1449,10 @@ class TransitionGenerator:
trans_files=files_b,
left_overlap=left_overlap,
right_overlap=right_overlap,
main_start_idx=folder_start_indices[folder_a],
trans_start_idx=folder_start_indices[folder_b]
main_start_idx=folder_start_indices[i],
trans_start_idx=folder_start_indices[i + 1],
main_folder_idx=i,
trans_folder_idx=i + 1,
))
return transitions

View File

@@ -39,7 +39,8 @@ class DatabaseManager:
CREATE TABLE IF NOT EXISTS symlink_sessions (
id INTEGER PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
destination TEXT NOT NULL
destination TEXT NOT NULL,
name TEXT DEFAULT NULL
);
CREATE TABLE IF NOT EXISTS symlinks (
@@ -138,9 +139,132 @@ class DatabaseManager:
except sqlite3.OperationalError:
conn.execute("ALTER TABLE sequence_trim_settings ADD COLUMN folder_order INTEGER DEFAULT 0")
# Migration: add name column to symlink_sessions if it doesn't exist
try:
conn.execute("SELECT name FROM symlink_sessions LIMIT 1")
except sqlite3.OperationalError:
conn.execute("ALTER TABLE symlink_sessions ADD COLUMN name TEXT DEFAULT NULL")
# Migration: widen UNIQUE constraints to allow duplicate folder paths per session.
# sequence_trim_settings: UNIQUE(session_id, source_folder) → UNIQUE(session_id, folder_order)
self._migrate_unique_constraint(
conn, 'sequence_trim_settings',
"""CREATE TABLE sequence_trim_settings_new (
id INTEGER PRIMARY KEY,
session_id INTEGER REFERENCES symlink_sessions(id) ON DELETE CASCADE,
source_folder TEXT NOT NULL,
trim_start INTEGER DEFAULT 0,
trim_end INTEGER DEFAULT 0,
folder_type TEXT DEFAULT 'auto',
folder_order INTEGER DEFAULT 0,
UNIQUE(session_id, folder_order)
)""",
'session_id, source_folder, trim_start, trim_end, folder_type, folder_order',
)
# per_transition_settings: add folder_order, widen UNIQUE
try:
conn.execute("SELECT folder_order FROM per_transition_settings LIMIT 1")
except sqlite3.OperationalError:
conn.execute("ALTER TABLE per_transition_settings ADD COLUMN folder_order INTEGER DEFAULT 0")
self._migrate_unique_constraint(
conn, 'per_transition_settings',
"""CREATE TABLE per_transition_settings_new (
id INTEGER PRIMARY KEY,
session_id INTEGER REFERENCES symlink_sessions(id) ON DELETE CASCADE,
trans_folder TEXT NOT NULL,
left_overlap INTEGER DEFAULT 16,
right_overlap INTEGER DEFAULT 16,
folder_order INTEGER DEFAULT 0,
UNIQUE(session_id, trans_folder, folder_order)
)""",
'session_id, trans_folder, left_overlap, right_overlap, folder_order',
)
# removed_files: add folder_order, widen UNIQUE
try:
conn.execute("SELECT folder_order FROM removed_files LIMIT 1")
except sqlite3.OperationalError:
conn.execute("ALTER TABLE removed_files ADD COLUMN folder_order INTEGER DEFAULT 0")
self._migrate_unique_constraint(
conn, 'removed_files',
"""CREATE TABLE removed_files_new (
id INTEGER PRIMARY KEY,
session_id INTEGER REFERENCES symlink_sessions(id) ON DELETE CASCADE,
source_folder TEXT NOT NULL,
filename TEXT NOT NULL,
folder_order INTEGER DEFAULT 0,
UNIQUE(session_id, source_folder, filename, folder_order)
)""",
'session_id, source_folder, filename, folder_order',
)
# direct_transition_settings: add folder_order, widen UNIQUE
try:
conn.execute("SELECT folder_order FROM direct_transition_settings LIMIT 1")
except sqlite3.OperationalError:
conn.execute("ALTER TABLE direct_transition_settings ADD COLUMN folder_order INTEGER DEFAULT 0")
self._migrate_unique_constraint(
conn, 'direct_transition_settings',
"""CREATE TABLE direct_transition_settings_new (
id INTEGER PRIMARY KEY,
session_id INTEGER REFERENCES symlink_sessions(id) ON DELETE CASCADE,
after_folder TEXT NOT NULL,
frame_count INTEGER DEFAULT 16,
method TEXT DEFAULT 'film',
enabled INTEGER DEFAULT 1,
folder_order INTEGER DEFAULT 0,
UNIQUE(session_id, after_folder, folder_order)
)""",
'session_id, after_folder, frame_count, method, enabled, folder_order',
)
# Migration: remove overlap_frames from transition_settings (now per-transition)
# We'll keep it for backward compatibility but won't use it
@staticmethod
def _migrate_unique_constraint(
conn: sqlite3.Connection,
table: str,
create_new_sql: str,
columns: str,
) -> None:
"""Recreate a table with a new UNIQUE constraint if needed.
Tests whether duplicate folder_order=0 entries can be inserted.
If an IntegrityError fires, the old constraint is too narrow and
the table must be recreated.
"""
new_table = f"{table}_new"
try:
# Test: can we insert two rows with same session+folder but different folder_order?
# If the old UNIQUE is still (session_id, source_folder) this will fail.
conn.execute(f"INSERT INTO {table} (session_id, {columns.split(',')[1].strip()}, folder_order) VALUES (-999, '__test__', 1)")
conn.execute(f"INSERT INTO {table} (session_id, {columns.split(',')[1].strip()}, folder_order) VALUES (-999, '__test__', 2)")
# Clean up test rows
conn.execute(f"DELETE FROM {table} WHERE session_id = -999")
# If we got here, the constraint already allows duplicates — no migration needed
return
except sqlite3.IntegrityError:
# Old constraint is too narrow — need to recreate
conn.execute(f"DELETE FROM {table} WHERE session_id = -999")
except sqlite3.OperationalError:
# Column might not exist yet or other issue — try migration anyway
conn.execute(f"DELETE FROM {table} WHERE session_id = -999")
try:
conn.execute(f"DROP TABLE IF EXISTS {new_table}")
conn.execute(create_new_sql)
conn.execute(f"INSERT INTO {new_table} ({columns}) SELECT {columns} FROM {table}")
conn.execute(f"DROP TABLE {table}")
conn.execute(f"ALTER TABLE {new_table} RENAME TO {table}")
except (sqlite3.OperationalError, sqlite3.IntegrityError):
# Clean up failed migration attempt
try:
conn.execute(f"DROP TABLE IF EXISTS {new_table}")
except sqlite3.OperationalError:
pass
def clear_session_data(self, session_id: int) -> None:
"""Delete all data for a session (symlinks, settings, etc.) but keep the session row."""
try:
@@ -159,11 +283,12 @@ class DatabaseManager:
conn.execute("PRAGMA foreign_keys = ON")
return conn
def create_session(self, destination: str) -> int:
def create_session(self, destination: str, name: Optional[str] = None) -> int:
"""Create a new linking session.
Args:
destination: The destination directory path.
name: Optional display name (e.g. "autosave").
Returns:
The ID of the created session.
@@ -174,8 +299,8 @@ class DatabaseManager:
try:
with self._connect() as conn:
cursor = conn.execute(
"INSERT INTO symlink_sessions (destination) VALUES (?)",
(destination,)
"INSERT INTO symlink_sessions (destination, name) VALUES (?, ?)",
(destination, name)
)
return cursor.lastrowid
except sqlite3.Error as e:
@@ -249,7 +374,7 @@ class DatabaseManager:
"""
with self._connect() as conn:
rows = conn.execute("""
SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count
SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count, s.name
FROM symlink_sessions s
LEFT JOIN symlinks l ON s.id = l.session_id
GROUP BY s.id
@@ -261,7 +386,8 @@ class DatabaseManager:
id=row[0],
created_at=datetime.fromisoformat(row[1]),
destination=row[2],
link_count=row[3]
link_count=row[3],
name=row[4]
)
for row in rows
]
@@ -377,7 +503,7 @@ class DatabaseManager:
"""
with self._connect() as conn:
rows = conn.execute("""
SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count
SELECT s.id, s.created_at, s.destination, COUNT(l.id) as link_count, s.name
FROM symlink_sessions s
LEFT JOIN symlinks l ON s.id = l.session_id
WHERE s.destination = ?
@@ -390,7 +516,8 @@ class DatabaseManager:
id=row[0],
created_at=datetime.fromisoformat(row[1]),
destination=row[2],
link_count=row[3]
link_count=row[3],
name=row[4]
)
for row in rows
]
@@ -423,11 +550,11 @@ class DatabaseManager:
"""INSERT INTO sequence_trim_settings
(session_id, source_folder, trim_start, trim_end, folder_type, folder_order)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, source_folder)
DO UPDATE SET trim_start=excluded.trim_start,
ON CONFLICT(session_id, folder_order)
DO UPDATE SET source_folder=excluded.source_folder,
trim_start=excluded.trim_start,
trim_end=excluded.trim_end,
folder_type=excluded.folder_type,
folder_order=excluded.folder_order""",
folder_type=excluded.folder_type""",
(session_id, source_folder, trim_start, trim_end, folder_type.value, folder_order)
)
except sqlite3.Error as e:
@@ -672,13 +799,15 @@ class DatabaseManager:
def save_per_transition_settings(
self,
session_id: int,
settings: PerTransitionSettings
settings: PerTransitionSettings,
folder_order: int = 0,
) -> None:
"""Save per-transition overlap settings.
Args:
session_id: The session ID.
settings: PerTransitionSettings to save.
folder_order: Position of this folder in the source list.
Raises:
DatabaseError: If saving fails.
@@ -687,13 +816,14 @@ class DatabaseManager:
with self._connect() as conn:
conn.execute(
"""INSERT INTO per_transition_settings
(session_id, trans_folder, left_overlap, right_overlap)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id, trans_folder)
DO UPDATE SET left_overlap=excluded.left_overlap,
(session_id, trans_folder, left_overlap, right_overlap, folder_order)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, folder_order)
DO UPDATE SET trans_folder=excluded.trans_folder,
left_overlap=excluded.left_overlap,
right_overlap=excluded.right_overlap""",
(session_id, str(settings.trans_folder),
settings.left_overlap, settings.right_overlap)
settings.left_overlap, settings.right_overlap, folder_order)
)
except sqlite3.Error as e:
raise DatabaseError(f"Failed to save per-transition settings: {e}") from e
@@ -730,36 +860,31 @@ class DatabaseManager:
def get_all_per_transition_settings(
self,
session_id: int
) -> dict[str, PerTransitionSettings]:
) -> list[tuple[str, int, int, int]]:
"""Get all per-transition settings for a session.
Args:
session_id: The session ID.
Returns:
Dict mapping transition folder paths to PerTransitionSettings.
List of (trans_folder, left_overlap, right_overlap, folder_order) tuples.
"""
with self._connect() as conn:
rows = conn.execute(
"""SELECT trans_folder, left_overlap, right_overlap
FROM per_transition_settings WHERE session_id = ?""",
"""SELECT trans_folder, left_overlap, right_overlap, folder_order
FROM per_transition_settings WHERE session_id = ?
ORDER BY folder_order""",
(session_id,)
).fetchall()
return {
row[0]: PerTransitionSettings(
trans_folder=Path(row[0]),
left_overlap=row[1],
right_overlap=row[2]
)
for row in rows
}
return [(row[0], row[1], row[2], row[3]) for row in rows]
def save_removed_files(
self,
session_id: int,
source_folder: str,
filenames: list[str]
filenames: list[str],
folder_order: int = 0,
) -> None:
"""Save removed files for a folder in a session.
@@ -767,39 +892,40 @@ class DatabaseManager:
session_id: The session ID.
source_folder: Path to the source folder.
filenames: List of removed filenames.
folder_order: Position of this folder in the source list.
"""
try:
with self._connect() as conn:
for filename in filenames:
conn.execute(
"""INSERT OR IGNORE INTO removed_files
(session_id, source_folder, filename)
VALUES (?, ?, ?)""",
(session_id, source_folder, filename)
(session_id, source_folder, filename, folder_order)
VALUES (?, ?, ?, ?)""",
(session_id, source_folder, filename, folder_order)
)
except sqlite3.Error as e:
raise DatabaseError(f"Failed to save removed files: {e}") from e
def get_removed_files(self, session_id: int) -> dict[str, set[str]]:
"""Get all removed files for a session.
def get_removed_files(self, session_id: int) -> dict[int, set[str]]:
"""Get all removed files for a session, keyed by folder_order.
Args:
session_id: The session ID.
Returns:
Dict mapping source folder paths to sets of removed filenames.
Dict mapping folder_order to sets of removed filenames.
"""
with self._connect() as conn:
rows = conn.execute(
"SELECT source_folder, filename FROM removed_files WHERE session_id = ?",
"SELECT source_folder, filename, folder_order FROM removed_files WHERE session_id = ?",
(session_id,)
).fetchall()
result: dict[str, set[str]] = {}
for folder, filename in rows:
if folder not in result:
result[folder] = set()
result[folder].add(filename)
result: dict[int, set[str]] = {}
for folder, filename, folder_order in rows:
if folder_order not in result:
result[folder_order] = set()
result[folder_order].add(filename)
return result
def save_direct_transition(
@@ -809,33 +935,35 @@ class DatabaseManager:
frame_count: int,
method: str,
enabled: bool,
folder_order: int = 0,
) -> None:
"""Save direct interpolation settings for a folder transition."""
try:
with self._connect() as conn:
conn.execute(
"""INSERT INTO direct_transition_settings
(session_id, after_folder, frame_count, method, enabled)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(session_id, after_folder)
DO UPDATE SET frame_count=excluded.frame_count,
(session_id, after_folder, frame_count, method, enabled, folder_order)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, folder_order)
DO UPDATE SET after_folder=excluded.after_folder,
frame_count=excluded.frame_count,
method=excluded.method,
enabled=excluded.enabled""",
(session_id, after_folder, frame_count, method, 1 if enabled else 0)
(session_id, after_folder, frame_count, method, 1 if enabled else 0, folder_order)
)
except sqlite3.Error as e:
raise DatabaseError(f"Failed to save direct transition: {e}") from e
def get_direct_transitions(self, session_id: int) -> list[tuple[str, int, str, bool]]:
def get_direct_transitions(self, session_id: int) -> list[tuple[str, int, str, bool, int]]:
"""Get direct interpolation settings for a session.
Returns:
List of (after_folder, frame_count, method, enabled) tuples.
List of (after_folder, frame_count, method, enabled, folder_order) tuples.
"""
with self._connect() as conn:
rows = conn.execute(
"SELECT after_folder, frame_count, method, enabled "
"SELECT after_folder, frame_count, method, enabled, folder_order "
"FROM direct_transition_settings WHERE session_id = ?",
(session_id,)
).fetchall()
return [(r[0], r[1], r[2], bool(r[3])) for r in rows]
return [(r[0], r[1], r[2], bool(r[3]), r[4]) for r in rows]

View File

@@ -122,6 +122,100 @@ class SymlinkManager:
return removed
@staticmethod
def remove_orphan_files(directory: Path, keep_names: set[str]) -> int:
"""Remove seq* files and film_temp_* not in the keep set.
Same pattern matching as cleanup_old_links but skips filenames
present in keep_names.
Args:
directory: Directory to clean orphans from.
keep_names: Set of filenames to keep.
Returns:
Number of files removed.
Raises:
CleanupError: If removal fails.
"""
removed = 0
seq_pattern = re.compile(
r'^seq\d*_\d+\.(png|jpg|jpeg|webp)$', re.IGNORECASE
)
temp_pattern = re.compile(
r'^film_temp_\d+\.png$', re.IGNORECASE
)
try:
for item in directory.iterdir():
if item.name in keep_names:
continue
should_remove = False
if item.name.startswith("seq"):
if item.is_symlink():
should_remove = True
elif item.is_file() and seq_pattern.match(item.name):
should_remove = True
elif item.is_file() and temp_pattern.match(item.name):
should_remove = True
if should_remove:
item.unlink()
removed += 1
except OSError as e:
raise CleanupError(f"Failed to remove orphan files: {e}") from e
return removed
@staticmethod
def symlink_matches(link_path: Path, expected_source: Path) -> bool:
"""Check if existing symlink resolves to expected source."""
if not link_path.is_symlink():
return False
try:
return link_path.resolve() == expected_source.resolve()
except OSError:
return False
@staticmethod
def copy_matches(dest_path: Path, source_path: Path) -> bool:
"""Check if existing copy matches source.
Fast path: size + mtime comparison. If sizes match but mtimes
differ, falls back to comparing file contents so that a
re-export after touching (but not changing) the source is still
skipped, while a genuine content change is caught.
"""
if not dest_path.is_file() or dest_path.is_symlink():
return False
try:
src_stat = source_path.stat()
dst_stat = dest_path.stat()
if src_stat.st_size != dst_stat.st_size:
return False
# Fast path: identical mtime means the copy2 wrote this file
if abs(src_stat.st_mtime - dst_stat.st_mtime) < 2.0:
return True
# Size matches but mtime differs — compare contents
return SymlinkManager._files_equal(source_path, dest_path)
except OSError:
return False
@staticmethod
def _files_equal(a: Path, b: Path, chunk_size: int = 65536) -> bool:
"""Compare two files by reading in chunks."""
try:
with open(a, 'rb') as fa, open(b, 'rb') as fb:
while True:
ca = fa.read(chunk_size)
cb = fb.read(chunk_size)
if ca != cb:
return False
if not ca:
return True
except OSError:
return False
def create_sequence_links(
self,
sources: list[Path],
@@ -145,7 +239,6 @@ class SymlinkManager:
Tuple of (list of LinkResult objects, session_id or None).
"""
self.validate_paths(sources, dest)
self.cleanup_old_links(dest)
session_id = None
if self.db:
@@ -176,6 +269,13 @@ class SymlinkManager:
expanded_files.append((source_dir, filename, folder_idx, file_idx))
files = expanded_files
# Build planned names for orphan removal
planned_names: set[str] = set()
for file_data in files:
_, fn, fi, fli = file_data
ext = Path(fn).suffix
planned_names.add(f"seq{fi + 1:02d}_{fli:04d}{ext}")
for i, file_data in enumerate(files):
source_dir, filename, folder_idx, file_idx = file_data
source_path = source_dir / filename
@@ -184,12 +284,24 @@ class SymlinkManager:
link_path = dest / link_name
try:
if copy_files:
import shutil
shutil.copy2(source_path, link_path)
else:
rel_source = Path(os.path.relpath(source_path.resolve(), dest.resolve()))
link_path.symlink_to(rel_source)
# Check if existing file already matches
already_correct = False
if link_path.exists() or link_path.is_symlink():
if copy_files:
already_correct = self.copy_matches(link_path, source_path)
else:
already_correct = self.symlink_matches(link_path, source_path)
if not already_correct:
if link_path.exists() or link_path.is_symlink():
link_path.unlink()
if copy_files:
import shutil
shutil.copy2(source_path, link_path)
else:
rel_source = Path(os.path.relpath(source_path.resolve(), dest.resolve()))
link_path.symlink_to(rel_source)
if self.db and session_id:
self.db.record_symlink(
@@ -215,4 +327,10 @@ class SymlinkManager:
error=str(e)
))
# Remove orphan seq*/film_temp_* files not in the planned set
try:
self.remove_orphan_files(dest, planned_names)
except CleanupError:
pass
return results, session_id

View File

@@ -103,6 +103,7 @@ VIDEO_PRESETS: dict[str, VideoPreset] = {
'fast_preview': VideoPreset('Fast Preview', 'mp4', 'libx264', 28, preset='ultrafast'),
'webm_vp9': VideoPreset('WebM VP9', 'webm', 'libvpx-vp9', 30, extra_args=['-b:v', '0']),
'webm_av1': VideoPreset('WebM AV1', 'webm', 'libaom-av1', 30, extra_args=['-b:v', '0', '-strict', 'experimental']),
'godot_theora': VideoPreset('Godot (Theora)', 'ogv', 'libtheora', 8, extra_args=['-g', '512']),
}
@@ -129,6 +130,9 @@ class TransitionSpec:
# Indices into the overall file list
main_start_idx: int
trans_start_idx: int
# Position indices in the folders list (for duplicate folder support)
main_folder_idx: int = 0
trans_folder_idx: int = 0
@dataclass
@@ -160,6 +164,7 @@ class SessionRecord:
created_at: datetime
destination: str
link_count: int = 0
name: Optional[str] = None
# --- Exceptions ---

View File

@@ -65,7 +65,7 @@ def encode_image_sequence(
'-framerate', str(fps),
'-i', str(input_dir / input_pattern),
'-c:v', preset.codec,
'-crf', str(preset.crf),
'-q:v' if preset.codec == 'libtheora' else '-crf', str(preset.crf),
'-pix_fmt', preset.pixel_format,
]
@@ -198,7 +198,7 @@ def encode_from_file_list(
'-f', 'concat', '-safe', '0',
'-i', str(concat_path),
'-c:v', preset.codec,
'-crf', str(preset.crf),
'-q:v' if preset.codec == 'libtheora' else '-crf', str(preset.crf),
'-pix_fmt', preset.pixel_format,
]

File diff suppressed because it is too large Load Diff