spriteworkflow 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ """SpriteWorkflow — convert video frames into linear spritesheets.
2
+
3
+ Provides :func:`extract_frames` (extract frames from video via FFmpeg),
4
+ :func:`create_spritesheet_lineal` (stitch frames into a horizontal
5
+ spritesheet), :func:`remove_background` (chroma-key background removal
6
+ on frames), :func:`create_spritesheet_grid` (arrange frames in a
7
+ rows × columns grid), and :func:`generate_report` (inspect frames and
8
+ produce a JSON validation report).
9
+ """
10
+
11
+ from .extractor import extract_frames
12
+ from .spritesheet_lineal import create_spritesheet_lineal
13
+ from .matte import remove_background
14
+ from .spritesheet_grid import create_spritesheet_grid
15
+ from .report import generate_report
16
+
17
+ __all__ = [
18
+ "extract_frames",
19
+ "create_spritesheet_lineal",
20
+ "remove_background",
21
+ "create_spritesheet_grid",
22
+ "generate_report",
23
+ ]
spriteworkflow/cli.py ADDED
@@ -0,0 +1,202 @@
1
+ """SpriteWorkflow CLI — build spritesheets from video via the command line.
2
+
3
+ Usage::
4
+
5
+ spriteworkflow build <video> [options]
6
+
7
+ Subcommands
8
+ -----------
9
+ build Extract frames (optionally remove background), assemble into a
10
+ spritesheet, and optionally produce a validation report.
11
+ """
12
+
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from spriteworkflow import (
18
+ extract_frames,
19
+ remove_background,
20
+ create_spritesheet_lineal,
21
+ create_spritesheet_grid,
22
+ generate_report,
23
+ )
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Parser
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ """Create the argument parser for the ``build`` subcommand.
32
+
33
+ Returns
34
+ -------
35
+ argparse.ArgumentParser
36
+ """
37
+ parser = argparse.ArgumentParser(
38
+ prog="spriteworkflow",
39
+ description="Convert video frames into linear or grid spritesheets.",
40
+ )
41
+
42
+ sub = parser.add_subparsers(dest="command", required=True)
43
+ build = sub.add_parser("build", help="Build a spritesheet from a video")
44
+
45
+ # Positional
46
+ build.add_argument(
47
+ "video",
48
+ help="Path to the input video file",
49
+ )
50
+
51
+ # Output
52
+ build.add_argument(
53
+ "--output", "-o",
54
+ default="spritesheet.png",
55
+ help="Output spritesheet path (default: %(default)s)",
56
+ )
57
+
58
+ # Layout
59
+ build.add_argument(
60
+ "--layout",
61
+ choices=("lineal", "grid"),
62
+ default="lineal",
63
+ help="Spritesheet layout (default: %(default)s)",
64
+ )
65
+ build.add_argument(
66
+ "--columns",
67
+ type=int,
68
+ default=None,
69
+ help="Grid columns — auto-calculated as ceil(sqrt(N)) when not set. "
70
+ "Only relevant with --layout=grid.",
71
+ )
72
+ build.add_argument(
73
+ "--padding",
74
+ type=int,
75
+ default=0,
76
+ help="Padding pixels between grid cells (default: %(default)s). "
77
+ "Only relevant with --layout=grid.",
78
+ )
79
+
80
+ # Chroma-key (matte)
81
+ build.add_argument(
82
+ "--matte",
83
+ action="store_true",
84
+ default=False,
85
+ help="Enable chroma-key background removal between extraction and "
86
+ "spritesheet assembly",
87
+ )
88
+ build.add_argument(
89
+ "--bg-color",
90
+ nargs=3,
91
+ type=int,
92
+ default=None,
93
+ metavar=("R", "G", "B"),
94
+ help="RGB triplet for chroma-key color, e.g. --bg-color 0 255 0. "
95
+ "Auto-detected from corner pixels when not set.",
96
+ )
97
+ build.add_argument(
98
+ "--tolerance",
99
+ type=int,
100
+ default=30,
101
+ help="Chroma-key euclidean tolerance (default: %(default)s)",
102
+ )
103
+ build.add_argument(
104
+ "--feather",
105
+ type=int,
106
+ default=0,
107
+ help="Chroma-key feather width in pixels (default: %(default)s)",
108
+ )
109
+
110
+ # Report
111
+ build.add_argument(
112
+ "--report-file",
113
+ default=None,
114
+ help="Path for JSON validation report (omitted when not set)",
115
+ )
116
+
117
+ # Internal
118
+ build.add_argument(
119
+ "--temp-dir",
120
+ default="temp",
121
+ help="Temporary directory for extracted frames (default: %(default)s)",
122
+ )
123
+
124
+ return parser
125
+
126
+
127
+ # ---------------------------------------------------------------------------
128
+ # Entry point
129
+ # ---------------------------------------------------------------------------
130
+
131
+ def main(argv=None) -> None:
132
+ """CLI entry point.
133
+
134
+ Parameters
135
+ ----------
136
+ argv : list of str, optional
137
+ Command-line arguments. If ``None`` (default), ``sys.argv[1:]`` is
138
+ read. Passing an explicit list enables testing without I/O.
139
+ """
140
+ parser = build_parser()
141
+ args = parser.parse_args(argv)
142
+
143
+ # argparse requires a subcommand — safety net for edge cases
144
+ if args.command != "build":
145
+ parser.print_help()
146
+ sys.exit(2)
147
+
148
+ try:
149
+ # ---- Step 1 : Extract frames ------------------------------------
150
+ print("> Extracting frames from video...")
151
+ frames = extract_frames(args.video, output_dir=args.temp_dir)
152
+ print(f" {len(frames)} frames extracted.")
153
+
154
+ # ---- Step 2 (optional) : Remove background ----------------------
155
+ if args.matte:
156
+ print("> Removing background via chroma-key...")
157
+ bg = tuple(args.bg_color) if args.bg_color else None
158
+ frames = remove_background(
159
+ frames=frames,
160
+ bg_color=bg,
161
+ tolerance=args.tolerance,
162
+ feather=args.feather,
163
+ output_dir=str(Path(args.temp_dir) / "matted"),
164
+ )
165
+ print(f" {len(frames)} frames processed.")
166
+
167
+ # ---- Step 3 : Build spritesheet ---------------------------------
168
+ print(f"> Building {args.layout} spritesheet...")
169
+
170
+ if args.layout == "grid":
171
+ sheet = create_spritesheet_grid(
172
+ frames=frames,
173
+ columns=args.columns,
174
+ padding=args.padding,
175
+ output_file=args.output,
176
+ )
177
+ else:
178
+ sheet = create_spritesheet_lineal(
179
+ frames=frames,
180
+ output_file=args.output,
181
+ )
182
+
183
+ print(f" -> Spritesheet saved: {sheet}")
184
+
185
+ # ---- Step 4 (optional) : Generate report ------------------------
186
+ if args.report_file:
187
+ print("> Generating validation report...")
188
+ report = generate_report(
189
+ frames=frames,
190
+ spritesheet_path=sheet,
191
+ output_file=args.report_file,
192
+ )
193
+ print(f" -> Report saved: {report}")
194
+
195
+ except (ValueError, FileNotFoundError, NotADirectoryError,
196
+ RuntimeError, PermissionError) as exc:
197
+ print(f"Error: {exc}", file=sys.stderr)
198
+ sys.exit(1)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
@@ -0,0 +1,64 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+ from spriteworkflow.ffmpeg_manager import get_ffmpeg_path
4
+ import tempfile
5
+
6
+ def extract_frames(video_name, output_dir="temp"):
7
+
8
+ ffmpeg_path = get_ffmpeg_path()
9
+
10
+ video_path = Path(video_name)
11
+
12
+ if not video_path.exists():
13
+ raise FileNotFoundError(f"Video file not found: {video_name}")
14
+
15
+ if not video_path.is_file():
16
+ raise ValueError(f"Path is not a valid file: {video_name}")
17
+
18
+ if video_path.stat().st_size <= 0:
19
+ raise ValueError(f"Video file is empty: {video_name}")
20
+
21
+ try:
22
+ with open(video_path, "rb"):
23
+ pass
24
+ except PermissionError as e:
25
+ raise PermissionError(f"Video file is not readable: {video_name}") from e
26
+
27
+ temp_root = Path(output_dir)
28
+ temp_root.mkdir(parents=True, exist_ok=True)
29
+
30
+ temp_dir = tempfile.mkdtemp(prefix="frames_", dir=temp_root)
31
+
32
+ output_path = Path(temp_dir)
33
+
34
+ frame_pattern = output_path / "frame_%04d.png"
35
+
36
+ command = [
37
+ ffmpeg_path,
38
+ "-v",
39
+ "error",
40
+ "-i",
41
+ str(video_path),
42
+ str(frame_pattern)
43
+ ]
44
+
45
+ try:
46
+ subprocess.run(command, check=True, stderr=subprocess.PIPE, text=True)
47
+ except subprocess.CalledProcessError as e:
48
+ stderr = e.stderr.strip()
49
+ raise RuntimeError(f"FFmpeg failed:\n{stderr}") from e
50
+
51
+ frames = sorted(output_path.glob("*.png"))
52
+
53
+ if not frames:
54
+ raise RuntimeError(
55
+ "No frames were extracted. "
56
+ "Possible causes:\n"
57
+ "- Corrupted video\n"
58
+ "- Unsupported codec\n"
59
+ "- Zero-duration video\n"
60
+ "- FFmpeg decoding issue\n"
61
+ "- Permission problems"
62
+ )
63
+
64
+ return frames
@@ -0,0 +1,9 @@
1
+ import imageio_ffmpeg
2
+
3
+ def get_ffmpeg_path():
4
+ try:
5
+ ffmpeg_path = imageio_ffmpeg.get_ffmpeg_exe()
6
+ except RuntimeError as e:
7
+ raise RuntimeError("Failed to initialize FFmpeg automatically.") from e
8
+
9
+ return ffmpeg_path
@@ -0,0 +1,152 @@
1
+ from pathlib import Path
2
+
3
+ import cv2
4
+ import numpy as np
5
+ from PIL import Image
6
+
7
+
8
+ def remove_background(frames=None, frames_dir=None, bg_color=None, tolerance=30, feather=0, output_dir="matted"):
9
+ """Remove background from frames via chroma-key (euclidean distance in RGB).
10
+
11
+ Parameters
12
+ ----------
13
+ frames : list of Path or str, optional
14
+ List of paths to individual frame images.
15
+ frames_dir : Path or str, optional
16
+ Directory containing PNG frames. Mutually exclusive with *frames*.
17
+ bg_color : tuple of int, optional
18
+ RGB triplet (0-255 each). If ``None`` it is auto-detected by
19
+ averaging the four corner pixels of the first frame.
20
+ tolerance : int
21
+ Maximum euclidean distance from *bg_color* for a pixel to be
22
+ considered background (made transparent). Default 30.
23
+ feather : int
24
+ Width of the linear feather ramp around *tolerance*. With
25
+ ``feather=0`` (default) the alpha mask is binary. With
26
+ ``feather > 0`` pixels whose distance falls inside the ramp
27
+ ``[tolerance - feather, tolerance + feather]`` get a smoothly
28
+ interpolated alpha value between 0 and 255.
29
+ output_dir : Path or str
30
+ Directory where the processed RGBA PNGs are saved. Created if it
31
+ does not exist. Default ``"matted"``.
32
+
33
+ Returns
34
+ -------
35
+ list of Path
36
+ Sorted list of paths to the generated RGBA PNG files.
37
+
38
+ Raises
39
+ ------
40
+ ValueError
41
+ If neither *frames* nor *frames_dir* is provided, if both are
42
+ provided, if *frames_dir* points to an empty directory, if
43
+ *bg_color* is not a valid RGB triplet, or if *feather* is not a
44
+ non-negative integer.
45
+ FileNotFoundError
46
+ If *frames_dir* does not exist.
47
+ NotADirectoryError
48
+ If *frames_dir* is a file, not a directory.
49
+ """
50
+ # ------------------------------------------------------------------
51
+ # Validación de entrada (mismo estilo que create_spritesheet_lineal)
52
+ # ------------------------------------------------------------------
53
+ if frames is None and frames_dir is None:
54
+ raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
55
+
56
+ if frames is not None and frames_dir is not None:
57
+ raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
58
+
59
+ if frames_dir is not None:
60
+ frames_path = Path(frames_dir)
61
+
62
+ if not frames_path.exists():
63
+ raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
64
+
65
+ if not frames_path.is_dir():
66
+ raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
67
+
68
+ frames = sorted(frames_path.glob("*.png"))
69
+
70
+ if not frames:
71
+ raise ValueError("No frames found to process for background removal.")
72
+
73
+ # Normalise frame paths
74
+ frame_paths = [Path(f) for f in frames]
75
+
76
+ # ------------------------------------------------------------------
77
+ # Color de fondo — autodetección o validación
78
+ # ------------------------------------------------------------------
79
+ if bg_color is None:
80
+ bgr = cv2.imread(str(frame_paths[0]), cv2.IMREAD_COLOR)
81
+ if bgr is None:
82
+ raise ValueError(f"Cannot read frame '{frame_paths[0]}' with OpenCV.")
83
+
84
+ h, w = bgr.shape[:2]
85
+ corners_bgr = [
86
+ bgr[0, 0],
87
+ bgr[0, w - 1],
88
+ bgr[h - 1, 0],
89
+ bgr[h - 1, w - 1],
90
+ ]
91
+ avg_bgr = np.mean(corners_bgr, axis=0).astype(int)
92
+ bg_color = tuple(avg_bgr[::-1]) # BGR → RGB
93
+ else:
94
+ if not isinstance(bg_color, tuple) or len(bg_color) != 3:
95
+ raise ValueError("bg_color must be a tuple of 3 integers (R, G, B).")
96
+
97
+ if not all(isinstance(c, int) and 0 <= c <= 255 for c in bg_color):
98
+ raise ValueError("bg_color values must be integers in the range 0-255.")
99
+
100
+ # ------------------------------------------------------------------
101
+ # Validación de feather
102
+ # ------------------------------------------------------------------
103
+ if not isinstance(feather, int) or feather < 0:
104
+ raise ValueError("feather must be a non-negative integer.")
105
+
106
+ # ------------------------------------------------------------------
107
+ # Procesar cada frame
108
+ # ------------------------------------------------------------------
109
+ output_path = Path(output_dir)
110
+ output_path.mkdir(parents=True, exist_ok=True)
111
+
112
+ bg_array = np.array(bg_color, dtype=np.uint8) # RGB
113
+ result_paths = []
114
+
115
+ for frame_path in frame_paths:
116
+ if not frame_path.exists():
117
+ raise FileNotFoundError(f"The frame '{frame_path}' does not exist.")
118
+
119
+ bgr = cv2.imread(str(frame_path), cv2.IMREAD_COLOR)
120
+ if bgr is None:
121
+ raise ValueError(f"Cannot read frame '{frame_path}' with OpenCV.")
122
+
123
+ # Convert BGR → RGB for the distance computation
124
+ rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
125
+
126
+ # Euclidean distance per pixel against bg_color
127
+ diff = rgb.astype(np.float32) - bg_array.astype(np.float32)
128
+ dist = np.sqrt(np.sum(diff ** 2, axis=2))
129
+
130
+ # Alpha mask: binary (feather=0) or linear ramp (feather>0)
131
+ if feather == 0:
132
+ alpha = np.where(dist <= tolerance, 0, 255).astype(np.uint8)
133
+ else:
134
+ lower = max(0, tolerance - feather)
135
+ upper = tolerance + feather
136
+ alpha = np.where(
137
+ dist <= lower,
138
+ np.uint8(0),
139
+ np.where(
140
+ dist >= upper,
141
+ np.uint8(255),
142
+ ((dist - lower) / (upper - lower) * 255).astype(np.uint8),
143
+ ),
144
+ )
145
+
146
+ rgba = np.dstack([rgb, alpha])
147
+
148
+ out_file = output_path / frame_path.name
149
+ Image.fromarray(rgba, "RGBA").save(out_file)
150
+ result_paths.append(out_file)
151
+
152
+ return sorted(result_paths)
@@ -0,0 +1,142 @@
1
+ import json
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+
5
+ from PIL import Image
6
+
7
+
8
+ def generate_report(frames=None, frames_dir=None, spritesheet_path=None, output_file="report.json"):
9
+ """Inspect frames and produce a JSON validation report.
10
+
11
+ Use this function *after* creating a spritesheet to detect issues
12
+ such as mismatched frame dimensions, verify the output file, and
13
+ produce a portable metadata record.
14
+
15
+ Unlike :func:`create_spritesheet_lineal` and
16
+ :func:`create_spritesheet_grid`, this function **does not** raise
17
+ when frames have different sizes — mismatches are recorded in the
18
+ report instead, which is the whole point of validation.
19
+
20
+ Parameters
21
+ ----------
22
+ frames : list of Path or str, optional
23
+ List of paths to individual frame images.
24
+ frames_dir : Path or str, optional
25
+ Directory containing PNG frames. Mutually exclusive with *frames*.
26
+ spritesheet_path : Path or str, optional
27
+ Path to a spritesheet file to include its dimensions in the report.
28
+ output_file : Path or str
29
+ Path for the generated JSON report. Default ``"report.json"``.
30
+
31
+ Returns
32
+ -------
33
+ Path
34
+ Path to the generated JSON report file.
35
+
36
+ Raises
37
+ ------
38
+ ValueError
39
+ If neither *frames* nor *frames_dir* is provided, if both are
40
+ provided, or if *frames_dir* points to an empty directory.
41
+ FileNotFoundError
42
+ If *frames_dir* or *spritesheet_path* does not exist.
43
+ NotADirectoryError
44
+ If *frames_dir* is a file, not a directory.
45
+ """
46
+ # ------------------------------------------------------------------
47
+ # Validación de entrada (mismo estilo que spritesheet_lineal)
48
+ # ------------------------------------------------------------------
49
+ if frames is None and frames_dir is None:
50
+ raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
51
+
52
+ if frames is not None and frames_dir is not None:
53
+ raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
54
+
55
+ if frames_dir is not None:
56
+ frames_path = Path(frames_dir)
57
+
58
+ if not frames_path.exists():
59
+ raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
60
+
61
+ if not frames_path.is_dir():
62
+ raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
63
+
64
+ frames = sorted(frames_path.glob("*.png"))
65
+
66
+ if not frames:
67
+ raise ValueError("No frames found to report.")
68
+
69
+ # ------------------------------------------------------------------
70
+ # Validación de spritesheet_path
71
+ # ------------------------------------------------------------------
72
+ if spritesheet_path is not None:
73
+ sprite_path = Path(spritesheet_path)
74
+ if not sprite_path.exists():
75
+ raise FileNotFoundError(f"The spritesheet '{spritesheet_path}' does not exist.")
76
+ if not sprite_path.is_file():
77
+ raise ValueError(f"The path '{spritesheet_path}' is not a valid file.")
78
+
79
+ # ------------------------------------------------------------------
80
+ # Procesar frames
81
+ # ------------------------------------------------------------------
82
+ frame_paths = [Path(f) for f in frames]
83
+
84
+ frame_entries = []
85
+ mismatched = []
86
+ reference_size = None
87
+
88
+ for idx, frame_path in enumerate(frame_paths):
89
+ img = Image.open(frame_path)
90
+ w, h = img.size
91
+
92
+ frame_entries.append({
93
+ "file": frame_path.name,
94
+ "width": w,
95
+ "height": h,
96
+ })
97
+
98
+ if idx == 0:
99
+ reference_size = [w, h]
100
+ elif [w, h] != reference_size:
101
+ mismatched.append(frame_path.name)
102
+
103
+ # ------------------------------------------------------------------
104
+ # Información del spritesheet (opcional)
105
+ # ------------------------------------------------------------------
106
+ spritesheet_size = None
107
+ sprite_path_str = None
108
+
109
+ if spritesheet_path is not None:
110
+ sprite_path = Path(spritesheet_path)
111
+ sprite_img = Image.open(sprite_path)
112
+ spritesheet_size = list(sprite_img.size)
113
+ sprite_path_str = str(sprite_path)
114
+
115
+ # ------------------------------------------------------------------
116
+ # Construir y guardar el reporte
117
+ # ------------------------------------------------------------------
118
+ report = {
119
+ "total_frames": len(frame_paths),
120
+ "frames": frame_entries,
121
+ "consistent_dimensions": len(mismatched) == 0,
122
+ "reference_size": reference_size,
123
+ "mismatched_frames": mismatched,
124
+ "spritesheet_path": sprite_path_str,
125
+ "spritesheet_size": spritesheet_size,
126
+ "generated_at": datetime.now().isoformat(),
127
+ }
128
+
129
+ output_path = Path(output_file)
130
+
131
+ if output_path.suffix == "":
132
+ output_path = output_path.with_suffix(".json")
133
+
134
+ if output_path.exists():
135
+ print(f"Warning: The file '{output_file}' already exists and will be overwritten.")
136
+
137
+ output_path.parent.mkdir(parents=True, exist_ok=True)
138
+
139
+ with open(output_path, "w", encoding="utf-8") as f:
140
+ json.dump(report, f, indent=2)
141
+
142
+ return output_path
@@ -0,0 +1,132 @@
1
+ import math
2
+ from pathlib import Path
3
+
4
+ from PIL import Image
5
+
6
+
7
+ def create_spritesheet_grid(frames=None, frames_dir=None, columns=None, padding=0,
8
+ background=(0, 0, 0, 0), output_file="spritesheet_grid.png"):
9
+ """Arrange frames into a grid spritesheet (rows × columns).
10
+
11
+ Parameters
12
+ ----------
13
+ frames : list of Path or str, optional
14
+ List of paths to individual frame images.
15
+ frames_dir : Path or str, optional
16
+ Directory containing PNG frames. Mutually exclusive with *frames*.
17
+ columns : int, optional
18
+ Number of columns in the grid. If ``None`` it is auto-calculated as
19
+ ``ceil(sqrt(total_frames))``.
20
+ padding : int
21
+ Pixels of empty space between adjacent cells. Default 0.
22
+ background : tuple of int
23
+ RGBA quadruplet used to fill the background canvas and any unused
24
+ cells. Default ``(0, 0, 0, 0)`` (fully transparent).
25
+ output_file : Path or str
26
+ Path for the generated spritesheet. Default ``"spritesheet_grid.png"``.
27
+
28
+ Returns
29
+ -------
30
+ Path
31
+ Path to the generated grid spritesheet.
32
+
33
+ Raises
34
+ ------
35
+ ValueError
36
+ If neither *frames* nor *frames_dir* is provided, if both are
37
+ provided, if *frames_dir* points to an empty directory, if
38
+ *columns* is not an integer ≥ 1, or if a frame has a different
39
+ size than the first frame.
40
+ FileNotFoundError
41
+ If *frames_dir* does not exist.
42
+ NotADirectoryError
43
+ If *frames_dir* is a file, not a directory.
44
+ """
45
+ # ------------------------------------------------------------------
46
+ # Validación de entrada (mismo estilo que create_spritesheet_lineal)
47
+ # ------------------------------------------------------------------
48
+ if frames is None and frames_dir is None:
49
+ raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
50
+
51
+ if frames is not None and frames_dir is not None:
52
+ raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
53
+
54
+ if frames_dir is not None:
55
+ frames_path = Path(frames_dir)
56
+
57
+ if not frames_path.exists():
58
+ raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
59
+
60
+ if not frames_path.is_dir():
61
+ raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
62
+
63
+ frames = sorted(frames_path.glob("*.png"))
64
+
65
+ if not frames:
66
+ raise ValueError("No frames found to create the spritesheet.")
67
+
68
+ # ------------------------------------------------------------------
69
+ # Validación de columns
70
+ # ------------------------------------------------------------------
71
+ if columns is None:
72
+ columns = math.ceil(math.sqrt(len(frames)))
73
+ elif not isinstance(columns, int) or columns < 1:
74
+ raise ValueError("columns must be an integer >= 1.")
75
+
76
+ # ------------------------------------------------------------------
77
+ # Calcular dimensiones del grid
78
+ # ------------------------------------------------------------------
79
+ frame_paths = [Path(f) for f in frames]
80
+
81
+ first_frame_path = frame_paths[0]
82
+ if not first_frame_path.exists():
83
+ raise FileNotFoundError(f"The frame '{first_frame_path}' does not exist.")
84
+
85
+ first_frame = Image.open(first_frame_path)
86
+ frame_width, frame_height = first_frame.size
87
+
88
+ total_frames = len(frame_paths)
89
+ rows = math.ceil(total_frames / columns)
90
+
91
+ sheet_width = columns * frame_width + padding * (columns - 1)
92
+ sheet_height = rows * frame_height + padding * (rows - 1)
93
+
94
+ spritesheet = Image.new("RGBA", (sheet_width, sheet_height), background)
95
+
96
+ # ------------------------------------------------------------------
97
+ # Colocar frames en el grid
98
+ # ------------------------------------------------------------------
99
+ for index, frame_path in enumerate(frame_paths):
100
+ frame_path = Path(frame_path)
101
+
102
+ if not frame_path.exists():
103
+ raise FileNotFoundError(f"The frame '{frame_path}' does not exist.")
104
+
105
+ frame = Image.open(frame_path)
106
+
107
+ if frame.size != (frame_width, frame_height):
108
+ raise ValueError(f"Frame '{frame_path}' has a different size than the first frame.")
109
+
110
+ col = index % columns
111
+ row = index // columns
112
+ x = col * (frame_width + padding)
113
+ y = row * (frame_height + padding)
114
+
115
+ spritesheet.paste(frame, (x, y))
116
+
117
+ # ------------------------------------------------------------------
118
+ # Guardar (mismo patrón que spritesheet_lineal)
119
+ # ------------------------------------------------------------------
120
+ output_path = Path(output_file)
121
+
122
+ if output_path.suffix == "":
123
+ output_path = output_path.with_suffix(".png")
124
+
125
+ if output_path.exists():
126
+ print(f"Warning: The file '{output_file}' already exists and will be overwritten.")
127
+
128
+ output_path.parent.mkdir(parents=True, exist_ok=True)
129
+
130
+ spritesheet.save(output_path)
131
+
132
+ return output_path
@@ -0,0 +1,68 @@
1
+ from pathlib import Path
2
+ from PIL import Image
3
+
4
+ def create_spritesheet_lineal(frames=None, frames_dir=None, output_file="spritesheet.png"):
5
+ if frames is None and frames_dir is None:
6
+ raise ValueError("Either 'frames' or 'frames_dir' must be provided.")
7
+
8
+ if frames is not None and frames_dir is not None:
9
+ raise ValueError("Only one of 'frames' or 'frames_dir' should be provided, not both.")
10
+
11
+ if frames_dir is not None:
12
+ frames_path = Path(frames_dir)
13
+
14
+ if not frames_path.exists():
15
+ raise FileNotFoundError(f"The directory '{frames_dir}' does not exist.")
16
+
17
+ if not frames_path.is_dir():
18
+ raise NotADirectoryError(f"The path '{frames_dir}' is not a directory.")
19
+
20
+ frames = sorted(frames_path.glob("*.png"))
21
+
22
+ if not frames:
23
+ raise ValueError("No frames found to create the spritesheet.")
24
+
25
+ first_frame_path = Path(frames[0])
26
+
27
+ if not first_frame_path.exists():
28
+ raise FileNotFoundError(f"The frame '{frames[0]}' does not exist.")
29
+
30
+ first_frame = Image.open(first_frame_path)
31
+ frame_width, frame_height = first_frame.size
32
+
33
+ total_frames = len(frames)
34
+
35
+ sheet_width = frame_width * total_frames
36
+ sheet_height = frame_height
37
+
38
+ spritesheet = Image.new("RGBA", (sheet_width, sheet_height))
39
+
40
+ for index, frame_path in enumerate(frames):
41
+ frame_path = Path(frame_path)
42
+
43
+ if not frame_path.exists():
44
+ raise FileNotFoundError(f"The frame '{frame_path}' does not exist.")
45
+
46
+ frame = Image.open(frame_path)
47
+
48
+ if frame.size != (frame_width, frame_height):
49
+ raise ValueError(f"Frame '{frame_path}' has a different size than the first frame.")
50
+
51
+ x_position = index * frame_width
52
+ y_position = 0
53
+
54
+ spritesheet.paste(frame, (x_position, y_position))
55
+
56
+ output_path = Path(output_file)
57
+
58
+ if output_path.suffix == "":
59
+ output_path = output_path.with_suffix(".png")
60
+
61
+ if output_path.exists():
62
+ print(f"Warning: The file '{output_file}' already exists and will be overwritten.")
63
+
64
+ output_path.parent.mkdir(parents=True, exist_ok=True)
65
+
66
+ spritesheet.save(output_path)
67
+
68
+ return output_path
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: spriteworkflow
3
+ Version: 0.1.0
4
+ Summary: Simple video to spritesheet workflow library.
5
+ Author-email: jhonvillanueva-hash <jhon.villanueva.lozano.v@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jhonvillanueva-hash/spriteworkflow
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: opencv-python>=4.8.0
14
+ Requires-Dist: pillow>=10.0.0
15
+ Requires-Dist: imageio-ffmpeg>=0.6.0
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=7.0; extra == "test"
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=7.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # SpriteWorkflow
23
+
24
+ Biblioteca Python para convertir videos en spritesheets (hojas de sprites).
25
+ Extrae fotogramas de un video, opcionalmente remueve el fondo por chroma-key,
26
+ y los ensambla en un spritesheet lineal (horizontal) o en cuadrícula (grid).
27
+ Incluye un reporte de validación en formato JSON.
28
+
29
+ ## Instalación
30
+
31
+ ```bash
32
+ pip install spriteworkflow
33
+ ```
34
+
35
+ Requiere **Python ≥ 3.10** y dependencias: `opencv-python`, `pillow`, `imageio-ffmpeg`.
36
+
37
+ ### Dependencias de sistema
38
+
39
+ - **FFmpeg**: necesario para la extracción de fotogramas. La biblioteca lo
40
+ localiza automáticamente con `imageio-ffmpeg`, pero si no está disponible
41
+ puedes instalarlo manualmente:
42
+ - Windows: `winget install ffmpeg`
43
+ - macOS: `brew install ffmpeg`
44
+ - Linux: `sudo apt install ffmpeg`
45
+
46
+ ---
47
+
48
+ ## Funciones de la biblioteca
49
+
50
+ ### `extract_frames(video_name, output_dir="temp")`
51
+
52
+ Extrae fotogramas de un video en archivos PNG numerados.
53
+
54
+ | Parámetro | Tipo | Default | Descripción |
55
+ |----------------|-------------------|-----------|------------------------------------------|
56
+ | `video_name` | `str` o `Path` | — | Ruta al archivo de video |
57
+ | `output_dir` | `str` o `Path` | `"temp"` | Directorio de salida (se crea si no existe) |
58
+
59
+ - **Retorna**: `list[Path]` — rutas a los PNG extraídos, ordenados.
60
+ - **Excepciones**: `FileNotFoundError` (video no existe),
61
+ `ValueError` (archivo inválido o vacío), `PermissionError` (sin permiso),
62
+ `RuntimeError` (FFmpeg falla o no se extrajeron fotogramas).
63
+
64
+ ### `remove_background(frames=None, frames_dir=None, bg_color=None, tolerance=30, feather=0, output_dir="matted")`
65
+
66
+ Elimina el fondo de fotogramas mediante chroma-key (distancia euclidiana en RGB).
67
+
68
+ | Parámetro | Tipo | Default | Descripción |
69
+ |---------------|-------------------|-------------|------------------------------------------------|
70
+ | `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
71
+ | `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa a `frames`) |
72
+ | `bg_color` | `tuple[int,int,int]` | `None` | RGB del chroma-key; auto-detectado si es `None`|
73
+ | `tolerance` | `int` | `30` | Distancia euclídea máxima del color de fondo |
74
+ | `feather` | `int` | `0` | Ancho del difuminado lineal alrededor de `tolerance`|
75
+ | `output_dir` | `str` o `Path` | `"matted"` | Directorio para los PNG con canal alpha |
76
+
77
+ - **Retorna**: `list[Path]` — rutas a los PNG procesados (RGBA), ordenados.
78
+ - **Excepciones**: `ValueError` (parámetros inválidos),
79
+ `FileNotFoundError` (`frames_dir` no existe),
80
+ `NotADirectoryError` (`frames_dir` no es directorio).
81
+
82
+ ### `create_spritesheet_lineal(frames=None, frames_dir=None, output_file="spritesheet.png")`
83
+
84
+ Ensambla fotogramas en un spritesheet horizontal (una fila).
85
+
86
+ | Parámetro | Tipo | Default | Descripción |
87
+ |----------------|-------------------|----------------------|------------------------------------------|
88
+ | `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
89
+ | `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa a `frames`)|
90
+ | `output_file` | `str` o `Path` | `"spritesheet.png"` | Ruta del spritesheet generado |
91
+
92
+ - **Retorna**: `Path` — ruta al archivo generado.
93
+ - **Excepciones**: `ValueError`, `FileNotFoundError`, `NotADirectoryError`.
94
+
95
+ ### `create_spritesheet_grid(frames=None, frames_dir=None, columns=None, padding=0, background=(0,0,0,0), output_file="spritesheet_grid.png")`
96
+
97
+ Ensambla fotogramas en un spritesheet en cuadrícula (filas × columnas).
98
+
99
+ | Parámetro | Tipo | Default | Descripción |
100
+ |----------------|--------------------|----------------------------|------------------------------------------|
101
+ | `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
102
+ | `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa a `frames`)|
103
+ | `columns` | `int` o `None` | `None` | N° de columnas; `None` = `ceil(sqrt(N))`|
104
+ | `padding` | `int` | `0` | Píxeles de espacio entre celdas |
105
+ | `background` | `tuple[int,int,int,int]` | `(0,0,0,0)` | RGBA de relleno para celdas vacías |
106
+ | `output_file` | `str` o `Path` | `"spritesheet_grid.png"` | Ruta del spritesheet generado |
107
+
108
+ - **Retorna**: `Path` — ruta al archivo generado.
109
+ - **Excepciones**: `ValueError`, `FileNotFoundError`, `NotADirectoryError`.
110
+
111
+ ### `generate_report(frames=None, frames_dir=None, spritesheet_path=None, output_file="report.json")`
112
+
113
+ Inspecciona fotogramas y produce un reporte de validación JSON.
114
+
115
+ | Parámetro | Tipo | Default | Descripción |
116
+ |--------------------|-------------------|----------------|------------------------------------------|
117
+ | `frames` | `list[Path]` | `None` | Lista de rutas a los fotogramas PNG |
118
+ | `frames_dir` | `str` o `Path` | `None` | Directorio con PNG (alternativa) |
119
+ | `spritesheet_path` | `str` o `Path` | `None` | Ruta al spritesheet (opcional) |
120
+ | `output_file` | `str` o `Path` | `"report.json"`| Ruta del archivo JSON generado |
121
+
122
+ - **Retorna**: `Path` — ruta al archivo JSON.
123
+ - **Excepciones**: `ValueError`, `FileNotFoundError`, `NotADirectoryError`.
124
+
125
+ El reporte incluye: `total_frames`, `frames[]` (archivo, ancho, alto),
126
+ `consistent_dimensions`, `reference_size`, `mismatched_frames`,
127
+ `spritesheet_path`, `spritesheet_size`, y `generated_at`.
128
+
129
+ ---
130
+
131
+ ## Ejemplo de uso (como biblioteca)
132
+
133
+ ```python
134
+ from spriteworkflow import extract_frames, remove_background
135
+ from spriteworkflow import create_spritesheet_grid, generate_report
136
+
137
+ # 1. Extraer fotogramas de un video
138
+ frames = extract_frames("gameplay.mp4", output_dir="temp")
139
+ print(f"Extraídos {len(frames)} fotogramas")
140
+
141
+ # 2. Remover fondo verde (chroma-key)
142
+ frames_matted = remove_background(
143
+ frames=frames,
144
+ bg_color=(0, 255, 0),
145
+ tolerance=40,
146
+ output_dir="matted",
147
+ )
148
+
149
+ # 3. Crear spritesheet en grilla (3 columnas)
150
+ sheet = create_spritesheet_grid(
151
+ frames=frames_matted,
152
+ columns=3,
153
+ padding=2,
154
+ background=(0, 0, 0, 0),
155
+ output_file="hoja_sprites.png",
156
+ )
157
+ print(f"Spritesheet guardado: {sheet}")
158
+
159
+ # 4. Generar reporte de validación
160
+ report = generate_report(
161
+ frames=frames_matted,
162
+ spritesheet_path=sheet,
163
+ output_file="reporte.json",
164
+ )
165
+ print(f"Reporte guardado: {report}")
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Uso desde CLI
171
+
172
+ El paquete instala el comando `spriteworkflow` con un subcomando `build` que
173
+ encadena el pipeline completo:
174
+
175
+ ```bash
176
+ spriteworkflow build <video> [opciones]
177
+ ```
178
+
179
+ ### Argumentos
180
+
181
+ | Argumento / Flag | Descripción | Default |
182
+ |-------------------------|-----------------------------------------------|-------------------------|
183
+ | `video` | Ruta al video de entrada (requerido) | — |
184
+ | `--output`, `-o` | Ruta del spritesheet generado | `spritesheet.png` |
185
+ | `--layout` | Lineal o grid (`lineal`, `grid`) | `lineal` |
186
+ | `--columns` | Columnas para grilla (auto si no se indica) | auto `ceil(sqrt(N))` |
187
+ | `--padding` | Píxeles de separación entre celdas | `0` |
188
+ | `--matte` | Activa remoción de fondo por chroma-key | desactivado |
189
+ | `--bg-color` | RGB para chroma-key, ej: `--bg-color 0 255 0`| auto-detectado |
190
+ | `--tolerance` | Tolerancia euclídea de chroma-key | `30` |
191
+ | `--feather` | Difuminado del borde del chroma-key | `0` |
192
+ | `--report-file` | Ruta del reporte JSON de validación | no se genera |
193
+ | `--temp-dir` | Directorio temporal para fotogramas extraídos | `temp` |
194
+
195
+ ### Ejemplos
196
+
197
+ ```bash
198
+ # Pipeline mínimo: extraer y ensamblar spritesheet lineal
199
+ spriteworkflow build partida.mp4
200
+
201
+ # Especificar ruta de salida
202
+ spriteworkflow build partida.mp4 -o assets/hoja.png
203
+
204
+ # Spritesheet en grilla de 4 columnas con padding
205
+ spriteworkflow build partida.mp4 --layout grid --columns 4 --padding 2
206
+
207
+ # Remover chroma-key verde, luego ensamblar spritesheet
208
+ spriteworkflow build partida.mp4 --matte --bg-color 0 255 0
209
+
210
+ # Pipeline completo con reporte
211
+ spriteworkflow build partida.mp4 \
212
+ --layout grid --columns 4 --padding 2 \
213
+ --matte --tolerance 40 \
214
+ --report-file reporte.json
215
+ ```
216
+
217
+ ## Ejecutar pruebas
218
+
219
+ El proyecto usa `pytest`. Para ejecutar la suite completa:
220
+
221
+ ```bash
222
+ pip install -e ".[test]"
223
+ pytest -v
224
+ ```
225
+
226
+ Para ejecutar un archivo de pruebas específico:
227
+
228
+ ```bash
229
+ pytest tests/cli_test.py -v
230
+ ```
231
+
232
+ ---
233
+
234
+ ## Licencia
235
+
236
+ MIT
@@ -0,0 +1,14 @@
1
+ spriteworkflow/__init__.py,sha256=nSIW9LISN71kS-YUGl-ALiRN8X2i48smgB7KjYZFrR0,828
2
+ spriteworkflow/cli.py,sha256=2J1NLSy4nqvdSN9NG4NDGkDxNFp7YjF8w9x8HVGPWXc,5869
3
+ spriteworkflow/extractor.py,sha256=kMcXwfGGYgbF2d-TYFY0sGKb-sIv3wMzRcyxF5lpvAI,1847
4
+ spriteworkflow/ffmpeg_manager.py,sha256=OUceUEx6VdTqB7IpA2_hg5u0mVmp_kO18OAqnLgtYD8,250
5
+ spriteworkflow/matte.py,sha256=euZfqo2Qd4y2UhJGJFjEMxz5S6o0P8N4y1ehbHjC5sc,5900
6
+ spriteworkflow/report.py,sha256=hRVxP5nB5j7aAvIYILJBuRQRs5OJRaBxLeDwVg8hLSM,5144
7
+ spriteworkflow/spritesheet_grid.py,sha256=f3StB0JOk-VyB9mryvnhv3R2MKFkgg5qM0AlzhQ2bnQ,4976
8
+ spriteworkflow/spritesheet_lineal.py,sha256=DkVRTDyPBOmHllwmdAzD6CdCIcDdhBrmnq86Ta5EMFE,2365
9
+ spriteworkflow-0.1.0.dist-info/licenses/LICENSE,sha256=Md8q0sNdvz6S9YWEeOpWmx-D13KRlsObRU_vO8bo0wg,1076
10
+ spriteworkflow-0.1.0.dist-info/METADATA,sha256=1Emo4MNKKRu5u6lj7whfUx5aq_v4GofYKbGavkuPf60,10276
11
+ spriteworkflow-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
12
+ spriteworkflow-0.1.0.dist-info/entry_points.txt,sha256=bQKlkkSyEbCjGfG9g2vnGbJ4BUZBr9EM14FlVQ83GoM,59
13
+ spriteworkflow-0.1.0.dist-info/top_level.txt,sha256=oc7QpLj3pf83efWq9YEbzn7KrUwMsLjRSYUV7i7HmfM,15
14
+ spriteworkflow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spriteworkflow = spriteworkflow.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 jhonvillanueva-hash
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.
@@ -0,0 +1 @@
1
+ spriteworkflow