vflow-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vflow/__init__.py +1 -0
- vflow/actions.py +39 -0
- vflow/backup_service.py +684 -0
- vflow/config.py +44 -0
- vflow/core/__init__.py +6 -0
- vflow/core/date_utils.py +114 -0
- vflow/core/fs_ops.py +64 -0
- vflow/core/media_ops.py +110 -0
- vflow/core/patterns.py +98 -0
- vflow/delivery_service.py +301 -0
- vflow/ingest_service.py +932 -0
- vflow/main.py +571 -0
- vflow_cli-0.1.1.dist-info/METADATA +543 -0
- vflow_cli-0.1.1.dist-info/RECORD +17 -0
- vflow_cli-0.1.1.dist-info/WHEEL +5 -0
- vflow_cli-0.1.1.dist-info/entry_points.txt +2 -0
- vflow_cli-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .core.fs_ops import copy_and_verify
|
|
10
|
+
from .core.media_ops import tag_media_file, copy_metadata_between_files
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def archive_file(
|
|
14
|
+
shoot_name: str,
|
|
15
|
+
file_name: str,
|
|
16
|
+
tags_str: str,
|
|
17
|
+
keep_log: bool,
|
|
18
|
+
work_ssd_path: Path,
|
|
19
|
+
archive_path: Path,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Tags a final rendered file, copies it to the archive Graded folder,
|
|
23
|
+
and optionally cleans up source files.
|
|
24
|
+
"""
|
|
25
|
+
export_file_path = work_ssd_path / shoot_name / "03_Exports" / file_name
|
|
26
|
+
archive_graded_dir = archive_path / "Video" / "Graded"
|
|
27
|
+
|
|
28
|
+
if not export_file_path.exists():
|
|
29
|
+
typer.echo(f"Export file not found: {export_file_path}", err=True)
|
|
30
|
+
raise typer.Exit(code=1)
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
archive_graded_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
typer.echo(
|
|
36
|
+
f"Could not create destination directories: {e}", err=True
|
|
37
|
+
)
|
|
38
|
+
raise typer.Exit(code=1)
|
|
39
|
+
|
|
40
|
+
tagged_file_path = tag_media_file(export_file_path, tags_str)
|
|
41
|
+
|
|
42
|
+
typer.echo(f"Copying tagged file to archive: {archive_graded_dir}")
|
|
43
|
+
if not copy_and_verify(tagged_file_path, archive_graded_dir):
|
|
44
|
+
typer.echo("Aborting cleanup due to copy failure.", err=True)
|
|
45
|
+
tagged_file_path.unlink()
|
|
46
|
+
raise typer.Exit(code=1)
|
|
47
|
+
|
|
48
|
+
tagged_file_path.unlink()
|
|
49
|
+
|
|
50
|
+
if not keep_log:
|
|
51
|
+
try:
|
|
52
|
+
source_folder = work_ssd_path / shoot_name / "01_Source"
|
|
53
|
+
if source_folder.exists():
|
|
54
|
+
typer.echo(
|
|
55
|
+
f"Cleaning up source files from {source_folder}..."
|
|
56
|
+
)
|
|
57
|
+
video_extensions = {
|
|
58
|
+
".mp4",
|
|
59
|
+
".mov",
|
|
60
|
+
".mxf",
|
|
61
|
+
".mts",
|
|
62
|
+
".avi",
|
|
63
|
+
".m4v",
|
|
64
|
+
".braw",
|
|
65
|
+
".r3d",
|
|
66
|
+
".crm",
|
|
67
|
+
}
|
|
68
|
+
for video_file in source_folder.iterdir():
|
|
69
|
+
if (
|
|
70
|
+
video_file.is_file()
|
|
71
|
+
and video_file.suffix.lower() in video_extensions
|
|
72
|
+
):
|
|
73
|
+
video_file.unlink()
|
|
74
|
+
typer.echo(f"Deleted: {video_file.name}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
typer.echo(
|
|
77
|
+
f"Warning: Could not clean up source files: {e}", err=True
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
typer.echo("\nArchive complete.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def copy_metadata_folder(source_folder: Path, target_folder: Path) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Copies metadata from files in source_folder to matching files in target_folder.
|
|
86
|
+
Matches by filename stem (ignoring extension).
|
|
87
|
+
"""
|
|
88
|
+
if not target_folder.exists() or not target_folder.is_dir():
|
|
89
|
+
typer.echo(f"Target folder not found: {target_folder}", err=True)
|
|
90
|
+
raise typer.Exit(code=1)
|
|
91
|
+
|
|
92
|
+
if not source_folder.exists() or not source_folder.is_dir():
|
|
93
|
+
typer.echo(f"Source folder not found: {source_folder}", err=True)
|
|
94
|
+
raise typer.Exit(code=1)
|
|
95
|
+
|
|
96
|
+
video_extensions = {
|
|
97
|
+
".mp4",
|
|
98
|
+
".mov",
|
|
99
|
+
".mxf",
|
|
100
|
+
".mts",
|
|
101
|
+
".avi",
|
|
102
|
+
".m4v",
|
|
103
|
+
".braw",
|
|
104
|
+
".r3d",
|
|
105
|
+
".crm",
|
|
106
|
+
}
|
|
107
|
+
target_files = [
|
|
108
|
+
f
|
|
109
|
+
for f in target_folder.iterdir()
|
|
110
|
+
if f.is_file() and f.suffix.lower() in video_extensions
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
if not target_files:
|
|
114
|
+
typer.echo("No files found in the target directory.")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
success_count = 0
|
|
118
|
+
fail_count = 0
|
|
119
|
+
|
|
120
|
+
with typer.progressbar(target_files, label="Processing files") as progress:
|
|
121
|
+
for target_file in progress:
|
|
122
|
+
source_files = list(source_folder.glob(f"{target_file.stem}.*"))
|
|
123
|
+
|
|
124
|
+
if not source_files:
|
|
125
|
+
typer.echo(
|
|
126
|
+
f"\nWarning: No matching source file found for '{target_file.name}'. Skipping.",
|
|
127
|
+
err=True,
|
|
128
|
+
)
|
|
129
|
+
fail_count += 1
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
source_file = source_files[0]
|
|
133
|
+
temp_output = target_file.with_name(
|
|
134
|
+
f"{target_file.stem}_temp_meta{target_file.suffix}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
ffmpeg_cmd = [
|
|
139
|
+
"ffmpeg",
|
|
140
|
+
"-i",
|
|
141
|
+
str(target_file),
|
|
142
|
+
"-i",
|
|
143
|
+
str(source_file),
|
|
144
|
+
"-map",
|
|
145
|
+
"0:v",
|
|
146
|
+
"-map",
|
|
147
|
+
"0:a",
|
|
148
|
+
"-map_metadata",
|
|
149
|
+
"1",
|
|
150
|
+
"-c:v",
|
|
151
|
+
"copy",
|
|
152
|
+
"-c:a",
|
|
153
|
+
"copy",
|
|
154
|
+
str(temp_output),
|
|
155
|
+
]
|
|
156
|
+
subprocess.run(
|
|
157
|
+
ffmpeg_cmd + ["-y"],
|
|
158
|
+
check=True,
|
|
159
|
+
capture_output=True,
|
|
160
|
+
text=True,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
shutil.move(str(temp_output), str(target_file))
|
|
164
|
+
success_count += 1
|
|
165
|
+
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
typer.echo(
|
|
168
|
+
"\nError: ffmpeg not found. Please install it and ensure it's in your PATH.",
|
|
169
|
+
err=True,
|
|
170
|
+
)
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
except subprocess.CalledProcessError as e:
|
|
173
|
+
typer.echo(
|
|
174
|
+
f"\nError processing '{target_file.name}': {e.stderr}",
|
|
175
|
+
err=True,
|
|
176
|
+
)
|
|
177
|
+
fail_count += 1
|
|
178
|
+
if temp_output.exists():
|
|
179
|
+
temp_output.unlink()
|
|
180
|
+
|
|
181
|
+
typer.echo(
|
|
182
|
+
f"\nMetadata copy complete. {success_count} files updated, {fail_count} files skipped."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def create_select_file(
|
|
187
|
+
shoot_name: str,
|
|
188
|
+
file_name: str,
|
|
189
|
+
tags_str: str,
|
|
190
|
+
work_ssd_path: Path,
|
|
191
|
+
archive_path: Path,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""
|
|
194
|
+
Tags a graded select, copies metadata from source, and distributes it to the
|
|
195
|
+
archive and the local SSD selects folder.
|
|
196
|
+
"""
|
|
197
|
+
export_file_path = work_ssd_path / shoot_name / "03_Exports" / file_name
|
|
198
|
+
source_folder = work_ssd_path / shoot_name / "01_Source"
|
|
199
|
+
archive_selects_dir = archive_path / "Video" / "Graded_Selects" / shoot_name
|
|
200
|
+
ssd_selects_dir = work_ssd_path / shoot_name / "05_Graded_Selects"
|
|
201
|
+
|
|
202
|
+
if not export_file_path.exists():
|
|
203
|
+
typer.echo(f"Export file not found: {export_file_path}", err=True)
|
|
204
|
+
raise typer.Exit(code=1)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
archive_selects_dir.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
ssd_selects_dir.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
typer.echo(
|
|
211
|
+
f"Could not create destination directories: {e}", err=True
|
|
212
|
+
)
|
|
213
|
+
raise typer.Exit(code=1)
|
|
214
|
+
|
|
215
|
+
typer.echo("Tagging file with new metadata...")
|
|
216
|
+
tagged_file_path = tag_media_file(export_file_path, tags_str)
|
|
217
|
+
|
|
218
|
+
if source_folder.exists():
|
|
219
|
+
source_files = list(source_folder.glob(f"{export_file_path.stem}.*"))
|
|
220
|
+
if source_files:
|
|
221
|
+
source_file = source_files[0]
|
|
222
|
+
typer.echo(f"Copying metadata from source file: {source_file.name}")
|
|
223
|
+
if copy_metadata_between_files(source_file, tagged_file_path):
|
|
224
|
+
typer.echo("✓ Metadata copied successfully from source file.")
|
|
225
|
+
else:
|
|
226
|
+
typer.echo(
|
|
227
|
+
"⚠ Warning: Could not copy metadata from source file. Continuing with tags only."
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
typer.echo(
|
|
231
|
+
f"⚠ No matching source file found in 01_Source for '{export_file_path.stem}'. Skipping metadata copy."
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
typer.echo(
|
|
235
|
+
f"⚠ Source folder not found: {source_folder}. Skipping metadata copy."
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
typer.echo(f"\nCopying tagged select to archive: {archive_selects_dir}")
|
|
239
|
+
archive_dest_file = archive_selects_dir / tagged_file_path.name
|
|
240
|
+
should_copy_to_archive = True
|
|
241
|
+
if archive_dest_file.exists():
|
|
242
|
+
try:
|
|
243
|
+
if (
|
|
244
|
+
tagged_file_path.stat().st_size
|
|
245
|
+
== archive_dest_file.stat().st_size
|
|
246
|
+
):
|
|
247
|
+
typer.echo(
|
|
248
|
+
"⚠ File already exists in archive (same size). Skipping archive copy."
|
|
249
|
+
)
|
|
250
|
+
should_copy_to_archive = False
|
|
251
|
+
else:
|
|
252
|
+
typer.echo(
|
|
253
|
+
"⚠ File exists in archive but with different size. Copying anyway."
|
|
254
|
+
)
|
|
255
|
+
except Exception:
|
|
256
|
+
typer.echo(
|
|
257
|
+
"⚠ Could not check size of existing file in archive. Copying anyway."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
if should_copy_to_archive:
|
|
261
|
+
if not copy_and_verify(tagged_file_path, archive_selects_dir):
|
|
262
|
+
typer.echo("Aborting due to archive copy failure.", err=True)
|
|
263
|
+
tagged_file_path.unlink()
|
|
264
|
+
raise typer.Exit(code=1)
|
|
265
|
+
else:
|
|
266
|
+
typer.echo("✓ File copied to archive.")
|
|
267
|
+
|
|
268
|
+
typer.echo(f"Copying tagged select to SSD: {ssd_selects_dir}")
|
|
269
|
+
ssd_dest_file = ssd_selects_dir / tagged_file_path.name
|
|
270
|
+
should_copy_to_ssd = True
|
|
271
|
+
if ssd_dest_file.exists():
|
|
272
|
+
try:
|
|
273
|
+
if (
|
|
274
|
+
tagged_file_path.stat().st_size
|
|
275
|
+
== ssd_dest_file.stat().st_size
|
|
276
|
+
):
|
|
277
|
+
typer.echo(
|
|
278
|
+
"⚠ File already exists in SSD selects folder (same size). Skipping SSD copy."
|
|
279
|
+
)
|
|
280
|
+
should_copy_to_ssd = False
|
|
281
|
+
else:
|
|
282
|
+
typer.echo(
|
|
283
|
+
"⚠ File exists in SSD selects but with different size. Copying anyway."
|
|
284
|
+
)
|
|
285
|
+
except Exception:
|
|
286
|
+
typer.echo(
|
|
287
|
+
"⚠ Could not check size of existing file on SSD. Copying anyway."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if should_copy_to_ssd:
|
|
291
|
+
if not copy_and_verify(tagged_file_path, ssd_selects_dir):
|
|
292
|
+
typer.echo(
|
|
293
|
+
"Warning: Could not copy select to SSD. It is safely in the archive.",
|
|
294
|
+
err=True,
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
typer.echo("✓ File copied to SSD selects folder.")
|
|
298
|
+
|
|
299
|
+
tagged_file_path.unlink()
|
|
300
|
+
|
|
301
|
+
typer.echo("\n✓ Create select complete.")
|