├── .github └── workflows │ ├── publish.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .markdownlint.json ├── LICENSE ├── README.md ├── images ├── Black And White Loop GIF by Pi-Slices.gif ├── Black And White Loop GIF by Pi-Slices.svg ├── Code Coding GIF by EscuelaDevRock Git.gif ├── Code Coding GIF by EscuelaDevRock Git.svg ├── Code Coding GIF by EscuelaDevRock GitHub.gif ├── Code Coding GIF by EscuelaDevRock GitHub.svg ├── Code Coding GIF by EscuelaDevRock Sublime.gif ├── Code Coding GIF by EscuelaDevRock Sublime.svg ├── Code Coding GIF by EscuelaDevRock VSCode.gif ├── Code Coding GIF by EscuelaDevRock VSCode.svg ├── Good_Morning_GIF_by_Hello_All.gif ├── Good_Morning_GIF_by_Hello_All.svg ├── black and white loop GIF by Sculpture.gif ├── black and white loop GIF by Sculpture.svg ├── framesvg.gif ├── framesvg.svg ├── icon_loading_GIF.gif ├── icon_loading_GIF.svg ├── kyubey.gif ├── kyubey.svg ├── voila hands GIF by brontron.gif └── voila hands GIF by brontron.svg ├── pyproject.toml ├── src └── framesvg │ ├── __init__.py │ └── py.typed ├── tests ├── __init__.py └── test_app.py └── web ├── api └── convert.py ├── public ├── examples.css ├── examples.html ├── examples.js ├── index.html ├── script.js └── style.css └── requirements.txt /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '[0-9]+.[0-9]+.[0-9]+*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install Hatch 16 | uses: pypa/hatch@a3c83ab3d481fbc2dc91dd0088628817488dd1d5 17 | 18 | - name: Build 19 | run: hatch build 20 | 21 | - name: Publish to PyPI 22 | env: 23 | HATCH_INDEX_USER: __token__ 24 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_TOKEN }} 25 | run: hatch publish 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | # Trigger on semantic version tags (e.g., 1.2.3, 1.2.3-rc1, 1.2.3+build4) 7 | # Note: This regex is simple; more complex patterns exist for strict SemVer. 8 | - '[0-9]+.[0-9]+.[0-9]+*' 9 | 10 | jobs: 11 | create_release: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | # Required to create releases and upload assets 15 | contents: write 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | with: 20 | # Fetch all history and tags for changelog generation 21 | fetch-depth: 0 22 | 23 | - name: Install Hatch 24 | # Using a specific commit hash for stability 25 | uses: pypa/hatch@a3c83ab3d481fbc2dc91dd0088628817488dd1d5 26 | 27 | - name: Build package 28 | run: hatch build 29 | 30 | - name: Get previous semantic version tag 31 | id: prev_tag 32 | run: | 33 | echo "Fetching tags..." 34 | git fetch --tags --force # Ensure all tags are fetched 35 | 36 | echo "Listing and sorting tags..." 37 | # Sort tags semantically in descending order (newest first) 38 | git tag --sort=-v:refname > tags.txt 39 | 40 | current_tag="${{ github.ref_name }}" 41 | echo "Current tag: $current_tag" 42 | 43 | # Find the line number of the current tag in the descending list 44 | line_number=$(grep -nxF "$current_tag" tags.txt | cut -d: -f1) 45 | 46 | if [ -z "$line_number" ]; then 47 | echo "Error: Current tag '$current_tag' not found in the list of tags." 48 | # Optionally fail the job: exit 1 49 | # For now, assume no previous tag 50 | echo "prev_tag=" >> $GITHUB_OUTPUT 51 | else 52 | # The previous tag is on the *next* line in the descending list 53 | prev_line=$((line_number + 1)) 54 | # Get the total number of lines (tags) 55 | total_lines=$(wc -l < tags.txt) 56 | 57 | if [ "$prev_line" -le "$total_lines" ]; then 58 | # Previous tag exists, get it from the next line 59 | prev_tag=$(sed -n "${prev_line}p" tags.txt) 60 | echo "Previous tag found: $prev_tag" 61 | echo "prev_tag=$prev_tag" >> $GITHUB_OUTPUT 62 | else 63 | # No previous tag found (current tag is the oldest/only semantic tag) 64 | echo "No previous semantic tag found." 65 | echo "prev_tag=" >> $GITHUB_OUTPUT 66 | fi 67 | fi 68 | rm tags.txt # Clean up temporary file 69 | 70 | - name: Generate changelog content 71 | id: changelog 72 | run: | 73 | current_tag="${{ github.ref_name }}" 74 | prev_tag="${{ steps.prev_tag.outputs.prev_tag }}" 75 | 76 | echo "Generating changelog for range: $prev_tag .. $current_tag" 77 | 78 | if [ -z "$prev_tag" ]; then 79 | # No previous tag, log commits up to the current tag 80 | log_range="$current_tag" 81 | echo "Using log range: $log_range (first release)" 82 | # For the very first tag, maybe list all conventional commits? 83 | # Or just state it's the first release. Let's list commits. 84 | commits=$(git log --pretty=format:"%s" "$log_range") 85 | else 86 | # Log commits between the previous tag (exclusive) and current tag (inclusive) 87 | log_range="$prev_tag..$current_tag" 88 | echo "Using log range: $log_range" 89 | commits=$(git log --pretty=format:"%s" "$log_range") 90 | fi 91 | 92 | # Filter for conventional commits (feat, fix) and format as markdown list 93 | # Use grep -E; handle case where no commits are found gracefully (|| true) 94 | filtered_commits=$(echo "$commits" | grep -E '^(feat|fix)(\(.*\))?:\s' || true) 95 | 96 | if [ -z "$filtered_commits" ]; then 97 | changelog_body="No notable changes (feat/fix) detected since the last release." 98 | echo "No notable changes found." 99 | else 100 | echo "Found notable changes:" 101 | echo "$filtered_commits" # Log the commits being included 102 | # Format: Convert "feat: Description" to "- **feat**: Description" 103 | changelog_body=$(echo "$filtered_commits" | sed -E 's/^(feat|fix)(\(.*\))?:\s*/- **\1**: /') 104 | fi 105 | 106 | # Prepare for multiline output (needed for set-output) 107 | # 1. Use a delimiter unlikely to be in the changelog 108 | delimiter="$(openssl rand -hex 8)" 109 | # 2. Echo the variable assignment using the delimiter 110 | echo "changelog<<$delimiter" >> $GITHUB_OUTPUT 111 | echo "$changelog_body" >> $GITHUB_OUTPUT 112 | echo "$delimiter" >> $GITHUB_OUTPUT 113 | echo "Changelog content generated." 114 | 115 | - name: Create GitHub Release 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | CURRENT_TAG: ${{ github.ref_name }} 119 | # Notes are passed via environment variable for better multiline handling 120 | CHANGELOG_NOTES: ${{ steps.changelog.outputs.changelog }} 121 | run: | 122 | echo "Creating release for tag: $CURRENT_TAG" 123 | 124 | # Check if it's a pre-release (tag contains a hyphen) 125 | prerelease_flag="" 126 | if [[ "$CURRENT_TAG" == *-* ]]; then 127 | echo "Detected pre-release tag." 128 | prerelease_flag="--prerelease" 129 | fi 130 | 131 | echo "Release Notes:" 132 | echo "$CHANGELOG_NOTES" 133 | echo "---" 134 | 135 | # Create the release using GitHub CLI 136 | # Pass notes via stdin for robust multiline handling 137 | echo "$CHANGELOG_NOTES" | gh release create "$CURRENT_TAG" \ 138 | --title "$CURRENT_TAG" \ 139 | --notes-file - \ 140 | $prerelease_flag \ 141 | ./dist/* # Attach all build artifacts from the dist directory 142 | 143 | echo "GitHub Release created successfully." 144 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ "**" ] 6 | pull_request: 7 | branches: [ "**" ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest, macos-latest] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install Hatch 20 | uses: pypa/hatch@a3c83ab3d481fbc2dc91dd0088628817488dd1d5 21 | 22 | - name: Run tests 23 | run: hatch test -a 24 | 25 | - name: Check formatting and lint 26 | run: hatch fmt --check -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | *.spec 9 | .ruff_cache 10 | 11 | # Virtual environments 12 | .venv 13 | 14 | # Testing 15 | .pytest_cache 16 | coverage.xml 17 | .coverage* 18 | 19 | # Misc 20 | dircat.md 21 | test.md 22 | 23 | # Vercel 24 | .vercel -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD033": { 3 | "allowed_elements": [ 4 | "img", 5 | "p", 6 | "a", 7 | "h1", 8 | "strong", 9 | "br" 10 | ] 11 | }, 12 | "MD013": false 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Romelium 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | FrameSVG Logo 3 |

4 | 5 |

6 | Convert animated GIFs to animated SVGs. 7 |

8 | 9 |

10 | Build Status: Passing 11 | License: MIT 12 | PyPI Version 13 | Python Versions Supported 14 |

15 | 16 | 17 | Kyubey SVG Image 18 | Kyubey SVG Image 19 | 20 | `framesvg` is a [web app](https://framesvg.romelium.cc), command-line tool, and Python library that converts animated GIFs into animated SVGs. It leverages the power of [VTracer](https://www.visioncortex.org/vtracer/) for raster-to-vector conversion, producing smooth, scalable, and *true vector* animations. This is a significant improvement over embedding raster images (like GIFs) directly within SVGs, as `framesvg` generates genuine vector output that plays automatically and scales beautifully. Ideal for readmes, documentation, and web graphics. 21 | 22 | You can try it now at [framesvg.romelium.cc](https://framesvg.romelium.cc) 23 | 24 |
25 | 26 | ## Why Use framesvg? 27 | 28 | * **True Vector Output:** Unlike simply embedding a GIF within an SVG, `framesvg` creates a true vector animation. This means: 29 | * **Scalability:** The SVG can be resized to any dimensions without losing quality. 30 | * **Smaller File Size (Potentially):** For many GIFs, the resulting SVG will be smaller, especially for graphics with large areas of solid color or simple shapes. Complex, photographic GIFs may be larger, however. 31 | * **Automatic Playback:** The generated SVGs are designed to play automatically in any environment that supports SVG animations (web browsers, GitHub, many image viewers, etc.). 32 | * **Easy to Use:** Simple command-line interface and a clean Python API. 33 | * **Customizable:** Control the frame rate and fine-tune the VTracer conversion process for optimal results. 34 | * **Network Compression:** SVGs are text-based and highly compressible. Web servers typically use gzip or Brotli compression, *significantly* reducing the actual transfer size of SVG files compared to GIFs (which are already compressed and don't utilize this). This leads to much faster loading times than GIFs. You can see it [here](https://framesvg.romelium.cc/examples.html). 35 | 36 | ## Examples 37 | 38 | There is a dedicated and more better example page [here](https://framesvg.romelium.cc/examples.html) 39 | 40 | The following examples demonstrate the conversion of GIFs (left) to SVGs (right) using `framesvg`. 41 | 42 |

43 | Animated GIF showing code being written on Git 44 | Animated SVG showing code being written on Git 45 | Animated GIF showing code being written on Github 46 | Animated SVG showing code being written on Github 47 | Animated GIF showing code being written on VSCode 48 | Animated SVG showing code being written on VSCode 49 | Animated GIF showing code being written on Sublime Text 50 | Animated SVG showing code being written on Sublime Text 51 |

52 | 53 | ### More Examples 54 | 55 |

56 | Animated GIF of Good Morning greeting 57 | Animated SVG of Good Morning greeting 58 | Animated GIF of a loading icon 59 | Animated SVG of a loading icon 60 | Animated GIF of hands doing a voila gesture 61 | Animated SVG of hands doing a voila gesture 62 |

63 | 64 | ### Complex Examples (Transparent Backgrounds) 65 | 66 | These examples demonstrate `binary` color mode. All bright colors in `binary` color mode turns transparent. (If they appear dark, it is due to the transparency. They will look correct on light backgrounds) 67 | 68 |

69 | Animated GIF of a black and white loop pattern 70 | Animated SVG of a black and white loop pattern 71 | Animated GIF of another black and white loop pattern 72 | Animated SVG of another black and white loop pattern 73 |

74 | 75 | ## Installation 76 | 77 | ### Using pipx (Recommended for CLI-only use) 78 | 79 | If you primarily intend to use `framesvg` as a command-line tool (and don't need the Python library for development), `pipx` is the recommended installation method. `pipx` installs Python applications in isolated environments, preventing dependency conflicts with other projects. 80 | 81 | ```bash 82 | pipx install framesvg 83 | ``` 84 | 85 | To install `pipx` if you don't already have it: 86 | 87 | ```bash 88 | python3 -m pip install --user pipx 89 | python3 -m pipx ensurepath 90 | ``` 91 | 92 | (You may need to restart your shell after installing `pipx`.) 93 | 94 | ### Using pip 95 | 96 | The easiest way to install `framesvg` is via pip: 97 | 98 | ```bash 99 | pip install framesvg 100 | ``` 101 | 102 | This installs both the command-line tool and the Python library. 103 | 104 | ### From Source 105 | 106 | 1. **Clone the repository:** 107 | 108 | ```bash 109 | git clone https://github.com/romelium/framesvg 110 | cd framesvg 111 | ``` 112 | 113 | 2. **Install:** 114 | 115 | ```bash 116 | pip install . 117 | ``` 118 | 119 | ## Usage 120 | 121 | ### Command-Line Interface 122 | 123 | ```bash 124 | framesvg input.gif [output.svg] [options] 125 | ``` 126 | 127 | * **`input.gif`:** (Required) Path to the input GIF file. 128 | * **`output.svg`:** (Optional) Path to save the output SVG file. If omitted, the output file will have the same name as the input, but with a `.svg` extension. 129 | 130 | **Options:** 131 | 132 | * **`-f`, `--fps `:** 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). 133 | * **`-l`, `--log-level `:** Sets the logging level. (Default: INFO). Choices: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`, `NONE`. `DEBUG` provides detailed output for troubleshooting. 134 | 135 | * **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. 136 | 137 | * `-c`, `--colormode `: Color mode. (Default: `color`). Choices: `color`, `binary`. 138 | * `-i`, `--hierarchical `: Hierarchy mode. (Default: `stacked`). Choices: `stacked`, `cutout`. 139 | * `-m`, `--mode `: Conversion mode. (Default: `polygon`). Choices: `spline`, `polygon`, `none`. `spline` creates smoother curves, but `polygon` often results in smaller files. 140 | * `-s`, `--filter-speckle `: Reduces noise and small details. (Default: 4). *This is a key parameter for controlling file size.* Higher values = smaller files, but less detail. 141 | * `-p`, `--color-precision `: Number of significant bits for color quantization. (Default: 8). Lower values = smaller files, but fewer colors. 142 | * `-d`, `--layer-difference `: Controls the number of layers. (Default: 16). Higher values can reduce file size. 143 | * `--corner-threshold `: Angle threshold for corner detection. (Default: 60). 144 | * `--length-threshold `: Minimum path length. (Default: 4.0). 145 | * `--max-iterations `: Maximum number of optimization iterations. (Default: 10). 146 | * `--splice-threshold `: Angle threshold for splitting splines. (Default: 45). 147 | * `--path-precision `: Number of decimal places for path coordinates. (Default: 8). 148 | 149 | **Command-Line Examples:** 150 | 151 | ```bash 152 | # Basic conversion with default settings 153 | framesvg input.gif 154 | 155 | # Specify output file and set FPS to 24 156 | framesvg input.gif output.svg -f 24 157 | 158 | # Optimize for smaller file size (less detail) 159 | framesvg input.gif -s 8 -p 3 -d 128 160 | 161 | # Enable debug logging 162 | framesvg input.gif -l DEBUG 163 | ``` 164 | 165 | ### Python API 166 | 167 | ```python 168 | from framesvg import gif_to_animated_svg_write, gif_to_animated_svg 169 | 170 | # Example 1: Convert and save to a file (using GIF's average FPS) 171 | gif_to_animated_svg_write("input.gif", "output.svg", fps=30) 172 | 173 | # Example 2: Get the SVG as a string 174 | animated_svg_string = gif_to_animated_svg("input.gif", fps=12) 175 | print(f"Generated SVG length: {len(animated_svg_string)}") 176 | # ... do something with the string (e.g., save to file, display in a web app) 177 | 178 | # Example 3: Customize VTracer options 179 | custom_options = { 180 | "mode": "spline", 181 | "filter_speckle": 2, 182 | } 183 | gif_to_animated_svg_write("input.gif", "output_custom.svg", vtracer_options=custom_options) 184 | ``` 185 | 186 | ### API Reference 187 | 188 | * **`gif_to_animated_svg_write(gif_path, output_svg_path, vtracer_options=None, fps=10.0, image_loader=None, vtracer_instance=None)`:** 189 | * `gif_path` (str): Path to the input GIF file. 190 | * `output_svg_path` (str): Path to save the output SVG file. 191 | * `vtracer_options` (dict, optional): A dictionary of VTracer options. If `None`, uses `DEFAULT_VTRACER_OPTIONS`. 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. 193 | * `image_loader` (ImageLoader, optional): Custom image loader. 194 | * `vtracer_instance` (VTracer, optional): Custom VTracer instance. 195 | * Raises: `FileNotFoundError`, `NotAnimatedGifError`, `NoValidFramesError`, `DimensionError`, `ExtractionError`, `FramesvgError`, `IsADirectoryError`. 196 | 197 | * **`gif_to_animated_svg(gif_path, vtracer_options=None, fps=10.0, image_loader=None, vtracer_instance=None)`:** 198 | * `gif_path` (str): Path to the input GIF file. 199 | * `vtracer_options` (dict, optional): A dictionary of VTracer options. If `None`, uses `DEFAULT_VTRACER_OPTIONS`. 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. 201 | * `image_loader` (ImageLoader, optional): Custom image loader. 202 | * `vtracer_instance` (VTracer, optional): Custom VTracer instance. 203 | * Returns: The animated SVG as a string. 204 | * Raises: `FileNotFoundError`, `NotAnimatedGifError`, `NoValidFramesError`, `DimensionError`, `ExtractionError`, `FramesvgError`. 205 | 206 | ## Tips for Optimizing Large File Size (> 1MB) 207 | 208 | * **[Online Demo](https://www.visioncortex.org/vtracer/):** Use this to visualize tweaking values. Experiment to find the best balance between size and quality. 209 | * **`filter-speckle`:** *This is the most impactful setting for reducing file size, especially on complex images.* Increasing it removes small details. 210 | * **`--mode polygon`:** Use the default polygon mode unless smooth curves (spline mode) are absolutely necessary. Polygon mode can significantly reduce file size by a factor of 5 or more. 211 | * **`layer-difference`:** Increase this to reduce the number of layers. 212 | * **`color-precision`:** Reduce the number of colors by lowering this value. 213 | 214 | ## Dev 215 | 216 | ### Install Hatch (Recommended) 217 | 218 | follow [this](https://hatch.pypa.io/latest/install) 219 | 220 | or just 221 | 222 | ```bash 223 | pip install hatch 224 | ``` 225 | 226 | ### Format and lint 227 | 228 | ```bash 229 | hatch fmt 230 | ``` 231 | 232 | ### Testing 233 | 234 | ```bash 235 | hatch test 236 | ``` 237 | 238 | ### Other Hatch Commands 239 | 240 | ```bash 241 | hatch -h 242 | ``` 243 | 244 | ## Dev Web app 245 | 246 | ### Install Vercel 247 | 248 | Install the Vercel CLI globally: 249 | 250 | ```bash 251 | npm install -g vercel 252 | ``` 253 | 254 | ### Running Locally 255 | 256 | ```bash 257 | vercel dev 258 | ``` 259 | 260 | **Setup (First Time Only):** When running for the *first* time, you'll be prompted to configure settings. Ensure you set the "In which directory is your code located?" option to `./web`. 261 | 262 | **Note:** The first conversion may take a significant amount of time. This is because the serverless functions need to be built. Subsequent conversions will be faster. 263 | 264 | ### Deploy to Vercel 265 | 266 | ```bash 267 | vercel deploy 268 | ``` 269 | 270 | ## Contributing 271 | 272 | Contributions are welcome! Please submit pull requests or open issues on the [GitHub repository](https://github.com/romelium/framesvg). 273 | -------------------------------------------------------------------------------- /images/Black And White Loop GIF by Pi-Slices.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/Black And White Loop GIF by Pi-Slices.gif -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock Git.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/Code Coding GIF by EscuelaDevRock Git.gif -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock Git.svg: -------------------------------------------------------------------------------- 1 | 2 | Generated Animation 3 | 4 frames at 7.690000 FPS 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock GitHub.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/Code Coding GIF by EscuelaDevRock GitHub.gif -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock GitHub.svg: -------------------------------------------------------------------------------- 1 | 2 | Generated Animation 3 | 4 frames at 7.690000 FPS 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock Sublime.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/Code Coding GIF by EscuelaDevRock Sublime.gif -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock Sublime.svg: -------------------------------------------------------------------------------- 1 | 2 | Generated Animation 3 | 4 frames at 7.690000 FPS 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock VSCode.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/Code Coding GIF by EscuelaDevRock VSCode.gif -------------------------------------------------------------------------------- /images/Code Coding GIF by EscuelaDevRock VSCode.svg: -------------------------------------------------------------------------------- 1 | 2 | Generated Animation 3 | 4 frames at 7.690000 FPS 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /images/Good_Morning_GIF_by_Hello_All.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/Good_Morning_GIF_by_Hello_All.gif -------------------------------------------------------------------------------- /images/black and white loop GIF by Sculpture.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/black and white loop GIF by Sculpture.gif -------------------------------------------------------------------------------- /images/framesvg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/framesvg.gif -------------------------------------------------------------------------------- /images/icon_loading_GIF.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/icon_loading_GIF.gif -------------------------------------------------------------------------------- /images/kyubey.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/kyubey.gif -------------------------------------------------------------------------------- /images/kyubey.svg: -------------------------------------------------------------------------------- 1 | 2 | Generated Animation 3 | 8 frames at 12.000000 FPS 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | -------------------------------------------------------------------------------- /images/voila hands GIF by brontron.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/images/voila hands GIF by brontron.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | version = "0.2.0" 7 | name = "framesvg" 8 | description = "Convert animated GIFs to animated SVGs." 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = {text = "MIT"} 12 | authors = [ 13 | {name = "Romelium", email = "author@romelium.cc" } 14 | ] 15 | maintainers = [ 16 | {name = "Romelium", email = "maintainer@romelium.cc"}, 17 | ] 18 | keywords = ["gif", "svg", "animation", "vector", "vtracer", "image-processing"] 19 | classifiers = [ 20 | "Development Status :: 4 - Beta", 21 | "Intended Audience :: Developers", 22 | "Intended Audience :: End Users/Desktop", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.8", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3.13", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Topic :: Multimedia :: Graphics :: Graphics Conversion", 34 | "Topic :: Multimedia :: Graphics :: Editors :: Vector-Based", 35 | "Topic :: Utilities", 36 | ] 37 | 38 | dependencies = [ 39 | "pillow>=10.0.0", 40 | "vtracer>=0.6.0", 41 | ] 42 | 43 | [project.urls] 44 | Homepage = "https://github.com/romelium/framesvg" 45 | Repository = "https://github.com/romelium/framesvg.git" 46 | Issues = "https://github.com/romelium/framesvg/issues" 47 | 48 | [project.scripts] 49 | framesvg = "framesvg:main" # Create python CLI program in Python scripts 50 | 51 | [tool.hatch.build.targets.wheel] 52 | packages = ["src/framesvg"] 53 | only-include = ["src/framesvg"] 54 | 55 | [tool.hatch.build.targets.sdist] 56 | exclude = [ 57 | "/.github", 58 | "/docs", 59 | "/tests", 60 | "/images", 61 | "/web", 62 | "/.gitignore", 63 | ] 64 | 65 | [[tool.hatch.envs.hatch-test.matrix]] 66 | python = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 67 | 68 | [tool.pytest.ini_options] 69 | addopts = [ 70 | "--import-mode=importlib", 71 | ] 72 | 73 | [tool.ruff.lint.per-file-ignores] 74 | "tests/**/*" = ["S101", "PLR2004"] 75 | "web/**/*" = ["ALL"] 76 | -------------------------------------------------------------------------------- /src/framesvg/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import argparse 4 | import io 5 | import logging 6 | import os 7 | import re 8 | import sys 9 | from typing import Literal, Protocol, TypedDict 10 | 11 | from PIL import Image 12 | 13 | 14 | class VTracerOptions(TypedDict, total=False): 15 | colormode: Literal["color", "binary"] | None 16 | hierarchical: Literal["stacked", "cutout"] | None 17 | mode: Literal["spline", "polygon", "none"] | None 18 | filter_speckle: int | None 19 | color_precision: int | None 20 | layer_difference: int | None 21 | corner_threshold: int | None 22 | length_threshold: float | None 23 | max_iterations: int | None 24 | splice_threshold: int | None 25 | path_precision: int | None 26 | 27 | 28 | FALLBACK_FPS = 10.0 29 | """Fallback FPS if GIF duration cannot be determined.""" 30 | 31 | DEFAULT_VTRACER_OPTIONS: VTracerOptions = { 32 | "colormode": "color", 33 | "hierarchical": "stacked", 34 | "mode": "polygon", 35 | "filter_speckle": 4, 36 | "color_precision": 8, 37 | "layer_difference": 16, 38 | "corner_threshold": 60, 39 | "length_threshold": 4.0, 40 | "max_iterations": 10, 41 | "splice_threshold": 45, 42 | "path_precision": 8, 43 | } 44 | 45 | 46 | class FramesvgError(Exception): 47 | """Base class for exceptions.""" 48 | 49 | 50 | class NotAnimatedGifError(FramesvgError): 51 | """Input GIF is not animated.""" 52 | 53 | def __init__(self, gif_path: str): 54 | super().__init__(f"{gif_path} is not an animated GIF.") 55 | 56 | 57 | class NoValidFramesError(FramesvgError): 58 | """No valid SVG frames generated.""" 59 | 60 | def __init__(self): 61 | super().__init__("No valid SVG frames were generated.") 62 | 63 | 64 | class DimensionError(FramesvgError): 65 | """SVG dimensions could not be determined.""" 66 | 67 | def __init__(self): 68 | super().__init__("Could not determine SVG dimensions.") 69 | 70 | 71 | class ExtractionError(FramesvgError): 72 | """SVG content could not be extracted.""" 73 | 74 | def __init__(self): 75 | super().__init__("Could not extract SVG content.") 76 | 77 | 78 | class FrameOutOfRangeError(FramesvgError): 79 | """Frame out of Range.""" 80 | 81 | def __init__(self, frame_number, max_frames): 82 | super().__init__(f"Frame number {frame_number} is out of range. Must be between 0 and {max_frames -1}") 83 | 84 | 85 | class ImageWrapper(Protocol): 86 | is_animated: bool 87 | n_frames: int 88 | format: str | None 89 | info: dict 90 | 91 | def seek(self, frame: int) -> None: ... 92 | def save(self, fp, img_format) -> None: ... 93 | def close(self) -> None: ... 94 | 95 | 96 | class ImageLoader(Protocol): 97 | def open(self, filepath: str) -> ImageWrapper: ... 98 | 99 | 100 | class VTracer(Protocol): 101 | def convert_raw_image_to_svg(self, image_bytes: bytes, img_format: str, options: VTracerOptions) -> str: ... 102 | 103 | 104 | class PILImageLoader: 105 | def open(self, filepath: str) -> ImageWrapper: 106 | return Image.open(filepath) 107 | 108 | 109 | class DefaultVTracer: 110 | def convert_raw_image_to_svg(self, image_bytes: bytes, img_format: str, options: VTracerOptions) -> str: 111 | import vtracer 112 | 113 | return vtracer.convert_raw_image_to_svg(image_bytes, img_format=img_format, **options) 114 | 115 | 116 | _DEFAULT_IMAGE_LOADER = PILImageLoader() 117 | _DEFAULT_VTRACER = DefaultVTracer() 118 | 119 | 120 | def load_image_wrapper(filepath: str, image_loader: ImageLoader) -> ImageWrapper: 121 | """Loads an image and returns an ImageWrapper.""" 122 | try: 123 | return image_loader.open(filepath) 124 | except FileNotFoundError: 125 | logging.exception("File not found: %s", filepath) 126 | raise 127 | except Exception: 128 | logging.exception("Error loading image: %s", filepath) 129 | raise 130 | 131 | 132 | def is_animated_gif(img: ImageWrapper, filepath: str) -> None: 133 | """Checks if the image is an animated GIF.""" 134 | if not img.is_animated: 135 | raise NotAnimatedGifError(filepath) 136 | 137 | 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 | 166 | def extract_svg_dimensions_from_content(svg_content: str) -> dict[str, int] | None: 167 | """Extracts width and height from SVG.""" 168 | dims: dict[str, int] = {"width": 0, "height": 0} 169 | view_box_pattern = re.compile(r'viewBox=["\'](\d+)\s+(\d+)\s+(\d+)\s+(\d+)["\']') 170 | width_pattern = re.compile(r"]*width=[\"'](\d+)") 171 | height_pattern = re.compile(r"]*height=[\"'](\d+)") 172 | 173 | match = view_box_pattern.search(svg_content) 174 | if match: 175 | dims["width"], dims["height"] = int(match.group(3)), int(match.group(4)) 176 | else: 177 | match_width = width_pattern.search(svg_content) 178 | if match_width: 179 | dims["width"] = int(match_width.group(1)) 180 | match_height = height_pattern.search(svg_content) 181 | if match_height: 182 | dims["height"] = int(match_height.group(1)) 183 | 184 | if dims["width"] <= 0 or dims["height"] <= 0: 185 | return None 186 | return dims 187 | 188 | 189 | def extract_inner_svg_content_from_full_svg(full_svg_content: str) -> str: 190 | """Extracts content within tags.""" 191 | start_pos = full_svg_content.find("", start_pos) + 1 196 | end_pos = full_svg_content.rfind("") 197 | if start_pos == -1 or end_pos == -1: 198 | return "" 199 | 200 | return full_svg_content[start_pos:end_pos] 201 | 202 | 203 | def process_gif_frame( 204 | img: ImageWrapper, 205 | frame_number: int, 206 | vtracer_instance: VTracer, 207 | vtracer_options: VTracerOptions, 208 | ) -> tuple[str, dict[str, int] | None]: 209 | """Processes single GIF frame, converting to SVG.""" 210 | if not 0 <= frame_number < img.n_frames: 211 | raise FrameOutOfRangeError(frame_number, img.n_frames) 212 | 213 | img.seek(frame_number) 214 | with io.BytesIO() as img_byte_arr: 215 | img_byte_arr.name = "temp.gif" 216 | img.save(img_byte_arr, img_format="GIF") 217 | img_bytes = img_byte_arr.getvalue() 218 | 219 | svg_content = vtracer_instance.convert_raw_image_to_svg(img_bytes, img_format="GIF", options=vtracer_options) 220 | dims = extract_svg_dimensions_from_content(svg_content) 221 | inner_svg = extract_inner_svg_content_from_full_svg(svg_content) if dims else "" 222 | 223 | return inner_svg, dims 224 | 225 | 226 | def process_gif_frames( 227 | img: ImageWrapper, 228 | vtracer_instance: VTracer, 229 | vtracer_options: VTracerOptions, 230 | ) -> tuple[list[str], dict[str, int]]: 231 | """Processes all GIF frames.""" 232 | frames: list[str] = [] 233 | max_dims = {"width": 0, "height": 0} 234 | 235 | for i in range(img.n_frames): 236 | inner_svg_content, dims = process_gif_frame(img, i, vtracer_instance, vtracer_options) 237 | 238 | if dims: 239 | max_dims["width"] = max(max_dims["width"], dims["width"]) 240 | max_dims["height"] = max(max_dims["height"], dims["height"]) 241 | if inner_svg_content: 242 | frames.append(inner_svg_content) 243 | 244 | if not frames: 245 | raise NoValidFramesError 246 | 247 | return frames, max_dims 248 | 249 | 250 | def create_animated_svg_string(frames: list[str], max_dims: dict[str, int], fps: float) -> str: 251 | """Generates animated SVG string.""" 252 | if not frames: 253 | msg = "No frames to generate SVG." 254 | 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 258 | frame_duration = 1.0 / fps 259 | total_duration = frame_duration * len(frames) 260 | 261 | svg_str = ( 262 | '\n' 263 | f'' 265 | f"Generated Animation\n" 266 | f"{len(frames)} frames at {fps:.6f} FPS\n" 267 | '\n' 268 | ) 269 | 270 | for i, frame_content in enumerate(frames): 271 | start_fraction = i / len(frames) 272 | end_fraction = (i + 1) / len(frames) 273 | svg_str += ( 274 | f'\n' 275 | f"{frame_content}\n" 276 | f'\n' 279 | "\n" 280 | ) 281 | 282 | svg_str += "\n\n" 283 | return svg_str 284 | 285 | 286 | def save_svg_to_file(svg_string: str, output_path: str) -> None: 287 | """Writes SVG string to file.""" 288 | if os.path.isdir(output_path): 289 | msg = f"'{output_path}' is a directory, not a file." 290 | logging.error(msg) 291 | raise IsADirectoryError(msg) 292 | try: 293 | with open(output_path, "w", encoding="utf-8") as f: 294 | f.write(svg_string) 295 | except Exception: 296 | logging.exception("Error writing SVG to file: %s", output_path) 297 | raise 298 | 299 | 300 | def gif_to_animated_svg( 301 | gif_path: str, 302 | vtracer_options: VTracerOptions | None = None, 303 | fps: float | None = None, 304 | image_loader: ImageLoader | None = None, 305 | vtracer_instance: VTracer | None = None, 306 | ) -> str: 307 | """Main function to convert GIF to animated SVG.""" 308 | image_loader = image_loader or _DEFAULT_IMAGE_LOADER 309 | vtracer_instance = vtracer_instance or _DEFAULT_VTRACER 310 | 311 | options = DEFAULT_VTRACER_OPTIONS.copy() 312 | if vtracer_options: 313 | options.update(vtracer_options) 314 | 315 | img = load_image_wrapper(gif_path, image_loader) 316 | try: 317 | 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 | frames, max_dims = process_gif_frames(img, vtracer_instance, options) 329 | return create_animated_svg_string(frames, max_dims, effective_fps) 330 | finally: 331 | img.close() 332 | 333 | 334 | def gif_to_animated_svg_write( 335 | gif_path: str, 336 | output_svg_path: str, 337 | vtracer_options: VTracerOptions | None = None, 338 | fps: float | None = None, 339 | image_loader: ImageLoader | None = None, 340 | vtracer_instance: VTracer | None = None, 341 | ) -> None: 342 | """Converts and writes to file.""" 343 | svg = gif_to_animated_svg(gif_path, vtracer_options, fps, image_loader, vtracer_instance) 344 | save_svg_to_file(svg, output_svg_path) 345 | 346 | 347 | def validate_positive_int(value: str) -> int: 348 | """Validates positive integer input.""" 349 | try: 350 | int_value = int(value) 351 | if int_value <= 0: 352 | msg = f"{value} is not a positive integer." 353 | raise argparse.ArgumentTypeError(msg) 354 | except ValueError as e: 355 | msg = f"{value} is not a valid integer." 356 | raise argparse.ArgumentTypeError(msg) from e 357 | else: 358 | return int_value 359 | 360 | 361 | def validate_positive_float(value: str) -> float: 362 | """Validates positive float input.""" 363 | try: 364 | float_value = float(value) 365 | if float_value <= 0: 366 | msg = f"{value} is not a positive float." 367 | raise argparse.ArgumentTypeError(msg) 368 | except ValueError as e: 369 | msg = f"{value} is not a valid float." 370 | raise argparse.ArgumentTypeError(msg) from e 371 | else: 372 | return float_value 373 | 374 | 375 | def parse_cli_arguments(args: list[str]) -> argparse.Namespace: 376 | """Parses command-line arguments.""" 377 | parser = argparse.ArgumentParser(description="Convert an animated GIF to an animated SVG.") 378 | parser.add_argument("gif_path", help="Path to the input GIF file.") 379 | parser.add_argument( 380 | "output_svg_path", 381 | nargs="?", 382 | help="Output. Defaults to input filename with .svg.", 383 | ) 384 | parser.add_argument( 385 | "-f", 386 | "--fps", 387 | type=validate_positive_float, 388 | default=None, # Default is now None, handled later 389 | help=f"Frames per second. (Default: Use GIF average FPS, fallback: {FALLBACK_FPS}).", 390 | ) 391 | parser.add_argument( 392 | "-l", 393 | "--log-level", 394 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "NONE"], 395 | default="INFO", 396 | help="Set the logging level (default: INFO).", 397 | ) 398 | 399 | # VTracer options 400 | parser.add_argument("-c", "--colormode", choices=["color", "binary"], help="Color mode.") 401 | parser.add_argument("-i", "--hierarchical", choices=["stacked", "cutout"], help="Hierarchical mode.") 402 | parser.add_argument("-m", "--mode", choices=["spline", "polygon", "none"], help="Mode.") 403 | parser.add_argument("-s", "--filter-speckle", type=validate_positive_int, help="Filter speckle.") 404 | parser.add_argument("-p", "--color-precision", type=validate_positive_int, help="Color precision.") 405 | parser.add_argument("-d", "--layer-difference", type=validate_positive_int, help="Layer difference.") 406 | parser.add_argument("--corner-threshold", type=validate_positive_int, help="Corner threshold.") 407 | parser.add_argument("--length-threshold", type=validate_positive_float, help="Length threshold.") 408 | parser.add_argument("--max-iterations", type=validate_positive_int, help="Max iterations.") 409 | parser.add_argument("--splice-threshold", type=validate_positive_int, help="Splice threshold.") 410 | parser.add_argument("--path-precision", type=validate_positive_int, help="Path precision.") 411 | 412 | return parser.parse_args(args) 413 | 414 | 415 | def main() -> None: 416 | """Main entry point.""" 417 | try: 418 | args = parse_cli_arguments(sys.argv[1:]) 419 | 420 | if args.log_level != "NONE": 421 | logging.basicConfig(level=args.log_level, format="%(levelname)s: %(message)s") 422 | 423 | output_path = args.output_svg_path 424 | if output_path is None: 425 | base, _ = os.path.splitext(args.gif_path) 426 | output_path = base + ".svg" 427 | 428 | vtracer_options = { 429 | k: v 430 | for k, v in vars(args).items() 431 | if k 432 | in [ 433 | "colormode", 434 | "hierarchical", 435 | "mode", 436 | "filter_speckle", 437 | "color_precision", 438 | "layer_difference", 439 | "corner_threshold", 440 | "length_threshold", 441 | "max_iterations", 442 | "splice_threshold", 443 | "path_precision", 444 | ] 445 | and v is not None 446 | } 447 | 448 | gif_to_animated_svg_write(args.gif_path, output_path, vtracer_options=vtracer_options, fps=args.fps) 449 | logging.info("Converted %s to %s", args.gif_path, output_path) 450 | except SystemExit as e: 451 | sys.exit(e.code) 452 | except FramesvgError: 453 | # Specific, expected errors are logged within the functions 454 | # Log general message here for unexpected FramesvgError subclasses 455 | logging.exception("FrameSVG processing failed. Check previous logs for details.") 456 | sys.exit(1) 457 | except Exception: 458 | logging.exception("An unexpected error occurred.") 459 | sys.exit(1) 460 | -------------------------------------------------------------------------------- /src/framesvg/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Romelium/FrameSVG/15a33b6044341aeb4c73344a2eb1e82598691fd6/src/framesvg/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Placeholder for test discovery 2 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | from typing import TYPE_CHECKING 6 | from unittest.mock import Mock 7 | 8 | import pytest 9 | from PIL import Image, ImageCms, ImageDraw, UnidentifiedImageError 10 | 11 | from framesvg import ( 12 | DEFAULT_VTRACER_OPTIONS, 13 | FALLBACK_FPS, 14 | FrameOutOfRangeError, 15 | NotAnimatedGifError, 16 | NoValidFramesError, 17 | VTracerOptions, 18 | _calculate_gif_average_fps, 19 | create_animated_svg_string, 20 | extract_inner_svg_content_from_full_svg, 21 | extract_svg_dimensions_from_content, 22 | gif_to_animated_svg, 23 | gif_to_animated_svg_write, 24 | is_animated_gif, 25 | load_image_wrapper, 26 | parse_cli_arguments, 27 | process_gif_frame, 28 | process_gif_frames, 29 | save_svg_to_file, 30 | ) 31 | 32 | if TYPE_CHECKING: 33 | from pathlib import Path 34 | 35 | 36 | def create_mock_image( 37 | *, 38 | is_animated: bool = True, 39 | n_frames: int = 2, 40 | width: int = 100, 41 | height: int = 100, 42 | img_format: str = "GIF", 43 | close_raises: bool = False, 44 | durations: list[int] | None = None, 45 | ): 46 | """Creates a mock Image object.""" 47 | mock_img = Mock() 48 | mock_img.is_animated = is_animated 49 | mock_img.n_frames = n_frames 50 | mock_img.width = width 51 | mock_img.height = height 52 | mock_img.format = img_format 53 | 54 | # Simulate frame-specific info using side_effect on seek 55 | if durations is None: 56 | durations = [100] * n_frames # Default duration if not specified 57 | 58 | def seek_side_effect(frame_index): 59 | if not durations or frame_index >= n_frames: # Handle empty durations or out-of-bounds seek 60 | mock_img.info = {} # Or some default info 61 | return 62 | # Use modulo for safety, though frame_index should be < n_frames 63 | current_duration = durations[frame_index % len(durations)] 64 | mock_img.info = {"duration": current_duration} 65 | 66 | mock_img.seek = Mock(side_effect=seek_side_effect) 67 | if n_frames > 0: # Initialize info for frame 0 only if frames exist 68 | mock_img.seek(0) 69 | else: 70 | mock_img.info = {} # Ensure info is initialized even with 0 frames 71 | 72 | if close_raises: 73 | mock_img.close.side_effect = Exception("Mock close error") 74 | else: 75 | mock_img.close = Mock() # Ensure close is callable 76 | return mock_img 77 | 78 | 79 | def create_mock_vtracer( 80 | return_svg: str | list[str] = '', 81 | raise_error: bool = False, # noqa: FBT001 FBT002 82 | ): 83 | """Creates a mock VTracer.""" 84 | mock_vtracer = Mock() 85 | if raise_error: 86 | mock_vtracer.convert_raw_image_to_svg.side_effect = Exception("VTracer error") 87 | elif isinstance(return_svg, list): 88 | mock_vtracer.convert_raw_image_to_svg.side_effect = return_svg 89 | else: 90 | mock_vtracer.convert_raw_image_to_svg.return_value = return_svg 91 | return mock_vtracer 92 | 93 | 94 | def create_temp_gif( 95 | tmp_path: Path, 96 | *, 97 | is_animated: bool = True, 98 | num_frames: int = 2, 99 | widths: tuple[int, ...] = (100,), 100 | heights: tuple[int, ...] = (100,), 101 | durations: list[int] | None = None, 102 | corrupt: bool = False, 103 | use_palette: bool = False, 104 | add_transparency: bool = False, 105 | color_profile: bool = False, 106 | ) -> str: 107 | """Creates a temp GIF.""" 108 | gif_path = tmp_path / "test.gif" 109 | images = [] 110 | 111 | actual_num_frames = max(1, num_frames if is_animated else 1) 112 | 113 | if durations is None: 114 | save_durations = [100] * actual_num_frames # Default duration for saving 115 | elif len(durations) == actual_num_frames: 116 | save_durations = durations 117 | else: 118 | # Adjust durations list to match the number of frames being generated/saved 119 | # Repeat the pattern or truncate as needed 120 | save_durations = (durations * (actual_num_frames // len(durations) + 1))[:actual_num_frames] 121 | 122 | for i in range(actual_num_frames): 123 | width = widths[i % len(widths)] 124 | height = heights[i % len(heights)] 125 | if use_palette: 126 | img = Image.new("P", (width, height), color=0) 127 | img.putpalette( 128 | [ 129 | 0, 130 | 0, 131 | 0, # Black 132 | 255, 133 | 255, 134 | 255, # White 135 | 255, 136 | 0, 137 | 0, # Red 138 | 0, 139 | 255, 140 | 0, # Green 141 | 0, 142 | 0, 143 | 255, # Blue 144 | ] 145 | * 51 146 | ) # Repeat the palette to fill 256 entries 147 | 148 | else: 149 | img = Image.new( 150 | "RGB", 151 | (width, height), 152 | color=(i * 50 % 256, i * 100 % 256, i * 150 % 256), 153 | ) 154 | 155 | if add_transparency and i % 2 == 0: # Make every other frame have some transparency 156 | alpha = Image.new("L", (width, height), color=128) # semi-transparent 157 | if use_palette: 158 | img.putalpha(alpha) # P mode needs special handling for alpha 159 | else: 160 | img = img.convert("RGBA") 161 | img.putalpha(alpha) 162 | 163 | if color_profile: 164 | # Create a simple sRGB color profile 165 | profile = ImageCms.createProfile("sRGB") 166 | img.info["icc_profile"] = ImageCms.ImageCmsProfile(profile).tobytes() 167 | 168 | draw = ImageDraw.Draw(img) 169 | draw.text((10, 10), text=f"frame {i}", fill=(0, 0, 0)) 170 | images.append(img) 171 | 172 | if is_animated and actual_num_frames > 1: 173 | images[0].save( 174 | gif_path, 175 | "GIF", 176 | save_all=True, 177 | append_images=images[1:], 178 | duration=save_durations, # Use the adjusted list 179 | loop=0, 180 | transparency=0 if add_transparency else None, # Specify transparency color index 181 | ) 182 | elif images: # Save single frame if not animated or only one frame 183 | images[0].save(gif_path, "GIF") 184 | else: 185 | # Handle case where no images were generated (e.g., num_frames=0) 186 | # Create a minimal valid GIF or raise an error? 187 | # For now, let it potentially fail if Pillow can't save an empty list 188 | pass 189 | 190 | if corrupt: 191 | with open(gif_path, "wb") as f: 192 | f.write(b"CORRUPT") 193 | 194 | return str(gif_path) 195 | 196 | 197 | @pytest.fixture 198 | def sample_svg_content(): 199 | return ( 200 | '\n' 201 | '\n' 202 | '\n' 203 | "" 204 | ) 205 | 206 | 207 | @pytest.fixture 208 | def sample_frames(): 209 | return [ 210 | '', 211 | '', 212 | '', 213 | ] 214 | 215 | 216 | @pytest.fixture(params=[True, False]) 217 | def animated_gif_state(request): 218 | return request.param 219 | 220 | 221 | @pytest.fixture(params=[1, 2, 5, 10]) 222 | def frame_number_count(request): 223 | return request.param 224 | 225 | 226 | @pytest.fixture(params=[(100, 100), (200, 150), (150, 200), (300, 300)]) 227 | def image_dimensions(request): 228 | return request.param 229 | 230 | 231 | @pytest.fixture(params=[[50], [100], [200], [50, 150], [100, 100, 0, 200]]) 232 | def duration_values(request): 233 | return request.param 234 | 235 | 236 | @pytest.fixture 237 | def mock_image_instance(animated_gif_state, frame_number_count, image_dimensions): 238 | width, height = image_dimensions 239 | return create_mock_image(is_animated=animated_gif_state, n_frames=frame_number_count, width=width, height=height) 240 | 241 | 242 | @pytest.fixture 243 | def mock_vtracer_instance_for_tests(): 244 | return create_mock_vtracer() 245 | 246 | 247 | @pytest.fixture 248 | def mock_image_loader_instance(): 249 | return Mock() 250 | 251 | 252 | def test_load_image_wrapper_success(tmp_path, duration_values): 253 | # Pass num_frames matching the duration list length to create_temp_gif 254 | gif_path = create_temp_gif(tmp_path, num_frames=len(duration_values), durations=duration_values) 255 | 256 | # Create mock objects locally 257 | mock_loader = Mock() 258 | # Use the same durations for the mock image as the real GIF 259 | mock_image = create_mock_image( 260 | is_animated=True, img_format="GIF", n_frames=len(duration_values), durations=duration_values 261 | ) 262 | mock_loader.open.return_value = mock_image 263 | 264 | img_wrapper = load_image_wrapper(gif_path, mock_loader) 265 | 266 | assert img_wrapper.is_animated 267 | assert img_wrapper.format == "GIF" # check the format too. 268 | mock_loader.open.assert_called_once_with(gif_path) 269 | # Check if info (duration) is accessible after loading 270 | img_wrapper.seek(0) 271 | assert "duration" in img_wrapper.info 272 | assert img_wrapper.info["duration"] == duration_values[0] 273 | 274 | 275 | def test_load_image_wrapper_file_not_found(): 276 | mock_loader = Mock() 277 | mock_loader.open.side_effect = FileNotFoundError 278 | with pytest.raises(FileNotFoundError): 279 | load_image_wrapper("nonexistent.gif", mock_loader) 280 | 281 | 282 | def test_load_image_wrapper_general_exception(tmp_path): 283 | mock_loader = Mock() 284 | gif_path = create_temp_gif(tmp_path) 285 | mock_loader.open.side_effect = Exception("Some error") 286 | with pytest.raises(Exception, match="Some error"): # Keep generic, as PIL can raise many 287 | load_image_wrapper(gif_path, mock_loader) 288 | 289 | 290 | def test_load_image_wrapper_corrupt_file(tmp_path): 291 | mock_loader = Mock() 292 | gif_path = create_temp_gif(tmp_path, corrupt=True) 293 | mock_loader.open.side_effect = UnidentifiedImageError 294 | with pytest.raises(UnidentifiedImageError): 295 | load_image_wrapper(gif_path, mock_loader) 296 | 297 | 298 | def test_load_image_wrapper_unsupported_format(tmp_path): 299 | mock_loader = Mock() 300 | # Create a text file with .gif extension 301 | path = tmp_path / "fake.gif" # type: ignore 302 | path.write_text("Not a GIF") # type: ignore 303 | mock_loader.open.side_effect = Image.UnidentifiedImageError 304 | with pytest.raises(Image.UnidentifiedImageError): 305 | load_image_wrapper(str(path), mock_loader) 306 | 307 | 308 | @pytest.mark.parametrize("system_error", [PermissionError, OSError]) 309 | def test_load_image_wrapper_system_errors(system_error): 310 | mock_loader = Mock() 311 | mock_loader.open.side_effect = system_error 312 | with pytest.raises(system_error): 313 | load_image_wrapper("test.gif", mock_loader) 314 | 315 | 316 | @pytest.mark.parametrize( 317 | ("svg_str_input", "expected_output"), 318 | [ 319 | ('', {"width": 100, "height": 200}), 320 | ('', {"width": 300, "height": 400}), 321 | ( 322 | '', 323 | {"width": 200, "height": 300}, 324 | ), 325 | ( 326 | '', 327 | {"width": 200, "height": 300}, 328 | ), 329 | ("", None), 330 | ('', None), 331 | ('', None), 332 | ('', None), 333 | ('', None), 334 | ], 335 | ) 336 | def test_extract_svg_dimensions_from_content_variations(svg_str_input, expected_output): 337 | if expected_output is None: 338 | assert extract_svg_dimensions_from_content(svg_str_input) is None 339 | else: 340 | assert extract_svg_dimensions_from_content(svg_str_input) == expected_output 341 | 342 | 343 | @pytest.mark.parametrize( 344 | ("full_svg_input", "expected_inner_content"), 345 | [ 346 | ("content", "content"), 347 | ("", ""), 348 | ("nestedcontent", "nestedcontent"), 349 | ("not svg content", ""), 350 | ("unclosed", ""), 351 | ("", ""), 352 | ], 353 | ) 354 | def test_extract_inner_svg_content_from_full_svg_variations(full_svg_input, expected_inner_content): 355 | assert extract_inner_svg_content_from_full_svg(full_svg_input) == expected_inner_content 356 | 357 | 358 | def test_process_gif_frame_success(mock_image_instance, mock_vtracer_instance_for_tests): 359 | inner_svg, dims = process_gif_frame( 360 | mock_image_instance, 0, mock_vtracer_instance_for_tests, DEFAULT_VTRACER_OPTIONS 361 | ) 362 | assert isinstance(inner_svg, str) 363 | assert isinstance(dims, dict) 364 | assert "width" in dims 365 | assert "height" in dims 366 | 367 | 368 | def test_process_gif_frame_vtracer_error(mock_image_instance, mock_vtracer_instance_for_tests): 369 | mock_vtracer_instance_for_tests.convert_raw_image_to_svg.side_effect = Exception("Simulated VTracer error") 370 | with pytest.raises(Exception, match="Simulated VTracer error"): 371 | process_gif_frame(mock_image_instance, 0, mock_vtracer_instance_for_tests, DEFAULT_VTRACER_OPTIONS) 372 | 373 | 374 | @pytest.mark.parametrize("selected_frame", [-1, 0, 1, 2]) 375 | def test_process_gif_frame_number_variations(mock_image_instance, mock_vtracer_instance_for_tests, selected_frame): 376 | if 0 <= selected_frame < mock_image_instance.n_frames: 377 | inner_svg, dims = process_gif_frame( 378 | mock_image_instance, 379 | selected_frame, 380 | mock_vtracer_instance_for_tests, 381 | DEFAULT_VTRACER_OPTIONS, 382 | ) 383 | assert isinstance(inner_svg, str) 384 | assert isinstance(dims, dict) 385 | else: 386 | with pytest.raises(FrameOutOfRangeError): 387 | process_gif_frame( 388 | mock_image_instance, 389 | selected_frame, 390 | mock_vtracer_instance_for_tests, 391 | DEFAULT_VTRACER_OPTIONS, 392 | ) 393 | 394 | 395 | def test_check_if_animated_gif_positive(mock_image_instance): 396 | mock_image_instance.is_animated = True # Ensure it's set to True 397 | # Test should pass without raising an exception 398 | is_animated_gif(mock_image_instance, "test.gif") 399 | 400 | 401 | def test_check_if_animated_gif_negative(mock_image_instance): 402 | mock_image_instance.is_animated = False # Set to False 403 | with pytest.raises(NotAnimatedGifError, match="test.gif is not an animated GIF."): 404 | is_animated_gif(mock_image_instance, "test.gif") 405 | 406 | 407 | def test_create_animated_svg_string_comprehensive_tests(sample_frames): 408 | fps_list = [1.0, 10.0, 24.0, 60.0] 409 | dimension_list = [ 410 | {"width": 100, "height": 100}, 411 | {"width": 200, "height": 150}, 412 | {"width": 1920, "height": 1080}, 413 | ] 414 | 415 | for test_fps in fps_list: 416 | for test_dims in dimension_list: 417 | svg_string_result = create_animated_svg_string(sample_frames, test_dims, test_fps) 418 | 419 | assert '' in svg_string_result 420 | 421 | assert f'width="{test_dims["width"]}"' in svg_string_result 422 | assert f'height="{test_dims["height"]}"' in svg_string_result 423 | assert f'viewBox="0 0 {test_dims["width"]} {test_dims["height"]}"' in svg_string_result 424 | 425 | expected_duration = len(sample_frames) / test_fps 426 | assert f'dur="{expected_duration:.6f}s"' in svg_string_result 427 | 428 | for frame_content in sample_frames: 429 | assert frame_content in svg_string_result 430 | 431 | 432 | def test_create_animated_svg_string_edge_cases_tests(): 433 | test_dims = {"width": 100, "height": 100} 434 | 435 | with pytest.raises(ValueError, match="No frames to generate SVG."): # type: ignore 436 | create_animated_svg_string([], test_dims, FALLBACK_FPS) 437 | 438 | single_frame_svg = create_animated_svg_string([""], test_dims, FALLBACK_FPS) 439 | assert "" in single_frame_svg 440 | assert 'repeatCount="indefinite"' in single_frame_svg 441 | 442 | high_fps_svg = create_animated_svg_string([""], test_dims, 1000.0) 443 | assert 'dur="0.001000s"' in high_fps_svg 444 | 445 | low_fps_svg = create_animated_svg_string([""], test_dims, 0.1) 446 | assert 'dur="10.000000s"' in low_fps_svg 447 | 448 | 449 | def test_process_gif_frames_integration_tests(mock_image_instance, mock_vtracer_instance_for_tests): 450 | frames, max_dims = process_gif_frames(mock_image_instance, mock_vtracer_instance_for_tests, DEFAULT_VTRACER_OPTIONS) 451 | assert isinstance(frames, list) 452 | assert isinstance(max_dims, dict) 453 | assert len(frames) > 0 454 | assert all(isinstance(frame, str) for frame in frames) 455 | assert max_dims["width"] > 0 456 | assert max_dims["height"] > 0 457 | 458 | mock_image_instance.n_frames = 0 459 | with pytest.raises(NoValidFramesError): 460 | process_gif_frames(mock_image_instance, mock_vtracer_instance_for_tests, DEFAULT_VTRACER_OPTIONS) 461 | 462 | mock_image_instance.n_frames = 2 # Restore 463 | 464 | mock_vtracer = create_mock_vtracer(return_svg="") 465 | with pytest.raises(NoValidFramesError): 466 | process_gif_frames(mock_image_instance, mock_vtracer, DEFAULT_VTRACER_OPTIONS) 467 | 468 | mock_vtracer = create_mock_vtracer(return_svg=['', ""]) 469 | frames, max_dims = process_gif_frames(mock_image_instance, mock_vtracer, DEFAULT_VTRACER_OPTIONS) 470 | assert len(frames) == 1 471 | 472 | 473 | def test_gif_to_animated_svg_comprehensive( 474 | tmp_path, mock_image_loader_instance, mock_vtracer_instance_for_tests, caplog 475 | ): 476 | frame_counts = [2, 5, 10] 477 | dimension_sets = [(100, 100), (200, 150), (150, 200)] 478 | fps_values = [10.0, 24.0, 60.0] 479 | duration_sets = [[100], [50, 150], [100, 100, 100]] # Different duration patterns 480 | vtracer_options_set: VTracerOptions = { # type: ignore 481 | "colormode": "color", 482 | "filter_speckle": 4, 483 | "corner_threshold": 60, 484 | } 485 | 486 | for num_frames in frame_counts: 487 | for width, height in dimension_sets: 488 | for fps in fps_values: 489 | for durations in duration_sets: 490 | # Ensure durations list matches num_frames for simplicity in test setup 491 | test_durations = (durations * (num_frames // len(durations) + 1))[:num_frames] 492 | gif_path = create_temp_gif( 493 | tmp_path, num_frames=num_frames, widths=(width,), heights=(height,), durations=test_durations 494 | ) 495 | 496 | # --- Test with explicit FPS --- 497 | mock_image = create_mock_image( 498 | is_animated=True, n_frames=num_frames, width=width, height=height, durations=test_durations 499 | ) 500 | mock_image_loader_instance.open.return_value = mock_image 501 | 502 | svg_string_explicit_fps = gif_to_animated_svg( 503 | gif_path, 504 | vtracer_options=vtracer_options_set, 505 | fps=fps, # Explicit FPS 506 | image_loader=mock_image_loader_instance, 507 | vtracer_instance=mock_vtracer_instance_for_tests, 508 | ) 509 | 510 | assert svg_string_explicit_fps.startswith("" in svg_string_explicit_fps 513 | assert f"{num_frames} frames at {fps:.6f} FPS" in svg_string_explicit_fps 514 | assert 'repeatCount="indefinite"' in svg_string_explicit_fps 515 | mock_image_loader_instance.open.assert_called_with(gif_path) 516 | mock_image.close.assert_called_once() # Ensure image is closed 517 | 518 | # --- Test with default FPS (calculated from GIF) --- 519 | mock_image_loader_instance.reset_mock() 520 | mock_image = create_mock_image( 521 | is_animated=True, n_frames=num_frames, width=width, height=height, durations=test_durations 522 | ) 523 | mock_image_loader_instance.open.return_value = mock_image 524 | caplog.clear() 525 | with caplog.at_level(logging.INFO): 526 | svg_string_default_fps = gif_to_animated_svg( 527 | gif_path, 528 | vtracer_options=vtracer_options_set, 529 | fps=None, # Use default (calculate) 530 | image_loader=mock_image_loader_instance, 531 | vtracer_instance=mock_vtracer_instance_for_tests, 532 | ) 533 | 534 | # Calculate expected average FPS using the same logic as the function 535 | total_duration_ms_calc = sum(d if d > 0 else 100 for d in test_durations) 536 | valid_frames_calc = len(test_durations) 537 | expected_fps = ( 538 | (valid_frames_calc / (total_duration_ms_calc / 1000.0)) 539 | if total_duration_ms_calc > 0 540 | else FALLBACK_FPS 541 | ) 542 | 543 | assert svg_string_default_fps.startswith("" in svg_string_default_fps 546 | assert f"{num_frames} frames at {expected_fps:.6f} FPS" in svg_string_default_fps 547 | assert 'repeatCount="indefinite"' in svg_string_default_fps 548 | assert f"Using calculated average FPS: {expected_fps:.2f}" in caplog.text 549 | mock_image_loader_instance.open.assert_called_with(gif_path) 550 | mock_image.close.assert_called_once() # Ensure image is closed 551 | 552 | 553 | def test_gif_to_animated_svg_error_handling(tmp_path): 554 | gif_path = create_temp_gif(tmp_path, is_animated=False) 555 | with pytest.raises(NotAnimatedGifError): 556 | gif_to_animated_svg(gif_path) 557 | 558 | gif_path = create_temp_gif(tmp_path, corrupt=True) 559 | with pytest.raises(Image.UnidentifiedImageError): 560 | gif_to_animated_svg(gif_path) 561 | 562 | with pytest.raises(FileNotFoundError): 563 | gif_to_animated_svg("nonexistent.gif") 564 | 565 | 566 | @pytest.mark.parametrize( 567 | ("cli_args", "expected_parsed_args"), 568 | [ 569 | ( 570 | ["input.gif"], 571 | { 572 | "gif_path": "input.gif", 573 | "output_svg_path": None, 574 | "fps": None, # Default is now None 575 | "log_level": "INFO", 576 | }, 577 | ), 578 | ( 579 | ["input.gif", "output.svg"], 580 | { 581 | "gif_path": "input.gif", 582 | "output_svg_path": "output.svg", 583 | "fps": None, 584 | "log_level": "INFO", 585 | }, 586 | ), 587 | ( 588 | ["input.gif", "--fps", "30"], 589 | { 590 | "gif_path": "input.gif", 591 | "output_svg_path": None, 592 | "fps": 30.0, 593 | "log_level": "INFO", 594 | }, 595 | ), 596 | ( 597 | ["input.gif", "--log-level", "DEBUG"], 598 | { 599 | "gif_path": "input.gif", 600 | "output_svg_path": None, 601 | "fps": None, 602 | "log_level": "DEBUG", 603 | }, 604 | ), 605 | ( 606 | ["input.gif", "--colormode", "binary", "--filter-speckle", "2"], 607 | { 608 | "gif_path": "input.gif", 609 | "output_svg_path": None, 610 | "fps": None, 611 | "log_level": "INFO", 612 | "colormode": "binary", 613 | "filter_speckle": 2, 614 | }, 615 | ), 616 | ( 617 | [ 618 | "input.gif", 619 | "output.svg", 620 | "--fps", 621 | "60", 622 | "--log-level", 623 | "ERROR", 624 | "--mode", 625 | "polygon", 626 | ], 627 | { 628 | "gif_path": "input.gif", 629 | "output_svg_path": "output.svg", 630 | "fps": 60.0, 631 | "log_level": "ERROR", 632 | "mode": "polygon", 633 | }, 634 | ), 635 | ( 636 | ["input.gif", "-f", "24"], 637 | { 638 | "gif_path": "input.gif", 639 | "output_svg_path": None, 640 | "fps": 24.0, 641 | "log_level": "INFO", 642 | }, 643 | ), 644 | ( 645 | [ 646 | "input.gif", 647 | "--colormode", 648 | "color", 649 | "--hierarchical", 650 | "stacked", 651 | "--mode", 652 | "spline", 653 | "--filter-speckle", 654 | "4", 655 | "--color-precision", 656 | "6", 657 | "--layer-difference", 658 | "16", 659 | "--corner-threshold", 660 | "100", 661 | "--length-threshold", 662 | "4.0", 663 | "--max-iterations", 664 | "10", 665 | "--splice-threshold", 666 | "45", 667 | "--path-precision", 668 | "8", 669 | ], 670 | { 671 | "gif_path": "input.gif", 672 | "output_svg_path": None, 673 | "fps": None, 674 | "log_level": "INFO", 675 | "colormode": "color", 676 | "hierarchical": "stacked", 677 | "mode": "spline", 678 | "filter_speckle": 4, 679 | "color_precision": 6, 680 | "layer_difference": 16, 681 | "corner_threshold": 100, 682 | "length_threshold": 4.0, 683 | "max_iterations": 10, 684 | "splice_threshold": 45, 685 | "path_precision": 8, 686 | }, 687 | ), 688 | ( 689 | ["input.gif", "output file.svg"], 690 | { 691 | "gif_path": "input.gif", 692 | "output_svg_path": "output file.svg", 693 | "fps": None, 694 | "log_level": "INFO", 695 | }, 696 | ), 697 | ( 698 | ["input.gif", "--fps", "abc"], 699 | { 700 | "gif_path": "input.gif", 701 | "fps": "abc", 702 | "output_svg_path": None, 703 | "log_level": "INFO", 704 | }, 705 | ), # Should raise error. 706 | ], 707 | ) 708 | def test_parse_cli_arguments_comprehensive(cli_args, expected_parsed_args): 709 | if cli_args == []: 710 | with pytest.raises(SystemExit): 711 | parse_cli_arguments(cli_args) 712 | return 713 | 714 | if ( 715 | "fps" in expected_parsed_args 716 | and isinstance(expected_parsed_args["fps"], str) 717 | and expected_parsed_args["fps"] == "abc" 718 | ): 719 | with pytest.raises(SystemExit): 720 | parse_cli_arguments(cli_args) 721 | return 722 | 723 | parsed_args = parse_cli_arguments(cli_args) 724 | parsed_args_dict = vars(parsed_args) 725 | # Check only keys present in expected_parsed_args 726 | for key, expected_value in expected_parsed_args.items(): 727 | assert key in parsed_args_dict, f"Expected key '{key}' not found in parsed args" 728 | assert ( 729 | parsed_args_dict[key] == expected_value 730 | ), f"Mismatch for key '{key}': expected {expected_value}, got {parsed_args_dict[key]}" 731 | 732 | 733 | @pytest.mark.parametrize( 734 | ("cli_arguments", "expected_error_message"), 735 | [ 736 | ([], "the following arguments are required: gif_path"), 737 | (["in.gif", "--fps", "-1"], "argument -f/--fps: -1 is not a positive float."), 738 | (["in.gif", "--fps", "0"], "argument -f/--fps: 0 is not a positive float."), 739 | ( 740 | ["in.gif", "--fps", "abc"], 741 | "argument -f/--fps: abc is not a valid float.", 742 | ), 743 | ( 744 | ["in.gif", "--log-level", "INVALID"], 745 | "argument -l/--log-level: invalid choice: 'INVALID'", 746 | ), 747 | ( 748 | ["in.gif", "--colormode", "invalid"], 749 | "argument -c/--colormode: invalid choice: 'invalid'", 750 | ), 751 | ( 752 | ["in.gif", "--filter-speckle", "-1"], 753 | "argument -s/--filter-speckle: -1 is not a positive integer.", 754 | ), 755 | ( 756 | ["in.gif", "--filter-speckle", "0"], 757 | "argument -s/--filter-speckle: 0 is not a positive integer.", 758 | ), 759 | ( 760 | ["in.gif", "--filter-speckle", "abc"], 761 | "argument -s/--filter-speckle: abc is not a valid integer.", 762 | ), 763 | ], 764 | ) 765 | def test_parse_cli_arguments_validation_invalid(cli_arguments, expected_error_message, capsys): 766 | with pytest.raises(SystemExit) as excinfo: 767 | parse_cli_arguments(cli_arguments) 768 | assert excinfo.value.code == 2 769 | captured_output = capsys.readouterr() 770 | assert expected_error_message in captured_output.err 771 | 772 | 773 | def test_save_svg_to_file_success(tmp_path, sample_svg_content): 774 | output_file_path = str(tmp_path / "output.svg") 775 | save_svg_to_file(sample_svg_content, output_file_path) 776 | 777 | assert os.path.exists(output_file_path) 778 | with open(output_file_path, encoding="utf-8") as f: 779 | written_file_content = f.read() 780 | assert written_file_content == sample_svg_content 781 | 782 | 783 | def test_save_svg_to_file_errors(tmp_path, sample_svg_content): 784 | nonexistent_directory = str(tmp_path / "nonexistent_dir") 785 | with pytest.raises(FileNotFoundError): 786 | save_svg_to_file(sample_svg_content, nonexistent_directory + "/output.svg") 787 | 788 | read_only_directory = tmp_path / "readonly" 789 | read_only_directory.mkdir() 790 | output_svg_file_ro = read_only_directory / "output.svg" 791 | output_svg_file_ro.touch() 792 | 793 | os.chmod(output_svg_file_ro, 0o444) 794 | 795 | output_directory = tmp_path / "output_dir" 796 | output_directory.mkdir() 797 | with pytest.raises(IsADirectoryError): 798 | save_svg_to_file(sample_svg_content, str(output_directory)) 799 | 800 | 801 | def test_gif_to_animated_svg_write_integration(tmp_path): 802 | gif_input_path = create_temp_gif(tmp_path, num_frames=3) 803 | svg_output_path = tmp_path / "output.svg" 804 | gif_to_animated_svg_write(gif_input_path, str(svg_output_path)) 805 | assert svg_output_path.exists() 806 | assert svg_output_path.stat().st_size > 0 807 | 808 | output_directory = tmp_path / "output_dir" 809 | output_directory.mkdir() 810 | with pytest.raises(IsADirectoryError): 811 | gif_to_animated_svg_write(gif_input_path, str(output_directory)) 812 | 813 | 814 | def test_gif_to_animated_svg_closes_image(tmp_path, mock_vtracer_instance_for_tests): 815 | """Test image gets closed even with processing or vtracer errors""" 816 | gif_path = create_temp_gif(tmp_path) 817 | mock_image = create_mock_image() 818 | mock_image_loader = Mock(return_value=mock_image) # Mock Image Loader 819 | mock_image_loader.open.return_value = mock_image # 820 | 821 | # Test case 1: Successful conversion. 822 | gif_to_animated_svg(gif_path, image_loader=mock_image_loader, vtracer_instance=mock_vtracer_instance_for_tests) 823 | mock_image.close.assert_called() # Image Close should have been called. 824 | 825 | # reset call count. 826 | mock_image.close.reset_mock() 827 | 828 | # Test case 2: vtracer failure. 829 | mock_vtracer_fail = create_mock_vtracer(raise_error=True) 830 | with pytest.raises(Exception, match="VTracer error"): # Keep as generic exception. 831 | gif_to_animated_svg(gif_path, image_loader=mock_image_loader, vtracer_instance=mock_vtracer_fail) 832 | mock_image.close.assert_called() 833 | 834 | # Test Case 3: GIF not animated. 835 | mock_image.close.reset_mock() # reset. 836 | mock_image.is_animated = False 837 | with pytest.raises(NotAnimatedGifError): 838 | gif_to_animated_svg(gif_path, image_loader=mock_image_loader) 839 | mock_image.close.assert_called() 840 | 841 | # Test Case 4: No valid frames 842 | mock_image.close.reset_mock() # reset. 843 | mock_image.is_animated = True 844 | mock_vtracer_no_valid = create_mock_vtracer(return_svg="") 845 | with pytest.raises(NoValidFramesError): 846 | gif_to_animated_svg(gif_path, image_loader=mock_image_loader, vtracer_instance=mock_vtracer_no_valid) 847 | mock_image.close.assert_called() 848 | 849 | # Test Case 5: Image closing raises error 850 | mock_image.close.reset_mock() # reset. 851 | mock_image.is_animated = True 852 | mock_image.close.side_effect = Exception("Simulated close error") # Set close to raise error. 853 | with pytest.raises(Exception, match="Simulated close error"): 854 | gif_to_animated_svg(gif_path, image_loader=mock_image_loader, vtracer_instance=mock_vtracer_instance_for_tests) 855 | mock_image.close.assert_called() # should still be called. 856 | 857 | 858 | @pytest.mark.parametrize( 859 | ("durations", "expected_fps"), 860 | [ 861 | ([100, 100, 100], 10.0), # 3 frames @ 100ms = 10 FPS 862 | ([50, 50, 50, 50], 20.0), # 4 frames @ 50ms = 20 FPS 863 | ([200, 200], 5.0), # 2 frames @ 200ms = 5 FPS 864 | ([100, 50, 150], 10.0), # Avg: (3 / (0.1 + 0.05 + 0.15)) = 3 / 0.3 = 10 FPS 865 | ([100, 0, 100], 10.0), # PIL defaults 0 to 100ms -> 3 frames / 0.3s = 10 FPS 866 | ([0, 0, 0], 10.0), # All default to 100ms -> 3 frames / 0.3s = 10 FPS 867 | ([100], None), # Single frame is not animated 868 | ([], None), # No frames 869 | ], 870 | ) 871 | def test_calculate_gif_average_fps(durations, expected_fps): 872 | if not durations: 873 | mock_img = create_mock_image(is_animated=False, n_frames=0, durations=[]) 874 | else: 875 | mock_img = create_mock_image(is_animated=len(durations) > 1, n_frames=len(durations), durations=durations) 876 | 877 | calculated_fps = _calculate_gif_average_fps(mock_img) 878 | 879 | if expected_fps is None: 880 | assert calculated_fps is None 881 | else: 882 | assert calculated_fps == pytest.approx(expected_fps) 883 | 884 | 885 | def test_calculate_gif_average_fps_eof_error(caplog): 886 | """Test handling of EOFError during duration reading.""" 887 | mock_img = create_mock_image(is_animated=True, n_frames=5, durations=[100, 100, 100, 100, 100]) 888 | # Make seek raise EOFError after reading some frames 889 | original_seek = mock_img.seek.side_effect 890 | 891 | def seek_with_eof(frame_index): 892 | if frame_index >= 2: 893 | msg = "Simulated EOF" 894 | raise EOFError(msg) 895 | return original_seek(frame_index) # Call original side effect 896 | 897 | mock_img.seek.side_effect = seek_with_eof 898 | 899 | with caplog.at_level(logging.WARNING): 900 | fps = _calculate_gif_average_fps(mock_img) 901 | 902 | # Should calculate based on frames read before error (2 frames / 0.2s) 903 | assert fps == pytest.approx(10.0) 904 | assert "EOFError encountered" in caplog.text 905 | -------------------------------------------------------------------------------- /web/api/convert.py: -------------------------------------------------------------------------------- 1 | from http.server import BaseHTTPRequestHandler 2 | import json 3 | import base64 4 | import logging 5 | import os 6 | from urllib.parse import parse_qs 7 | 8 | from framesvg import ( 9 | FALLBACK_FPS, 10 | gif_to_animated_svg, 11 | DEFAULT_VTRACER_OPTIONS, 12 | VTracerOptions, 13 | FramesvgError, 14 | NotAnimatedGifError, 15 | NoValidFramesError, 16 | DimensionError, 17 | ExtractionError, 18 | ) 19 | 20 | 21 | class handler(BaseHTTPRequestHandler): 22 | def do_POST(self): 23 | """Handles the GIF to SVG conversion request.""" 24 | logging.basicConfig(level=logging.INFO) 25 | 26 | try: 27 | content_length = int(self.headers.get("Content-Length", 0)) 28 | if content_length == 0: 29 | self.send_error(400, "No content provided") 30 | return 31 | 32 | content_type = self.headers.get("Content-Type", "") 33 | if "application/json" not in content_type: 34 | self.send_error(400, "Invalid content type") 35 | return 36 | 37 | post_data = self.rfile.read(content_length) 38 | try: 39 | data = json.loads(post_data.decode("utf-8")) 40 | except json.JSONDecodeError: 41 | self.send_error(400, "Invalid JSON data") 42 | return 43 | 44 | if "file" not in data: 45 | self.send_error(400, "No file provided") 46 | return 47 | 48 | try: 49 | gif_data = base64.b64decode(data["file"].split(",")[1]) 50 | except Exception: 51 | self.send_error(400, "Invalid base64 data") 52 | return 53 | 54 | params = data.get("params", {}) 55 | vtracer_options: VTracerOptions = DEFAULT_VTRACER_OPTIONS.copy() 56 | 57 | if params: 58 | for key, value in params.items(): 59 | if key in DEFAULT_VTRACER_OPTIONS and value is not None: 60 | if isinstance(DEFAULT_VTRACER_OPTIONS[key], int): 61 | try: 62 | vtracer_options[key] = int(value) # type: ignore 63 | except ValueError: 64 | self.send_error(400, f"Invalid integer value for {key}") 65 | return 66 | elif isinstance(DEFAULT_VTRACER_OPTIONS[key], float): 67 | try: 68 | vtracer_options[key] = float(value) # type: ignore 69 | except ValueError: 70 | self.send_error(400, f"Invalid float value for {key}") 71 | return 72 | elif isinstance(DEFAULT_VTRACER_OPTIONS[key], str): 73 | if key == "colormode" and value not in ["color", "binary"]: 74 | self.send_error(400, "Invalid value for colormode") 75 | return 76 | if key == "hierarchical" and value not in ["stacked", "cutout"]: 77 | self.send_error(400, "Invalid value for hierarchical") 78 | return 79 | if key == "mode" and value not in ["spline", "polygon", "none"]: 80 | self.send_error(400, "Invalid value for mode") 81 | return 82 | vtracer_options[key] = value # type: ignore 83 | 84 | fps: float | None = None # Default to None (calculate from GIF) 85 | if "fps" in params: 86 | try: 87 | fps = float(params["fps"]) 88 | if fps <= 0: 89 | raise ValueError("FPS must be positive") 90 | except ValueError: 91 | self.send_error(400, "Invalid or non-positive fps value") 92 | return 93 | 94 | temp_gif_path = "/tmp/input.gif" 95 | with open(temp_gif_path, "wb") as f: 96 | f.write(gif_data) 97 | 98 | try: 99 | # Pass fps=None if not provided, otherwise pass the float value 100 | svg_content = gif_to_animated_svg( 101 | temp_gif_path, vtracer_options=vtracer_options, fps=fps 102 | ) # fps is either float or None here 103 | except NotAnimatedGifError: 104 | self.send_error(400, "The provided GIF is not animated.") 105 | return 106 | except NoValidFramesError: 107 | self.send_error(500, "No valid frames were generated.") 108 | return 109 | except (DimensionError, ExtractionError): 110 | self.send_error(500, "Error processing the GIF.") 111 | return 112 | except FramesvgError as e: 113 | self.send_error(500, f"FrameSVG Error: {e}") 114 | return 115 | except Exception as e: 116 | logging.exception("Unexpected error: %s", e) 117 | self.send_error(500, "An unexpected error occurred.") 118 | return 119 | finally: 120 | try: 121 | os.remove(temp_gif_path) 122 | except OSError: 123 | pass 124 | 125 | svg_size_bytes = len(svg_content.encode("utf-8")) 126 | if svg_size_bytes > 4.5 * 1024 * 1024: 127 | svg_size_mb = svg_size_bytes / (1024 * 1024) 128 | msg = f"Generated SVG is too large (exceeds 4.5MB). Size: {svg_size_mb:.2f}MB." 129 | self.send_error(413, msg, msg + " Use CLI or Python library for large input and output") 130 | return 131 | 132 | self.send_response(200) 133 | self.send_header("Content-Type", "application/json") 134 | self.end_headers() 135 | self.wfile.write(json.dumps({"svg": svg_content}).encode("utf-8")) 136 | 137 | except Exception as e: 138 | logging.exception("Unexpected error in handler: %s", e) 139 | self.send_error(500, "An unexpected error occurred.") 140 | return 141 | -------------------------------------------------------------------------------- /web/public/examples.css: -------------------------------------------------------------------------------- 1 | .example-group { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: flex-start; 5 | margin-bottom: 20px; 6 | } 7 | 8 | .example { 9 | text-align: center; 10 | width: 48%; 11 | min-width: 150px; 12 | } 13 | 14 | .example img { 15 | max-width: 100%; 16 | height: auto; 17 | border: 1px solid #ccc; 18 | display: block; 19 | margin-left: auto; 20 | margin-right: auto; 21 | } 22 | 23 | .example p { 24 | margin-top: 5px; 25 | font-style: italic; 26 | color: #555; 27 | word-wrap: break-word; 28 | } 29 | 30 | .example p span { 31 | font-style: normal; 32 | color: #595959; 33 | display: inline; 34 | word-wrap: break-word; 35 | } 36 | 37 | h2 { 38 | clear: both; 39 | margin-top: 30px; 40 | margin-bottom: 15px; 41 | } 42 | 43 | @media (max-width: 480px) { 44 | .example { 45 | min-width: 120px; 46 | } 47 | } 48 | 49 | @media (prefers-color-scheme: dark) { 50 | .example img { 51 | border-color: #666; 52 | } 53 | .example p { 54 | color: #ddd; 55 | } 56 | 57 | .example p span { 58 | color: #ccc; 59 | } 60 | .binary-img { 61 | background-color: white; 62 | } 63 | } -------------------------------------------------------------------------------- /web/public/examples.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | FrameSVG Examples 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 |
17 |

FrameSVG Examples

18 |

19 | These examples demonstrate the conversion of animated GIFs (left) to animated SVGs (right) using FrameSVG. 20 | Observe the scalability of the SVG output. SVGs, unlike GIFs, support network compression, often resulting 21 | in smaller file sizes for network transfer. 22 |

23 | 24 |

Coding Examples

25 |
26 |
27 | Animated GIF showing code being written on Git 30 |

Original GIF (Loading...)

31 |
32 |
33 | Animated SVG showing code being written on Git 36 |

Converted SVG (Loading...)

37 |
38 |
39 | 40 |
41 |
42 | Animated GIF showing code being written on Github 45 |

Original GIF (Loading...)

46 |
47 |
48 | Animated SVG showing code being written on Github 51 |

Converted SVG (Loading...)

52 |
53 |
54 | 55 |
56 |
57 | Animated GIF showing code being written on VSCode 60 |

Original GIF (Loading...)

61 |
62 |
63 | Animated SVG showing code being written on VSCode 66 |

Converted SVG (Loading...)

67 |
68 |
69 |
70 |
71 | Animated GIF showing code being written on Sublime 74 |

Original GIF (Loading...)

75 |
76 |
77 | Animated SVG showing code being written on Sublime 80 |

Converted SVG (Loading...)

81 |
82 |
83 | 84 |

More Examples

85 |
86 |
87 | Animated GIF of Good Morning greeting 90 |

Original GIF (Loading...)

91 |
92 |
93 | Animated SVG of Good Morning greeting 96 |

Converted SVG (Loading...)

97 |
98 |
99 |
100 |
101 | Animated GIF of a loading icon 104 |

Original GIF (Loading...)

105 |
106 |
107 | Animated SVG of a loading icon 110 |

Converted SVG (Loading...)

111 |
112 |
113 |
114 |
115 | Animated GIF of hands doing a voila gesture 118 |

Original GIF (Loading...)

119 |
120 |
121 | Animated SVG of hands doing a voila gesture 124 |

Converted SVG (Loading...)

125 |
126 |
127 | 128 |

Complex Examples (Transparent Backgrounds)

129 |

130 | These examples use the binary color mode, where bright colors become transparent. They 131 | will appear best on light backgrounds. 132 |

133 | 134 |
135 |
136 | Animated GIF of a black and white loop pattern 139 |

Original GIF (Loading...)

140 |
141 |
142 | Animated SVG of a black and white loop pattern 145 |

Converted SVG (Loading...)

146 |
147 |
148 | 149 |
150 |
151 | Animated GIF of another black and white loop pattern 154 |

Original GIF (Loading...)

155 |
156 |
157 | Animated SVG of another black and white loop pattern 160 |

Converted SVG (Loading...)

161 |
162 |
163 | 164 | 169 |
170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /web/public/examples.js: -------------------------------------------------------------------------------- 1 | async function getUncompressedSize(url, element) { 2 | const fullResponse = await fetch(url); 3 | if (!fullResponse.ok) return -1; 4 | const blob = await fullResponse.blob(); 5 | const uncompressedSizeInKB = (blob.size / 1024).toFixed(1); 6 | element.textContent += ` | ${uncompressedSizeInKB} KB (raw)`; 7 | } 8 | 9 | async function getFileSize(url, element, already_uncompressed = false) { 10 | try { 11 | const response = await fetch(url, { method: 'HEAD' }); 12 | if (!response.ok) { 13 | element.textContent = 'Error'; 14 | return; 15 | } 16 | 17 | const contentLength = response.headers.get('Content-Length'); 18 | if (contentLength) { 19 | const sizeInKB = (parseInt(contentLength) / 1024).toFixed(1); 20 | element.textContent = already_uncompressed ? `${sizeInKB} KB` : `${sizeInKB} KB (comp)`; 21 | } 22 | 23 | if (!already_uncompressed) getUncompressedSize(url, element); 24 | } catch { 25 | element.textContent = 'Error'; 26 | } 27 | } 28 | 29 | document.querySelectorAll('.example-group').forEach(async group => { 30 | const gifExample = group.querySelector('.example:first-child'); 31 | const svgExample = group.querySelector('.example:last-child'); 32 | 33 | if (gifExample) { 34 | const gifImg = gifExample.querySelector('img'); 35 | const gifSizeSpan = gifExample.querySelector('p > span'); 36 | if (gifImg && gifSizeSpan) getFileSize(gifImg.src, gifSizeSpan, true); 37 | } 38 | 39 | if (svgExample) { 40 | const svgImg = svgExample.querySelector('img'); 41 | const svgSizeSpan = svgExample.querySelector('p > span'); 42 | if (svgImg && svgSizeSpan) getFileSize(svgImg.src, svgSizeSpan, false); 43 | } 44 | }); 45 | -------------------------------------------------------------------------------- /web/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 11 | 12 | 13 | FrameSVG: Convert Animated GIFs to Animated SVGs 14 | 15 | 16 | 17 | 18 |
19 |

FrameSVG: Animated GIF to Animated SVG

20 |

Convert your animated GIFs to scalable, animated SVGs.

21 | 22 | 23 |
24 | 25 |

Parameters

26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 | 37 |
38 |
39 | 40 | 44 |
45 |
46 | 47 | 52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 | 63 | 64 |
65 |
66 | 67 | 68 |
69 |
70 | 71 | 72 |
73 |
74 | 75 | 76 |
77 |
78 | 79 | 80 |
81 |
82 | 83 | 84 |
85 |
86 | 87 | 88 |
89 | 90 | 91 | 96 |
97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /web/public/script.js: -------------------------------------------------------------------------------- 1 | document.getElementById('convertButton').addEventListener('click', async () => { 2 | const fileInput = document.getElementById('gifInput'); 3 | const statusDiv = document.getElementById('status'); 4 | const previewDiv = document.getElementById('preview'); 5 | const downloadLink = document.getElementById('downloadLink'); 6 | 7 | // Clear previous results 8 | statusDiv.textContent = ''; 9 | previewDiv.innerHTML = ''; 10 | downloadLink.style.display = 'none'; 11 | 12 | 13 | if (!fileInput.files || fileInput.files.length === 0) { 14 | statusDiv.textContent = 'Please select a GIF file.'; 15 | return; 16 | } 17 | 18 | const file = fileInput.files[0]; 19 | if (file.type !== 'image/gif') { 20 | statusDiv.textContent = 'Please select a valid GIF file.'; 21 | return; 22 | } 23 | 24 | statusDiv.textContent = 'Converting... Please wait.'; 25 | 26 | const reader = new FileReader(); 27 | reader.readAsDataURL(file); 28 | reader.onload = async () => { 29 | const base64Data = reader.result; 30 | 31 | const params = { 32 | fps: document.getElementById('fps').value, 33 | colormode: document.getElementById('colormode').value, 34 | hierarchical: document.getElementById('hierarchical').value, 35 | mode: document.getElementById('mode').value, 36 | filter_speckle: document.getElementById('filter_speckle').value, 37 | color_precision: document.getElementById('color_precision').value, 38 | layer_difference: document.getElementById('layer_difference').value, 39 | corner_threshold: document.getElementById('corner_threshold').value, 40 | length_threshold: document.getElementById('length_threshold').value, 41 | max_iterations: document.getElementById('max_iterations').value, 42 | splice_threshold: document.getElementById('splice_threshold').value, 43 | path_precision: document.getElementById('path_precision').value, 44 | }; 45 | 46 | const requestBody = JSON.stringify({ file: base64Data, params }); 47 | const requestSizeMB = new TextEncoder().encode(requestBody).length / (1024 * 1024); 48 | 49 | if (requestSizeMB > 4.5) { 50 | statusDiv.textContent = `Serverless function request size limit (4.5MB) exceeded. Request: ${requestSizeMB.toFixed(1)}MB. Use CLI or Python library for large input and output.`; 51 | return; 52 | } 53 | 54 | try { 55 | const response = await fetch('/api/convert', { 56 | method: 'POST', 57 | headers: { 58 | 'Content-Type': 'application/json', 59 | }, 60 | body: requestBody, 61 | }); 62 | 63 | const contentType = response.headers.get("content-type"); 64 | if (response.ok) { 65 | if (contentType && contentType.includes("application/json")) { 66 | const data = await response.json(); 67 | statusDiv.textContent = 'Conversion successful!'; 68 | const blob = new Blob([data.svg], { type: 'image/svg+xml' }); 69 | const blobURL = URL.createObjectURL(blob); 70 | 71 | const img = document.createElement('img'); 72 | img.src = blobURL; 73 | previewDiv.appendChild(img); 74 | 75 | downloadLink.href = blobURL; 76 | downloadLink.download = 'animation.svg'; 77 | downloadLink.style.display = 'block'; 78 | } else { 79 | statusDiv.textContent = "Unexpected response type from server."; 80 | } 81 | } else { 82 | if (contentType && contentType.includes("application/json")) { 83 | const data = await response.json(); 84 | statusDiv.textContent = `Error: ${data.error}`; 85 | } else if (contentType && contentType.includes("text/plain")) { 86 | const errorText = await response.text(); 87 | statusDiv.textContent = `Error: ${errorText}`; 88 | } else if (contentType && contentType.includes("text/html")) { 89 | const errorText = await response.text(); 90 | let errorMessage = ""; 91 | 92 | const explanationMatch = errorText.match(/Error code explanation: \d+ - (.*)<\/p>/); 93 | if (explanationMatch) { 94 | errorMessage = explanationMatch[1]; 95 | } else { 96 | const messageMatch = errorText.match(/Message: (.*?)<\/p>/); 97 | if (messageMatch) { 98 | errorMessage = messageMatch[1]; 99 | } else { 100 | errorMessage = "An HTML error occurred, but could not extract the message."; 101 | } 102 | } 103 | statusDiv.textContent = `Error: ${errorMessage}`; 104 | } 105 | else { 106 | statusDiv.textContent = `An error occurred: ${response.status} ${response.statusText}`; 107 | } 108 | } 109 | } catch (error) { 110 | statusDiv.textContent = `An error occurred: ${error}`; 111 | } 112 | }; 113 | 114 | reader.onerror = () => { 115 | statusDiv.textContent = 'Error reading file.'; 116 | } 117 | }); 118 | -------------------------------------------------------------------------------- /web/public/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | margin: 0; 4 | display: flex; 5 | justify-content: center; 6 | align-items: center; 7 | min-height: 100vh; 8 | background-color: #f0f0f0; 9 | } 10 | 11 | .container { 12 | background-color: #fff; 13 | padding: 20px; 14 | border-radius: 8px; 15 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 16 | width: 95%; 17 | max-width: 1000px; 18 | box-sizing: border-box; 19 | } 20 | 21 | h1 { 22 | color: #333; 23 | margin-bottom: 20px; 24 | text-align: center; 25 | font-size: 1.8rem; 26 | } 27 | 28 | h2 { 29 | margin-top: 20px; 30 | margin-bottom: 10px; 31 | color: #555; 32 | font-size: 1.4rem; 33 | } 34 | 35 | input[type="file"], 36 | button, 37 | select, 38 | input[type="number"] { 39 | padding: 10px; 40 | margin: 5px 0; 41 | border: 1px solid #ddd; 42 | border-radius: 4px; 43 | font-size: 1rem; 44 | width: 100%; 45 | box-sizing: border-box; 46 | } 47 | 48 | input[type="number"] { 49 | min-width: 60px; 50 | width: auto; 51 | } 52 | 53 | 54 | button { 55 | background-color: #0037FF; 56 | color: white; 57 | border: none; 58 | cursor: pointer; 59 | transition: background-color 0.3s; 60 | width: 100%; 61 | box-sizing: border-box; 62 | } 63 | 64 | button:hover { 65 | background-color: #0056b3; 66 | } 67 | 68 | #status { 69 | margin: 10px 0; 70 | font-weight: bold; 71 | text-align: center; 72 | font-size: 1rem; 73 | } 74 | 75 | #preview { 76 | margin-top: 20px; 77 | text-align: center; 78 | } 79 | 80 | #preview img { 81 | max-width: 100%; 82 | height: auto; 83 | border: 1px solid #ccc; 84 | margin-bottom: 10px; 85 | display: block; 86 | margin-left: auto; 87 | margin-right: auto; 88 | } 89 | 90 | #downloadLink { 91 | display: block; 92 | margin-top: 15px; 93 | text-align: center; 94 | padding: 10px; 95 | background-color: #28a745; 96 | color: white; 97 | text-decoration: none; 98 | border-radius: 4px; 99 | } 100 | 101 | #downloadLink:hover { 102 | background-color: #218838; 103 | } 104 | 105 | .params-grid { 106 | display: grid; 107 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 108 | gap: 10px; 109 | } 110 | 111 | .params-grid>div { 112 | display: flex; 113 | flex-direction: column; 114 | } 115 | 116 | @media (max-width: 768px) { 117 | .container { 118 | padding: 15px; 119 | } 120 | 121 | h1 { 122 | font-size: 1.5rem; 123 | } 124 | 125 | h2 { 126 | font-size: 1.2rem; 127 | } 128 | 129 | .params-grid { 130 | grid-template-columns: 1fr; 131 | } 132 | } 133 | 134 | @media (max-width: 480px) { 135 | .container { 136 | padding: 10px; 137 | } 138 | 139 | h1 { 140 | font-size: 1.2rem; 141 | } 142 | 143 | h2 { 144 | font-size: 1rem; 145 | margin-top: 15px; 146 | } 147 | } 148 | 149 | /* Footer Styles */ 150 | footer { 151 | text-align: center; 152 | border-top: 1px solid #ccc; 153 | max-width: 1000px; 154 | box-sizing: border-box; 155 | 156 | } 157 | 158 | footer a { 159 | text-decoration: none; 160 | } 161 | 162 | footer a:hover { 163 | text-decoration: underline; 164 | } 165 | 166 | @media (prefers-color-scheme: dark) { 167 | 168 | body { 169 | background-color: #121212; 170 | color: #eee; 171 | } 172 | 173 | .container { 174 | background-color: #1e1e1e; 175 | box-shadow: 0 2px 10px rgba(255, 255, 255, 0.1); 176 | } 177 | 178 | h1, h2 { 179 | color: #eee; 180 | } 181 | 182 | input[type="file"], 183 | button, 184 | select, 185 | input[type="number"] { 186 | background-color: #333; 187 | color: #fff; 188 | border-color: #555; 189 | } 190 | 191 | button { 192 | background-color: #4CAF50; 193 | } 194 | 195 | button:hover{ 196 | background-color: #367C39; 197 | } 198 | 199 | #status { 200 | color: #ddd; 201 | } 202 | 203 | #preview img { 204 | border-color: #666; 205 | } 206 | 207 | #downloadLink { 208 | background-color: #2196F3; 209 | } 210 | #downloadLink:hover{ 211 | background-color: #0d8aee; 212 | } 213 | 214 | footer { 215 | border-color: #555; 216 | color: #eee 217 | } 218 | footer a { 219 | color:#64b5f6 220 | } 221 | } -------------------------------------------------------------------------------- /web/requirements.txt: -------------------------------------------------------------------------------- 1 | framesvg --------------------------------------------------------------------------------