vsfgs 0.7.0__py3-none-win_amd64.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.
@@ -0,0 +1,9 @@
1
+ prefix=c:/
2
+ includedir=${prefix}/include
3
+ libdir=${prefix}/lib
4
+
5
+ Name: libdav1d
6
+ Description: AV1 decoding library
7
+ Version: 1.5.4
8
+ Libs: -L${libdir} -ldav1d
9
+ Cflags: -I${includedir}
Binary file
Binary file
vsfgs/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .core import apply_fgs
2
+ from .source import load_av1fgs_video
3
+
4
+ __all__ = ["apply_fgs", "load_av1fgs_video"]
vsfgs/core.py ADDED
@@ -0,0 +1,299 @@
1
+ import typing
2
+ import bisect
3
+ import vapoursynth as vs
4
+ from .parser import Dav1dFilmGrainData, _parse_binary_fgs
5
+
6
+ core = vs.core
7
+
8
+ def apply_fgs(
9
+ clip: vs.VideoNode,
10
+ fgs_file_path: str,
11
+ ignore_chroma: bool = False,
12
+ static: bool = False,
13
+ seed_list: int = 0,
14
+ simd: str | int = "auto",
15
+ temporal_average: float | tuple[float, int] | None = None,
16
+ temporal_avg_func: typing.Callable[[vs.VideoNode], vs.VideoNode] | None = None,
17
+ ignore_length: bool = False,
18
+ ) -> vs.VideoNode:
19
+ """
20
+ Applies Film Grain Synthesis (FGS) to a video clip using dav1d's FGS engine implementation.
21
+
22
+ This function parses a standard FGS text file format and applies the film grain to the input clip.
23
+
24
+ The FGS engine strictly operates on 8-bit, 10-bit, or 12-bit YUV data (4:2:0, 4:2:2, or 4:4:4).
25
+ If the input clip has an unsupported bit depth or is float, it will be temporarily converted to 12-bit (without dithering).
26
+ For high bit depth clips (> 12-bit integer or float), the generated grain is isolated via `MakeDiff` and applied back to the pristine original clip using `MergeDiff`. This guarantees that the original video's precision is perfectly preserved.
27
+
28
+ Args:
29
+ clip: The input VapourSynth clip.
30
+ fgs_file_path: The path to the text file containing the FGS parameters.
31
+ ignore_chroma: If True, grain is only applied to Luma (Chroma is copied). Default: False.
32
+ static: If True, the seed from the FGS table is used for all frames. If False, the seed rotates using a curated list for each frame. Default: False.
33
+ simd: SIMD instruction set to use ('auto', 'avx512', 'avx2', 'sse41', 'sse3', 'sse2', 'none'/'c', or an integer bitmask). Default: 'auto'.
34
+ temporal_average: Temporal grain smoothing weight or (weight, radius). Extracts the grain and blends it with a temporally averaged version of itself. Default: None.
35
+ temporal_avg_func: Custom function to use for temporal averaging instead of the default `core.std.AverageFrames`. Default: None.
36
+ ignore_length: If True and there is only a single FGS event, ignores its length and applies it to the entire video. Default: False.
37
+
38
+ Returns:
39
+ vs.VideoNode: The clip with film grain applied (always returned in the same bit depth as the input).
40
+ """
41
+ if not hasattr(core, "fgs") or not hasattr(core.fgs, "FGS"):
42
+ raise RuntimeError("vs_fgs plugin is not loaded correctly")
43
+
44
+ if clip.format.color_family != vs.YUV:
45
+ raise ValueError(
46
+ "vsfgs: only YUV color family is supported. Please convert your clip to YUV first."
47
+ )
48
+
49
+ original_clip = clip
50
+ original_format = clip.format
51
+
52
+ if (
53
+ original_format.bits_per_sample not in (8, 10, 12)
54
+ or original_format.sample_type != vs.INTEGER
55
+ ):
56
+ target_bits = 12
57
+ target_fmt = original_format.replace(
58
+ bits_per_sample=target_bits, sample_type=vs.INTEGER
59
+ ).id
60
+
61
+ if (
62
+ original_format.bits_per_sample > 12
63
+ or original_format.sample_type == vs.FLOAT
64
+ ):
65
+ clip = clip.resize.Bicubic(format=target_fmt, dither_type="none")
66
+ else:
67
+ clip = clip.resize.Bicubic(format=target_fmt, dither_type="none")
68
+
69
+ if fgs_file_path.lower().endswith(".bin"):
70
+ fgs_bytes = _parse_binary_fgs(fgs_file_path, clip.num_frames)
71
+ else:
72
+ blocks = []
73
+ current_block = None
74
+ bare_lines = []
75
+
76
+ with open(fgs_file_path, "r") as f:
77
+ for line in f:
78
+ parts = line.strip().split()
79
+ if not parts:
80
+ continue
81
+
82
+ if parts[0] == "E":
83
+ start_t = int(parts[1])
84
+ end_t = int(parts[2])
85
+ if start_t == 0 and end_t == 0:
86
+ end_t = float("inf")
87
+
88
+ current_block = {
89
+ "lines": [parts],
90
+ "start_time": start_t,
91
+ "end_time": end_t,
92
+ }
93
+ blocks.append(current_block)
94
+ elif current_block is not None:
95
+ current_block["lines"].append(parts)
96
+ elif parts[0] not in ("filmgrn1",):
97
+ bare_lines.append(parts)
98
+
99
+ if not blocks:
100
+ blocks.append(
101
+ {"lines": bare_lines, "start_time": 0, "end_time": float("inf")}
102
+ )
103
+
104
+ parsed_blocks = []
105
+ required_tokens = {"p", "sY", "sCb", "sCr", "cY", "cCb", "cCr"}
106
+
107
+ for b_idx, b in enumerate(blocks):
108
+ data = Dav1dFilmGrainData()
109
+ found_tokens = set()
110
+
111
+ try:
112
+ for parts in b["lines"]:
113
+ token = parts[0]
114
+ found_tokens.add(token)
115
+ if token == "E":
116
+ data.seed = int(parts[4])
117
+ elif token == "p":
118
+ data.ar_coeff_lag = int(parts[1])
119
+ data.ar_coeff_shift = int(parts[2])
120
+ data.grain_scale_shift = int(parts[3])
121
+ data.scaling_shift = int(parts[4])
122
+ data.chroma_scaling_from_luma = int(parts[5])
123
+ data.overlap_flag = int(parts[6])
124
+ data.uv_mult[0] = int(parts[7]) - 128
125
+ data.uv_luma_mult[0] = int(parts[8]) - 128
126
+ data.uv_offset[0] = int(parts[9]) - 256
127
+ data.uv_mult[1] = int(parts[10]) - 128
128
+ data.uv_luma_mult[1] = int(parts[11]) - 128
129
+ data.uv_offset[1] = int(parts[12]) - 256
130
+ elif token == "sY":
131
+ data.num_y_points = int(parts[1])
132
+ for i in range(data.num_y_points):
133
+ data.y_points[i][0] = int(parts[2 + i * 2])
134
+ data.y_points[i][1] = int(parts[3 + i * 2])
135
+ elif token == "sCb":
136
+ data.num_uv_points[0] = int(parts[1])
137
+ for i in range(data.num_uv_points[0]):
138
+ data.uv_points[0][i][0] = int(parts[2 + i * 2])
139
+ data.uv_points[0][i][1] = int(parts[3 + i * 2])
140
+ elif token == "sCr":
141
+ data.num_uv_points[1] = int(parts[1])
142
+ for i in range(data.num_uv_points[1]):
143
+ data.uv_points[1][i][0] = int(parts[2 + i * 2])
144
+ data.uv_points[1][i][1] = int(parts[3 + i * 2])
145
+ elif token == "cY":
146
+ for i in range(len(parts) - 1):
147
+ data.ar_coeffs_y[i] = int(parts[1 + i])
148
+ elif token == "cCb":
149
+ for i in range(len(parts) - 1):
150
+ data.ar_coeffs_uv[0][i] = int(parts[1 + i])
151
+ elif token == "cCr":
152
+ for i in range(len(parts) - 1):
153
+ data.ar_coeffs_uv[1][i] = int(parts[1 + i])
154
+ except (IndexError, ValueError) as e:
155
+ raise ValueError(
156
+ f"vsfgs: malformed parameters in FGS file at Event {b_idx + 1}, line: {' '.join(parts)}"
157
+ ) from e
158
+
159
+ missing_tokens = required_tokens - found_tokens
160
+ if missing_tokens:
161
+ raise ValueError(
162
+ f"vsfgs: missing required FGS fields {missing_tokens} in Event {b_idx + 1}"
163
+ )
164
+
165
+ data.clip_to_restricted_range = (
166
+ 1 # Could be exposed in the signature in the future
167
+ )
168
+
169
+ parsed_blocks.append(
170
+ {
171
+ "data_bytes": bytes(data),
172
+ "start_time": b["start_time"],
173
+ "end_time": b["end_time"],
174
+ }
175
+ )
176
+
177
+ fps_num = clip.fps.numerator
178
+ fps_den = clip.fps.denominator
179
+ if fps_num == 0:
180
+ raise ValueError(
181
+ "vs_fgs requires a constant framerate clip for dynamic FGS parsing. Please set clip fps."
182
+ )
183
+
184
+ timebase_scale = 10000000.0
185
+ fgs_structs_bytes = bytearray()
186
+ zero_data = bytes(Dav1dFilmGrainData())
187
+
188
+ start_times = [b["start_time"] for b in parsed_blocks]
189
+
190
+ for n in range(clip.num_frames):
191
+ frame_time_exact = (n * fps_den * timebase_scale) / fps_num
192
+ frame_time = int(round(frame_time_exact))
193
+
194
+ # 10% of a frame duration as safety margin for floating point / timestamp drift
195
+ tolerance = ((fps_den * timebase_scale) / fps_num) * 0.1
196
+
197
+ if ignore_length and len(parsed_blocks) == 1:
198
+ active_bytes = parsed_blocks[0]["data_bytes"]
199
+ else:
200
+ idx = bisect.bisect_right(start_times, frame_time) - 1
201
+ if (
202
+ idx >= 0
203
+ and frame_time <= parsed_blocks[idx]["end_time"] + tolerance
204
+ ):
205
+ active_bytes = parsed_blocks[idx]["data_bytes"]
206
+ else:
207
+ active_bytes = zero_data
208
+
209
+ fgs_structs_bytes.extend(active_bytes)
210
+
211
+ fgs_bytes = bytes(fgs_structs_bytes)
212
+
213
+ # For binary FGS files, seeds and parameters are pre-baked per-frame.
214
+ is_binary = fgs_file_path.lower().endswith(".bin")
215
+ dynamic_seed = 0 if (static or is_binary) else 1
216
+
217
+ simd_mask = -1
218
+ if isinstance(simd, str):
219
+ s = simd.strip().lower()
220
+ if s == "auto":
221
+ simd_mask = -1
222
+ elif s in ("none", "c"):
223
+ simd_mask = 0
224
+ elif s == "sse2":
225
+ simd_mask = 1 << 0
226
+ elif s in ("ssse3", "sse3"):
227
+ simd_mask = (1 << 0) | (1 << 1)
228
+ elif s in ("sse4.1", "sse41", "sse4"):
229
+ simd_mask = (1 << 0) | (1 << 1) | (1 << 2)
230
+ elif s in ("avx2", "avx"):
231
+ simd_mask = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3)
232
+ elif s in ("avx512", "avx-512", "avx512icl"):
233
+ simd_mask = (1 << 0) | (1 << 1) | (1 << 2) | (1 << 3) | (1 << 4)
234
+ else:
235
+ raise ValueError(
236
+ f"vsfgs: invalid simd architecture '{simd}'. Supported values: 'auto', 'none', 'sse2', 'sse3', 'sse41', 'avx2', 'avx512'."
237
+ )
238
+ elif isinstance(simd, int):
239
+ simd_mask = simd
240
+ else:
241
+ raise TypeError("vsfgs: simd parameter must be a string or an integer bitmask.")
242
+
243
+ fgs_clip = core.fgs.FGS(
244
+ clip, fgs_data=fgs_bytes, dynamic_seed=dynamic_seed, simd_mask=simd_mask, seed_list=seed_list
245
+ )
246
+ base_fgs_clip = fgs_clip
247
+
248
+ temp_weight, temp_radius = 0.0, 0
249
+ if temporal_average is not None:
250
+ if isinstance(temporal_average, (float, int)):
251
+ temp_weight = float(temporal_average)
252
+ temp_radius = 1 if temp_weight > 0.0 else 0
253
+ elif isinstance(temporal_average, tuple):
254
+ temp_weight = float(temporal_average[0])
255
+ temp_radius = int(temporal_average[1])
256
+
257
+ needs_diff = (
258
+ temp_weight > 0.0 and (temp_radius > 0 or temporal_avg_func is not None)
259
+ ) or (
260
+ original_format.id != clip.format.id
261
+ and (
262
+ original_format.bits_per_sample > 12
263
+ or original_format.sample_type == vs.FLOAT
264
+ )
265
+ )
266
+
267
+ if needs_diff:
268
+ grain_diff = core.std.MakeDiff(clipa=fgs_clip, clipb=clip)
269
+
270
+ if temp_weight > 0.0 and (temp_radius > 0 or temporal_avg_func is not None):
271
+ if temporal_avg_func is not None:
272
+ avg_grain = temporal_avg_func(grain_diff)
273
+ else:
274
+ avg_grain = core.std.AverageFrames(
275
+ grain_diff, weights=[1.0] * (temp_radius * 2 + 1)
276
+ )
277
+ grain_diff = core.std.Merge(grain_diff, avg_grain, weight=temp_weight)
278
+
279
+ if original_format.id != clip.format.id:
280
+ grain_diff = grain_diff.resize.Bicubic(
281
+ format=original_format.id, dither_type="none"
282
+ )
283
+
284
+ fgs_clip = core.std.MergeDiff(clipa=original_clip, clipb=grain_diff)
285
+ else:
286
+ if original_format.id != clip.format.id:
287
+ fgs_clip = fgs_clip.resize.Bicubic(
288
+ format=original_format.id, dither_type="none"
289
+ )
290
+
291
+ if ignore_chroma:
292
+ fgs_clip = core.std.ShufflePlanes(
293
+ clips=[fgs_clip, original_clip, original_clip],
294
+ planes=[0, 1, 2],
295
+ colorfamily=vs.YUV,
296
+ )
297
+ return core.std.CopyFrameProps(
298
+ clip=fgs_clip, prop_src=base_fgs_clip, props=["FGS_Seed"]
299
+ )
vsfgs/parser.py ADDED
@@ -0,0 +1,114 @@
1
+ import ctypes
2
+ import struct
3
+
4
+ class Dav1dFilmGrainData(ctypes.Structure):
5
+ _fields_ = [
6
+ ("seed", ctypes.c_uint),
7
+ ("num_y_points", ctypes.c_int),
8
+ ("y_points", (ctypes.c_uint8 * 2) * 14),
9
+ ("chroma_scaling_from_luma", ctypes.c_int),
10
+ ("num_uv_points", ctypes.c_int * 2),
11
+ ("uv_points", ((ctypes.c_uint8 * 2) * 10) * 2),
12
+ ("scaling_shift", ctypes.c_int),
13
+ ("ar_coeff_lag", ctypes.c_int),
14
+ ("ar_coeffs_y", ctypes.c_int8 * 24),
15
+ ("ar_coeffs_uv", (ctypes.c_int8 * 28) * 2),
16
+ ("ar_coeff_shift", ctypes.c_uint64),
17
+ ("grain_scale_shift", ctypes.c_int),
18
+ ("uv_mult", ctypes.c_int * 2),
19
+ ("uv_luma_mult", ctypes.c_int * 2),
20
+ ("uv_offset", ctypes.c_int * 2),
21
+ ("overlap_flag", ctypes.c_int),
22
+ ("clip_to_restricted_range", ctypes.c_int),
23
+ ]
24
+
25
+ def _parse_binary_fgs(fgs_file_path: str, target_num_frames: int) -> bytes:
26
+ records = []
27
+ with open(fgs_file_path, "rb") as f:
28
+ while True:
29
+ apply_grain_bytes = f.read(4)
30
+ if not apply_grain_bytes or len(apply_grain_bytes) < 4:
31
+ break
32
+
33
+ data = Dav1dFilmGrainData()
34
+ _ = struct.unpack("<i", apply_grain_bytes)[0]
35
+ data.seed = struct.unpack("<H", f.read(2))[0]
36
+ _update_parameters = struct.unpack("<i", f.read(4))[0]
37
+
38
+ data.num_y_points = struct.unpack("<i", f.read(4))[0]
39
+ if data.num_y_points > 0:
40
+ for j in range(data.num_y_points):
41
+ val, scaling = struct.unpack("<ii", f.read(8))
42
+ if j < 14:
43
+ data.y_points[j][0] = val
44
+ data.y_points[j][1] = scaling
45
+
46
+ data.num_uv_points[0] = struct.unpack("<i", f.read(4))[0]
47
+ if data.num_uv_points[0] > 0:
48
+ for j in range(data.num_uv_points[0]):
49
+ val, scaling = struct.unpack("<ii", f.read(8))
50
+ if j < 10:
51
+ data.uv_points[0][j][0] = val
52
+ data.uv_points[0][j][1] = scaling
53
+
54
+ data.num_uv_points[1] = struct.unpack("<i", f.read(4))[0]
55
+ if data.num_uv_points[1] > 0:
56
+ for j in range(data.num_uv_points[1]):
57
+ val, scaling = struct.unpack("<ii", f.read(8))
58
+ if j < 10:
59
+ data.uv_points[1][j][0] = val
60
+ data.uv_points[1][j][1] = scaling
61
+
62
+ data.scaling_shift = struct.unpack("<i", f.read(4))[0]
63
+ data.ar_coeff_lag = struct.unpack("<i", f.read(4))[0]
64
+
65
+ if data.num_y_points > 0:
66
+ y_ar = struct.unpack("<24i", f.read(96))
67
+ for j in range(24):
68
+ data.ar_coeffs_y[j] = y_ar[j]
69
+
70
+ if data.num_uv_points[0] > 0:
71
+ cb_ar = struct.unpack("<25i", f.read(100))
72
+ for j in range(25):
73
+ data.ar_coeffs_uv[0][j] = cb_ar[j]
74
+
75
+ if data.num_uv_points[1] > 0:
76
+ cr_ar = struct.unpack("<25i", f.read(100))
77
+ for j in range(25):
78
+ data.ar_coeffs_uv[1][j] = cr_ar[j]
79
+
80
+ data.ar_coeff_shift = struct.unpack("<i", f.read(4))[0]
81
+ data.grain_scale_shift = struct.unpack("<i", f.read(4))[0]
82
+
83
+ if data.num_uv_points[0] > 0:
84
+ data.uv_mult[0], data.uv_luma_mult[0], data.uv_offset[0] = (
85
+ struct.unpack("<iii", f.read(12))
86
+ )
87
+ data.uv_mult[0] -= 128
88
+ data.uv_luma_mult[0] -= 128
89
+ data.uv_offset[0] -= 256
90
+
91
+ if data.num_uv_points[1] > 0:
92
+ data.uv_mult[1], data.uv_luma_mult[1], data.uv_offset[1] = (
93
+ struct.unpack("<iii", f.read(12))
94
+ )
95
+ data.uv_mult[1] -= 128
96
+ data.uv_luma_mult[1] -= 128
97
+ data.uv_offset[1] -= 256
98
+
99
+ data.overlap_flag = struct.unpack("<i", f.read(4))[0]
100
+ data.clip_to_restricted_range = struct.unpack("<i", f.read(4))[0]
101
+
102
+ records.append(bytes(data))
103
+
104
+ if not records:
105
+ raise ValueError("vsfgs: binary FGS file is empty or malformed.")
106
+
107
+ fgs_structs_bytes = bytearray()
108
+ for n in range(target_num_frames):
109
+ if n < len(records):
110
+ fgs_structs_bytes.extend(records[n])
111
+ else:
112
+ fgs_structs_bytes.extend(records[-1])
113
+
114
+ return bytes(fgs_structs_bytes)
vsfgs/source.py ADDED
@@ -0,0 +1,206 @@
1
+ import inspect
2
+ import os
3
+ import shutil
4
+ import subprocess
5
+ import vapoursynth as vs
6
+ from typing import Any
7
+ from pathlib import Path
8
+ from .core import apply_fgs
9
+
10
+ core = vs.core
11
+
12
+
13
+ def load_av1fgs_video(
14
+ video_path: str,
15
+ fast: bool = False,
16
+ show_progress: bool = True,
17
+ grav_path: str | None = None,
18
+ simd: str | int = "auto",
19
+ **kwargs: Any,
20
+ ) -> vs.VideoNode:
21
+ """
22
+ Loads an AV1 video and automatically extracts and applies its Film Grain Synthesis (FGS) data.
23
+
24
+ This uses native VapourSynth source filters for neutral and optimal initialization:
25
+ - BestSource (`core.bs.VideoSource`) by default.
26
+ - FFMS2 (`core.ffms2.Source`) if `fast=True`.
27
+ Index files are saved alongside the video.
28
+
29
+ The FGS table is extracted using `grav1synth` (which must be in PATH or provided via `grav_path`).
30
+ The extracted FGS table is saved alongside the video with a `.tbl` extension so it can be reused
31
+ on subsequent loads without re-extracting.
32
+
33
+ If the video contains no FGS data, a message is printed, and the original video is returned.
34
+
35
+ Args:
36
+ video_path: Path to the AV1 video file.
37
+ fast: If True, uses FFMS2 instead of BestSource for decoding. Default: False.
38
+ show_progress: If True, displays a progress bar during indexing (only supported by BestSource). Default: True.
39
+ simd: SIMD instruction set to use ('auto', 'avx512', 'avx2', 'sse41', 'sse3', 'sse2', 'none'/'c', or an integer bitmask). Default: 'auto'.
40
+ grav_path: Path to the `grav1synth` executable. If None, it looks in PATH, CWD, or next to the calling .vpy script.
41
+ **kwargs: Additional arguments to pass to the source indexer (BestSource or FFMS2).
42
+
43
+ Returns:
44
+ vs.VideoNode: The loaded video clip, with FGS applied if present in the source file.
45
+ """
46
+ video_p = Path(video_path).resolve()
47
+ if not video_p.is_file():
48
+ raise FileNotFoundError(f"vsfgs: Video file not found: {video_path}")
49
+
50
+ idx_kwargs = kwargs
51
+
52
+ if fast:
53
+ if not hasattr(core, "ffms2") or not hasattr(core.ffms2, "Source"):
54
+ raise RuntimeError(
55
+ "vsfgs: FFMS2 plugin is required when fast=True but it is not loaded."
56
+ )
57
+ clip = core.ffms2.Source(source=str(video_p), **idx_kwargs)
58
+ else:
59
+ if not hasattr(core, "bs") or not hasattr(core.bs, "VideoSource"):
60
+ raise RuntimeError(
61
+ "vsfgs: BestSource plugin (bs.VideoSource) is required when fast=False but it is not loaded."
62
+ )
63
+
64
+ if "cachepath" not in idx_kwargs:
65
+ idx_kwargs["cachepath"] = str(video_p.resolve())
66
+ if "cachemode" not in idx_kwargs:
67
+ idx_kwargs["cachemode"] = 3
68
+
69
+ if show_progress:
70
+ if "showprogress" not in idx_kwargs:
71
+ idx_kwargs["showprogress"] = 1
72
+
73
+ import logging
74
+ import re
75
+ import sys
76
+ from contextlib import contextmanager
77
+
78
+ @contextmanager
79
+ def bs_progress_bar():
80
+ class ProgressHandler(logging.Handler):
81
+ def __init__(self):
82
+ super().__init__()
83
+ self.progress_re = re.compile(r"progress\s+(\d+(?:\.\d+)?)%")
84
+ self.last_pct = -1
85
+
86
+ def emit(self, record):
87
+ m = self.progress_re.search(record.getMessage())
88
+ if m:
89
+ pct = float(m.group(1))
90
+ if int(pct) > self.last_pct:
91
+ self.last_pct = int(pct)
92
+ sys.stdout.write(f"\rIndexing BestSource: {pct:.1f}%")
93
+ sys.stdout.flush()
94
+
95
+ logger = logging.getLogger("vapoursynth")
96
+ old_level = logger.level
97
+ logger.setLevel(logging.INFO)
98
+ handler = ProgressHandler()
99
+ logger.addHandler(handler)
100
+ try:
101
+ yield
102
+ sys.stdout.write("\rIndexing BestSource: 100.0%\n")
103
+ sys.stdout.flush()
104
+ finally:
105
+ logger.removeHandler(handler)
106
+ logger.setLevel(old_level)
107
+
108
+ with bs_progress_bar():
109
+ clip = core.bs.VideoSource(source=str(video_p), **idx_kwargs)
110
+ else:
111
+ clip = core.bs.VideoSource(source=str(video_p), **idx_kwargs)
112
+
113
+ tbl_path = video_p.with_suffix(video_p.suffix + ".tbl")
114
+
115
+ if not tbl_path.is_file():
116
+ exe = grav_path
117
+ if exe is None:
118
+ exe = shutil.which("grav1synth")
119
+
120
+ if exe is None:
121
+ exe_name = "grav1synth.exe" if sys.platform == "win32" else "grav1synth"
122
+ try:
123
+ for frame_info in inspect.stack():
124
+ caller_file = frame_info.filename
125
+ if caller_file and not caller_file.endswith(
126
+ os.path.basename(__file__)
127
+ ):
128
+ caller_dir = os.path.dirname(os.path.abspath(caller_file))
129
+ cand = os.path.join(caller_dir, exe_name)
130
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
131
+ exe = cand
132
+ break
133
+ except Exception:
134
+ pass
135
+
136
+ if exe is None:
137
+ cand = os.path.join(os.getcwd(), exe_name)
138
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
139
+ exe = cand
140
+
141
+ if exe is None:
142
+ cand = os.path.join(os.path.dirname(str(video_p)), exe_name)
143
+ if os.path.isfile(cand) and os.access(cand, os.X_OK):
144
+ exe = cand
145
+
146
+ if exe is None:
147
+ print(
148
+ "vsfgs [INFO]: grav1synth not found in PATH. Returning pristine video without applying FGS."
149
+ )
150
+ return clip
151
+
152
+ cmd = [exe, "inspect", "-o", str(tbl_path), "-y", str(video_p)]
153
+ try:
154
+ kwargs_subprocess = {}
155
+ if os.name == "nt":
156
+ kwargs_subprocess["creationflags"] = subprocess.CREATE_NO_WINDOW
157
+
158
+ res = subprocess.run(
159
+ cmd,
160
+ stdout=subprocess.PIPE,
161
+ stderr=subprocess.STDOUT,
162
+ text=True,
163
+ **kwargs_subprocess,
164
+ )
165
+
166
+ if res.returncode != 0:
167
+ output_lower = res.stdout.lower()
168
+ if any(
169
+ x in output_lower
170
+ for x in [
171
+ "no film grain",
172
+ "no grain",
173
+ "not found",
174
+ "does not contain",
175
+ "no fgs",
176
+ ]
177
+ ):
178
+ print(
179
+ "vsfgs [INFO]: No FGS found in video. Returning original clip."
180
+ )
181
+ else:
182
+ print(
183
+ f"vsfgs [WARNING]: grav1synth inspect failed (exit {res.returncode}). Returning original clip.\nOutput: {res.stdout}"
184
+ )
185
+
186
+ if tbl_path.exists():
187
+ tbl_path.unlink(missing_ok=True)
188
+ return clip
189
+
190
+ if not tbl_path.is_file() or tbl_path.stat().st_size == 0:
191
+ print(
192
+ "vsfgs [INFO]: grav1synth ran successfully but extracted no grain data. Returning original clip."
193
+ )
194
+ return clip
195
+
196
+ except Exception as e:
197
+ print(
198
+ f"vsfgs [WARNING]: Failed to run grav1synth: {e}. Returning original clip."
199
+ )
200
+ return clip
201
+
202
+ return apply_fgs(
203
+ clip,
204
+ str(tbl_path),
205
+ simd=simd,
206
+ )