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.
- .vsfgs.mesonpy.libs/pkgconfig/dav1d.pc +9 -0
- vapoursynth/plugins/vsfgs.dll +0 -0
- vapoursynth/plugins/vsfgs.lib +0 -0
- vsfgs/__init__.py +4 -0
- vsfgs/core.py +299 -0
- vsfgs/parser.py +114 -0
- vsfgs/source.py +206 -0
- vsfgs-0.7.0.data/headers/dav1d/common.h +96 -0
- vsfgs-0.7.0.data/headers/dav1d/data.h +117 -0
- vsfgs-0.7.0.data/headers/dav1d/dav1d.h +329 -0
- vsfgs-0.7.0.data/headers/dav1d/headers.h +440 -0
- vsfgs-0.7.0.data/headers/dav1d/picture.h +157 -0
- vsfgs-0.7.0.data/headers/dav1d/version.h +50 -0
- vsfgs-0.7.0.dist-info/METADATA +187 -0
- vsfgs-0.7.0.dist-info/RECORD +16 -0
- vsfgs-0.7.0.dist-info/WHEEL +5 -0
|
Binary file
|
|
Binary file
|
vsfgs/__init__.py
ADDED
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
|
+
)
|