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:
@@ -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
|
||||
|
||||
232
core/database.py
232
core/database.py
@@ -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]
|
||||
|
||||
132
core/manager.py
132
core/manager.py
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
1270
ui/main_window.py
1270
ui/main_window.py
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user