Skip to content

Commit 9174e35

Browse files
committed
feat: Calculate average FPS from GIF by default
Previously, the conversion defaulted to a fixed 10 FPS, which might not accurately reflect the original GIF's intended animation speed. This change introduces the capability to calculate the average FPS based on the frame durations embedded within the input GIF file itself. This calculated average FPS is now the default behavior when no explicit FPS is provided by the user, leading to more accurate output animations out-of-the-box. Changes include: - Added `_calculate_gif_average_fps` to read frame durations and compute the average FPS, handling missing or zero durations. - Modified `gif_to_animated_svg` and `gif_to_animated_svg_write` to accept `fps=None` (new default) to trigger this calculation. - Renamed `DEFAULT_FPS` to `FALLBACK_FPS` (value remains 10.0) and use it if calculation fails (e.g., non-animated GIF, read errors) or if an invalid FPS is provided. - Updated the CLI `--fps` argument: removed the explicit default value, allowing the calculation logic to take precedence. Help text updated. - Updated the Web API `/api/convert` endpoint to align with the new library default behavior. - Added comprehensive tests for the FPS calculation logic and its integration. - Updated README documentation to reflect the new default FPS behavior for both CLI and library usage. - Bumped project version to 0.2.0.
1 parent 7938f26 commit 9174e35

File tree

6 files changed

+258
-70
lines changed

6 files changed

+258
-70
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ framesvg input.gif [output.svg] [options]
129129

130130
**Options:**
131131

132-
* **`-f`, `--fps <value>`:** Sets the frames per second (FPS) for the animation. (Default: 10). Lower values can reduce file size.
132+
* **`-f`, `--fps <value>`:** Sets the frames per second (FPS) for the animation. (Default: Uses the average FPS calculated from the input GIF's frame durations. Falls back to 10 FPS if durations are missing or invalid).
133133
* **`-l`, `--log-level <level>`:** Sets the logging level. (Default: INFO). Choices: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, `NONE`. `DEBUG` provides detailed output for troubleshooting.
134134
135135
* **VTracer Options:** These options control the raster-to-vector conversion process performed by VTracer. Refer to the [VTracer Documentation](https://www.visioncortex.org/vtracer-docs/) and [Online Demo](https://www.visioncortex.org/vtracer/) for detailed explanations.
@@ -167,7 +167,7 @@ framesvg input.gif -l DEBUG
167167
```python
168168
from framesvg import gif_to_animated_svg_write, gif_to_animated_svg
169169
170-
# Example 1: Convert and save to a file
170+
# Example 1: Convert and save to a file (using GIF's average FPS)
171171
gif_to_animated_svg_write("input.gif", "output.svg", fps=30)
172172

173173
# Example 2: Get the SVG as a string
@@ -189,15 +189,15 @@ gif_to_animated_svg_write("input.gif", "output_custom.svg", vtracer_options=cust
189189
* `gif_path` (str): Path to the input GIF file.
190190
* `output_svg_path` (str): Path to save the output SVG file.
191191
* `vtracer_options` (dict, optional): A dictionary of VTracer options. If `None`, uses `DEFAULT_VTRACER_OPTIONS`.
192-
* `fps` (float, optional): Frames per second. Defaults to 10.0.
192+
* `fps` (float | None, optional): Frames per second. If `None` (default), calculates the average FPS from the input GIF. Falls back to 10.0 if calculation fails.
193193
* `image_loader` (ImageLoader, optional): Custom image loader.
194194
* `vtracer_instance` (VTracer, optional): Custom VTracer instance.
195195
* Raises: `FileNotFoundError`, `NotAnimatedGifError`, `NoValidFramesError`, `DimensionError`, `ExtractionError`, `FramesvgError`, `IsADirectoryError`.
196196

197197
* **`gif_to_animated_svg(gif_path, vtracer_options=None, fps=10.0, image_loader=None, vtracer_instance=None)`:**
198198
* `gif_path` (str): Path to the input GIF file.
199199
* `vtracer_options` (dict, optional): A dictionary of VTracer options. If `None`, uses `DEFAULT_VTRACER_OPTIONS`.
200-
* `fps` (float, optional): Frames per second. Defaults to 10.0.
200+
* `fps` (float | None, optional): Frames per second. If `None` (default), calculates the average FPS from the input GIF. Falls back to 10.0 if calculation fails.
201201
* `image_loader` (ImageLoader, optional): Custom image loader.
202202
* `vtracer_instance` (VTracer, optional): Custom VTracer instance.
203203
* Returns: The animated SVG as a string.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ requires = ["hatchling"]
33
build-backend = "hatchling.build"
44

55
[project]
6-
version = "0.1.1"
6+
version = "0.2.0"
77
name = "framesvg"
88
description = "Convert animated GIFs to animated SVGs."
99
readme = "README.md"

src/framesvg/__init__.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ class VTracerOptions(TypedDict, total=False):
2525
path_precision: int | None
2626

2727

28-
DEFAULT_FPS = 10.0
28+
FALLBACK_FPS = 10.0
29+
"""Fallback FPS if GIF duration cannot be determined."""
2930

3031
DEFAULT_VTRACER_OPTIONS: VTracerOptions = {
3132
"colormode": "color",
@@ -85,6 +86,7 @@ class ImageWrapper(Protocol):
8586
is_animated: bool
8687
n_frames: int
8788
format: str | None
89+
info: dict
8890

8991
def seek(self, frame: int) -> None: ...
9092
def save(self, fp, img_format) -> None: ...
@@ -133,6 +135,34 @@ def is_animated_gif(img: ImageWrapper, filepath: str) -> None:
133135
raise NotAnimatedGifError(filepath)
134136

135137

138+
def _calculate_gif_average_fps(img: ImageWrapper) -> float | None:
139+
"""Calculates the average FPS from GIF frame durations."""
140+
if not img.is_animated or img.n_frames <= 1:
141+
return None # Not animated or single frame
142+
143+
total_duration_ms = 0
144+
valid_frames_with_duration = 0
145+
try:
146+
for i in range(img.n_frames):
147+
img.seek(i)
148+
# PIL uses 100ms default if duration is missing or 0.
149+
# Use this default directly in the sum.
150+
duration = img.info.get("duration", 100)
151+
# Ensure duration is at least 1ms if it was 0, consistent with some viewers/browsers
152+
# treating 0 as a very small delay rather than 100ms.
153+
# However, for FPS calculation, using the 100ms default seems more robust.
154+
total_duration_ms += duration if duration > 0 else 100
155+
valid_frames_with_duration += 1
156+
except EOFError:
157+
logging.warning("EOFError encountered while reading GIF durations. FPS calculation might be inaccurate.")
158+
159+
# Avoid division by zero if somehow total_duration_ms is 0 after processing
160+
if total_duration_ms <= 0:
161+
return None
162+
163+
return valid_frames_with_duration / (total_duration_ms / 1000.0)
164+
165+
136166
def extract_svg_dimensions_from_content(svg_content: str) -> dict[str, int] | None:
137167
"""Extracts width and height from SVG."""
138168
dims: dict[str, int] = {"width": 0, "height": 0}
@@ -222,6 +252,9 @@ def create_animated_svg_string(frames: list[str], max_dims: dict[str, int], fps:
222252
if not frames:
223253
msg = "No frames to generate SVG."
224254
raise ValueError(msg)
255+
if fps <= 0:
256+
logging.warning("FPS is non-positive (%.2f), defaulting to fallback FPS %.1f", fps, FALLBACK_FPS)
257+
fps = FALLBACK_FPS
225258
frame_duration = 1.0 / fps
226259
total_duration = frame_duration * len(frames)
227260

@@ -253,9 +286,9 @@ def create_animated_svg_string(frames: list[str], max_dims: dict[str, int], fps:
253286
def save_svg_to_file(svg_string: str, output_path: str) -> None:
254287
"""Writes SVG string to file."""
255288
if os.path.isdir(output_path):
256-
msg = "'%s' is a directory, not a file."
257-
logging.exception(msg, output_path)
258-
raise IsADirectoryError(msg, output_path)
289+
msg = f"'{output_path}' is a directory, not a file."
290+
logging.error(msg)
291+
raise IsADirectoryError(msg)
259292
try:
260293
with open(output_path, "w", encoding="utf-8") as f:
261294
f.write(svg_string)
@@ -267,7 +300,7 @@ def save_svg_to_file(svg_string: str, output_path: str) -> None:
267300
def gif_to_animated_svg(
268301
gif_path: str,
269302
vtracer_options: VTracerOptions | None = None,
270-
fps: float = DEFAULT_FPS,
303+
fps: float | None = None,
271304
image_loader: ImageLoader | None = None,
272305
vtracer_instance: VTracer | None = None,
273306
) -> str:
@@ -282,8 +315,19 @@ def gif_to_animated_svg(
282315
img = load_image_wrapper(gif_path, image_loader)
283316
try:
284317
is_animated_gif(img, gif_path)
318+
319+
effective_fps = fps
320+
if effective_fps is None:
321+
calculated_fps = _calculate_gif_average_fps(img)
322+
effective_fps = calculated_fps if calculated_fps is not None else FALLBACK_FPS
323+
logging.info("Using calculated average FPS: %.2f (Fallback: %.1f)", effective_fps, FALLBACK_FPS)
324+
elif effective_fps <= 0:
325+
logging.warning("Provided FPS is non-positive (%.2f), using fallback FPS %.1f", effective_fps, FALLBACK_FPS)
326+
effective_fps = FALLBACK_FPS
327+
328+
285329
frames, max_dims = process_gif_frames(img, vtracer_instance, options)
286-
return create_animated_svg_string(frames, max_dims, fps)
330+
return create_animated_svg_string(frames, max_dims, effective_fps)
287331
finally:
288332
img.close()
289333

@@ -292,7 +336,7 @@ def gif_to_animated_svg_write(
292336
gif_path: str,
293337
output_svg_path: str,
294338
vtracer_options: VTracerOptions | None = None,
295-
fps: float = DEFAULT_FPS,
339+
fps: float | None = None,
296340
image_loader: ImageLoader | None = None,
297341
vtracer_instance: VTracer | None = None,
298342
) -> None:
@@ -342,8 +386,8 @@ def parse_cli_arguments(args: list[str]) -> argparse.Namespace:
342386
"-f",
343387
"--fps",
344388
type=validate_positive_float,
345-
default=DEFAULT_FPS,
346-
help=f"Frames per second (default: {DEFAULT_FPS}).",
389+
default=None, # Default is now None, handled later
390+
help=f"Frames per second. (Default: Use GIF average FPS, fallback: {FALLBACK_FPS}).",
347391
)
348392
parser.add_argument(
349393
"-l",
@@ -402,12 +446,14 @@ def main() -> None:
402446
and v is not None
403447
}
404448

405-
gif_to_animated_svg_write(args.gif_path, output_path, vtracer_options, args.fps)
449+
gif_to_animated_svg_write(args.gif_path, output_path, vtracer_options=vtracer_options, fps=args.fps)
406450
logging.info("Converted %s to %s", args.gif_path, output_path)
407451
except SystemExit as e:
408452
sys.exit(e.code)
409453
except FramesvgError:
410-
logging.exception("An error occurred during processing.")
454+
# Specific, expected errors are logged within the functions
455+
# Log general message here for unexpected FramesvgError subclasses
456+
logging.error("FrameSVG processing failed. Check previous logs for details.")
411457
sys.exit(1)
412458
except Exception:
413459
logging.exception("An unexpected error occurred.")

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Placeholder for test discovery

0 commit comments

Comments
 (0)