Metadata-Version: 2.4
Name: carousel-to-video
Version: 0.2.0
Summary: Convert an Instagram carousel into a video.
Author-email: techabinfo <contact@techabapps.com>
Maintainer-email: techabinfo <contact@techabapps.com>
License-Expression: MIT
Project-URL: Homepage, https://github.com/techabinfo/carousel-to-video
Project-URL: Repository, https://github.com/techabinfo/carousel-to-video
Project-URL: Issues, https://github.com/techabinfo/carousel-to-video/issues
Project-URL: Changelog, https://github.com/techabinfo/carousel-to-video/releases
Keywords: instagram,carousel,video,ffmpeg
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Video
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Requires-Dist: pytest-cov>=5; extra == "dev"
Requires-Dist: build>=1.2; extra == "dev"
Requires-Dist: twine>=5; extra == "dev"
Requires-Dist: python-semantic-release<11,>=10.7; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx>=7.2; extra == "docs"
Requires-Dist: furo>=2024.8; extra == "docs"
Requires-Dist: myst-parser>=3; extra == "docs"
Requires-Dist: sphinx-autodoc-typehints>=2; extra == "docs"
Dynamic: license-file

# Carousel2Video

Turns an Instagram image carousel into an MP4 video with effect
(slow zoom alternating between slides and optional transitions).

No login, no API key, no Python dependencies beyond the stdlib.

## Requirements

- Python 3.10+
- `ffmpeg` (for the `zoompan`/`gblur` filters)
- `yt-dlp` (for post scraping)

```bash
ffmpeg --version
yt-dlp --version
```

Install the system tools if missing:

```bash
sudo apt install ffmpeg
pipx install yt-dlp
```

## Installation

```bash
pip install carousel-to-video
```

For development, install from the repository instead:

```bash
pip install -e ".[dev]"
```

Both forms provide the `carousel2video` command and the `carousel_to_video`
package. From a checkout, you can also run the module without installing:

```bash
PYTHONPATH=src python3 -m carousel_to_video --help
```

## CLI usage

```bash
carousel2video https://www.instagram.com/p/example1234/
```

The module entry point works too:

```bash
python3 -m carousel_to_video https://www.instagram.com/p/example1234/
```

Output: `carrossel-example1234.mp4` (1080×1350, 30 fps, no audio).

The options and examples below cover the CLI. The same settings are available
programmatically through `ConvertOptions` and `RenderOptions`; see
[Library usage](#library-usage).

### Options

| Flag | Default | Description |
|---|---|---|
| `-o, --output` | `carrossel-<code>.mp4` | output file |
| `--seconds` | `3` | duration of each slide |
| `--fps` | `30` | frames per second |
| `--zoom` | `1.05` | intensity (`1.0` disables it) |
| `--fit` | `blur` | `blur` = whole image over a blurred background; `cover` = crop to fill |
| `--width` / `--height` | `1080` / `1350` | final resolution |
| `--crf` | `18` | x264 quality (lower is better) |
| `--preset` | `medium` | x264 preset |
| `--transition` | `none` | effect between slides (see [Transitions](#transitions)) |
| `--transition-duration` | `0.5` | length of each transition in seconds |
| `--from-dir` | — | use local images, no download |
| `--work-dir` | `work` | cache for images and segments |
| `--exclude` | — | pages to skip, 1-based (e.g. `2,5` or `2-4`; repeatable) |
| `--exclude-first` | — | skip the first page of the carousel |
| `--exclude-last` | — | skip the last page of the carousel |
| `--audio` | — | background audio file (looped or trimmed to the video) |
| `--audio-volume` | `1.0` | audio gain multiplier |
| `--audio-fade` | `2.0` | audio fade-out in seconds (`0` disables) |
| `--keep` | — | keep the rendered segments |
| `--refresh` | — | force a new download |
| `--cookies-from-browser` | — | forwarded to yt-dlp (private posts) |

### Examples

```bash
# more time per slide (better for text)
carousel2video <url> --seconds 5

# 1:1 square, no blur on the edges
carousel2video <url> --width 1080 --height 1080

# fill the frame by cropping the edges
carousel2video <url> --fit cover

# no download: use a folder of already downloaded images
carousel2video --from-dir ./images -o carousel.mp4

# drop pages 2 and 5 to 7 (1-based, same order as the post)
carousel2video <url> --exclude 2,5-7

# drop the opening and closing pages (e.g. cover and call to action)
carousel2video <url> --exclude-first --exclude-last

# pages follow the sorted file order when using --from-dir
carousel2video --from-dir ./images --exclude 1,4

# background music with a quieter mix and a longer fade-out
carousel2video <url> --audio ./music.mp3 --audio-volume 0.5 --audio-fade 4

# crossfade between slides
carousel2video <url> --transition fade

# vary the effect on every boundary (wipe, circle, slide, dissolve, ...)
carousel2video <url> --transition mix --transition-duration 0.4
```

### Skipping pages

`--exclude` uses the **original** page numbers, so it always refers to the post
as displayed: `--exclude 2` drops the second slide whether or not other pages
were downloaded from cache. The flag can be repeated and accepts ranges
(`--exclude 2 --exclude 5-7`). The zoom direction still alternates in the final
video, because slides are renumbered after the exclusion.

`--exclude-first` and `--exclude-last` are shortcuts for pages `1` and `N`, where
`N` is the total page count **before** any exclusion — so
`--exclude-first --exclude-last --exclude 2,5-7` on a 10-page carousel drops
pages 1, 2, 5, 6, 7 and 10. Combining a shortcut with the equivalent explicit
page is harmless (e.g. `--exclude-first --exclude 1`).

With `--from-dir`, the numbering follows the natural sort order of the image
files in the folder (`2.jpg` before `10.jpg`).

### Audio

`--audio` muxes a background track into the final file:

- a track shorter than the video is looped until the end;
- a longer track is trimmed to the video length;
- the last `--audio-fade` seconds (default `2`) fade out — use `--audio-fade 0`
  to disable;
- `--audio-volume` multiplies the gain (default `1.0`; `0.5` is half volume).

Any format ffmpeg can decode works (mp3, m4a, opus, wav, …). Without
transitions the video stream is copied without re-encoding and only the audio is
encoded to AAC 192 kbit/s, so the output stays exactly as long as the video.
Only the audio stream is taken from the file, which means embedded cover art is
ignored. Without `--audio` the video stays silent.

### Transitions

`--transition` adds an effect on every boundary between slides. The default
`none` keeps the hard cuts and copies the segments without re-encoding.

- `mix` cycles a curated list (wipe, circle, slide, dissolve, smooth, pixelize,
  zoom, fade), one effect per boundary;
- any other accepted value is an `xfade` effect name: `fade`, `fadeblack`,
  `fadewhite`, `dissolve`, `pixelize`, `radial`, `zoomin`, `wipeleft`,
  `wiperight`, `wipeup`, `wipedown`, `slideleft`, `slideright`, `slideup`,
  `slidedown`, `smoothleft`, `smoothright`, `circleopen`, `circleclose`.

`--transition-duration` (default `0.5`) must be positive and shorter than
`--seconds`. Neighbouring slides overlap during the transition, so the final
video gets shorter by `(slides - 1) × transition-duration`: 5 slides of 3s with
a 0.5s `fade` last `13.0s` instead of `15.0s`.

Unlike the default copy-based concatenation, transitions require a single
re-encode of the whole video (`--crf` and `--preset` still apply). With a single
slide the flag is ignored.

## Library usage

The CLI is a thin layer over the `carousel_to_video` package. Install it with
`pip install carousel-to-video` and call `convert()` directly; the console script
is not required.

### Local folder

```python
from pathlib import Path

from carousel_to_video import ConvertOptions, convert

result = convert(
    ConvertOptions(
        from_dir=Path("images"),
        output=Path("carousel.mp4"),
    )
)

print(result.output, result.duration, result.slides, result.size)
```

`from_dir` uses the natural sort order of the image files (`2.jpg` before
`10.jpg`).

### Instagram post

```python
from pathlib import Path

from carousel_to_video import ConvertOptions, RenderOptions, convert

result = convert(
    ConvertOptions(
        url="https://www.instagram.com/p/example1234/",
        output=Path("carousel.mp4"),
        cookies_from_browser="firefox",  # optional, for private posts
        render=RenderOptions(
            seconds=4.0,
            fit="cover",
            width=1080,
            height=1080,
        ),
    )
)
```

Instagram serves signed CDN URLs that expire. If a download fails, the pipeline
refreshes the metadata and retries automatically; `refresh=True` forces a fresh
download.

### Audio, exclusions and caching

```python
from pathlib import Path

from carousel_to_video import ConvertOptions, convert

options = ConvertOptions(
    from_dir=Path("images"),
    output=Path("carousel.mp4"),
    work_dir=Path("work"),
    exclude=frozenset({1, 5}),
    exclude_last=True,
    audio=Path("music.mp3"),
    audio_volume=0.5,
    audio_fade=3.0,
    keep=True,
)

result = convert(options)
```

`exclude` always refers to the original 1-based page numbers. `work_dir` is
relative to the current working directory by default.

### Logging and errors

`convert()` prints progress to stdout and warnings to stderr by default. Pass
callables to redirect or silence them:

```python
import logging
from pathlib import Path

from carousel_to_video import ConvertError, ConvertOptions, convert

logger = logging.getLogger("carousel_to_video")
options = ConvertOptions(from_dir=Path("images"), output=Path("carousel.mp4"))

try:
    result = convert(options, log=logger.info, warn=logger.warning)
except ConvertError as exc:
    print(f"conversion failed: {exc}")

# Fully silent:
# result = convert(options, log=lambda message: None, warn=lambda message: None)
```

Expected failures (missing dependency, invalid page, expired media that could
not be refreshed, …) always raise `ConvertError`; `KeyboardInterrupt` is left to
the caller.

### Result

`convert()` returns a frozen `Result` dataclass:

| Field | Meaning |
|---|---|
| `output` | `Path` of the generated file |
| `duration` | duration in seconds |
| `width` / `height` | final resolution |
| `slides` | number of rendered slides |
| `size` | file size in bytes |

### CLI options as library fields

| CLI | Library |
|---|---|
| `URL` | `ConvertOptions(url=...)` |
| `--from-dir` | `ConvertOptions(from_dir=Path(...))` |
| `-o, --output` | `ConvertOptions(output=Path(...))` |
| `--work-dir` | `ConvertOptions(work_dir=Path(...))` |
| `--exclude` | `ConvertOptions(exclude=frozenset({...}))` |
| `--exclude-first` / `--exclude-last` | `ConvertOptions(exclude_first=True, exclude_last=True)` |
| `--audio` / `--audio-volume` / `--audio-fade` | `ConvertOptions(audio=Path(...), audio_volume=..., audio_fade=...)` |
| `--keep` / `--refresh` | `ConvertOptions(keep=True, refresh=True)` |
| `--cookies-from-browser` | `ConvertOptions(cookies_from_browser=...)` |
| `--seconds`, `--fps`, `--zoom`, `--fit`, `--width`, `--height`, `--crf`, `--preset`, `--transition`, `--transition-duration` | `RenderOptions(...)` |

## Project structure

| Path | Responsibility |
|---|---|
| `src/carousel_to_video/cli.py` | argument parsing and exit codes |
| `src/carousel_to_video/pipeline.py` | `convert()` orchestration and `Result` |
| `src/carousel_to_video/options.py` | `ConvertOptions` / `RenderOptions` (no argparse) |
| `src/carousel_to_video/fetch.py` | yt-dlp metadata and image downloads |
| `src/carousel_to_video/render.py` | ffmpeg filters, slide rendering and concatenation |
| `src/carousel_to_video/ffprobe.py` | dimension/duration probes |
| `src/carousel_to_video/utils.py` | shared helpers, page exclusion, local image collection |
| `src/carousel_to_video/errors.py` | `ConvertError` / `ExpiredMedia` |
| `pyproject.toml` | packaging, console script and `src/` layout |
| `.github/workflows/release.yml` | semantic-release and PyPI publishing |
| `CONTRIBUTING.md` | development workflow and commit conventions |
| `CHANGELOG.md` | release history generated by Python Semantic Release |

## Tests

```bash
pip install -e ".[dev]"
pytest
pytest --cov=carousel_to_video --cov-report=term-missing
```

The suite is fully offline: yt-dlp, ffmpeg, ffprobe and the network are mocked.

## Documentation

The API reference is generated from the docstrings, and the rest of the site
includes this README. Build it locally:

```bash
pip install -e ".[docs]"
sphinx-build -W -b html docs docs/_build/html
```

Read the Docs publication will be enabled when the repository becomes public.

## Releases

Releases are automated with
[Python Semantic Release](https://python-semantic-release.readthedocs.io/) from
[Conventional Commits](https://www.conventionalcommits.org/).

| Commit | Release |
|---|---|
| `feat:` | minor |
| `fix:` | patch |
| `perf:` | patch |
| `feat!:` or `BREAKING CHANGE:` | major (minor while the project is 0.x) |
| `chore:`, `ci:`, `docs:`, `test:`, `style:`, `refactor:`, `build:` | no release |

On every push to `main`, the release workflow updates `project.version` and
`CHANGELOG.md`, creates the `v<version>` tag and GitHub release, builds the
distributions and publishes them to PyPI. Do not edit the version or create
release tags manually.

See
[CONTRIBUTING.md](https://github.com/techabinfo/carousel-to-video/blob/main/CONTRIBUTING.md)
for the development workflow and commit conventions.

## How it works

1. `yt-dlp -J --ignore-no-formats-error <url>` reads the post and returns, for
   each carousel item, the image URL at original resolution.
2. For each slide it picks the **uncropped variant**: Instagram serves the same
   photo in several versions, and the highest-resolution one is usually a
   square crop (`stp=c0.181.1086.1086a...`) that slices off the artwork. The
   script prefers the `_pNxN` variants, which keep the original aspect ratio — at
   the cost of some resolution (on this account, 1080×1080 cropped vs. 720×960
   uncropped).
3. Images are downloaded on the fly (CDN URLs are signed and expire).
4. Each slide becomes a composition on a 2× canvas (2160×2700) with a darkened
   blurred background and the whole image centered, animated with `zoompan`.
   The image is placed with enough margin so the zoom never crops its edges.
5. Segments are encoded with `libx264` and concatenated with the `concat`
   demuxer in `-c copy` mode — no re-encode, no extra loss. With `--audio`, the
   track is muxed in the same pass (video copied, audio encoded to AAC).

## Cache

`work/<post-code>/01.jpg…` stores the downloaded images. Running again for the
same post reuses the download; use `--refresh` to force a new one. `work/` is in
`.gitignore`.

## Troubleshooting

**`failed to read the post via yt-dlp` mentioning a login**
The post is private, was removed, or Instagram blocked anonymous access. Try
browser cookies:

```bash
carousel2video <url> --cookies-from-browser firefox
```

If it still fails, download the images manually and use `--from-dir`.

**`slide N is a video; using the cover frame`**
The carousel mixes images and videos. The script uses the video's cover frame.
Full clip support is not implemented.

**Text cut off at the top/bottom of the artwork**
It is not the zoom: Instagram usually delivers a square crop of the original
image. The script already prefers the uncropped variant, but it is smaller
(720 px in the example). If you want the uncropped version at full resolution,
try browser cookies — when logged in, Instagram sometimes serves the original
3:4:

```bash
carousel2video <url> --cookies-from-browser firefox --refresh
```

**Upscaling warning**
With `--fit blur`, narrow images are upscaled with Lanczos and the script
reports the factor. Above 1.6× the result may look soft.

## License

MIT — see [LICENSE](https://github.com/techabinfo/carousel-to-video/blob/main/LICENSE).
