├── .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 |
3 |
4 |
5 |
6 | Convert animated GIFs to animated SVGs.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
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 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | ### More Examples
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
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 |
70 |
71 |
72 |
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 |
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 |
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 |
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 |
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 |
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"