@tidyfactor/design 1.5.0
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.
- package/.tidyfactor +52 -0
- package/AGENTS.md +75 -0
- package/CHANGELOG.md +210 -0
- package/LICENSE +17 -0
- package/README.ar.md +398 -0
- package/README.de.md +44 -0
- package/README.es.md +44 -0
- package/README.fa.md +44 -0
- package/README.fr.md +44 -0
- package/README.md +418 -0
- package/README.pt.md +44 -0
- package/README.zh.md +44 -0
- package/SKILL-REGISTRY.md +69 -0
- package/SKILL.md +46 -0
- package/VISION.md +43 -0
- package/assets/blog.png +0 -0
- package/assets/content_layout.png +0 -0
- package/assets/content_output.png +0 -0
- package/assets/dark.png +0 -0
- package/assets/dashboard_layout.png +0 -0
- package/assets/dashboard_output.png +0 -0
- package/assets/ecommerce_layout.png +0 -0
- package/assets/ecommerce_output.png +0 -0
- package/assets/github-social-preview-2.png +0 -0
- package/assets/github-social-preview.png +0 -0
- package/assets/hero-banner.png +0 -0
- package/assets/light.png +0 -0
- package/assets/media_layout.png +0 -0
- package/assets/media_output.png +0 -0
- package/assets/og-default.png +0 -0
- package/assets/video.png +0 -0
- package/bin/add-skill.js +31 -0
- package/bin/create-kit.js +372 -0
- package/bin/remove-skill.js +137 -0
- package/brand.json +279 -0
- package/memory/01-design-schools.md +85 -0
- package/memory/02-design-tokens.md +41 -0
- package/memory/03-narrative-conversion.md +47 -0
- package/memory/04-motion-principles.md +44 -0
- package/memory/05-component-anatomy.md +34 -0
- package/memory/06-quality-bar.md +83 -0
- package/memory/07-consistency-contract.md +33 -0
- package/memory/08-arabic-bilingual.md +54 -0
- package/memory/09-prototype-flow.md +36 -0
- package/memory/10-python-tooling.md +64 -0
- package/memory/11-brand-json-v2.md +86 -0
- package/memory/12-typography-matrix.md +31 -0
- package/memory/13-layout-archetypes.md +30 -0
- package/memory/14-nav-footer-catalog.md +26 -0
- package/memory/15-performance-budget.md +18 -0
- package/memory/16-design-movements-guide.md +1084 -0
- package/memory/17-storytelling-industries-ux.md +143 -0
- package/memory/18-design-decision-engine.md +81 -0
- package/package.json +64 -0
- package/references/commands/_template.md +27 -0
- package/references/commands/assets.md +35 -0
- package/references/commands/audit.md +15 -0
- package/references/commands/brand.md +85 -0
- package/references/commands/brief.md +107 -0
- package/references/commands/clone.md +26 -0
- package/references/commands/components.md +46 -0
- package/references/commands/dashboard.md +45 -0
- package/references/commands/deploy.md +45 -0
- package/references/commands/flow.md +44 -0
- package/references/commands/handoff.md +53 -0
- package/references/commands/i18n.md +64 -0
- package/references/commands/init.md +30 -0
- package/references/commands/layout.md +81 -0
- package/references/commands/motion.md +62 -0
- package/references/commands/nav-footer.md +54 -0
- package/references/commands/page.md +45 -0
- package/references/commands/palette.md +35 -0
- package/references/commands/perf.md +57 -0
- package/references/commands/retrofit.md +15 -0
- package/references/commands/school.md +41 -0
- package/references/commands/states.md +48 -0
- package/references/commands/study.md +98 -0
- package/references/commands/tokens.md +45 -0
- package/references/commands/typography.md +87 -0
- package/references/foundations/daisyui.md +44 -0
- package/references/foundations/hybrid.md +32 -0
- package/references/foundations/native.md +31 -0
- package/references/foundations/tailwind-utility.md +43 -0
- package/references/memory/01-design-schools.md +85 -0
- package/references/memory/02-design-tokens.md +41 -0
- package/references/memory/03-narrative-conversion.md +47 -0
- package/references/memory/04-motion-principles.md +44 -0
- package/references/memory/05-component-anatomy.md +34 -0
- package/references/memory/06-quality-bar.md +83 -0
- package/references/memory/07-consistency-contract.md +33 -0
- package/references/memory/08-arabic-bilingual.md +54 -0
- package/references/memory/09-prototype-flow.md +36 -0
- package/references/memory/10-python-tooling.md +64 -0
- package/references/memory/11-brand-json-v2.md +86 -0
- package/references/memory/12-typography-matrix.md +31 -0
- package/references/memory/13-layout-archetypes.md +30 -0
- package/references/memory/14-nav-footer-catalog.md +26 -0
- package/references/memory/15-performance-budget.md +18 -0
- package/references/memory/16-design-movements-guide.md +1084 -0
- package/references/memory/17-storytelling-industries-ux.md +143 -0
- package/references/memory/18-design-decision-engine.md +81 -0
- package/references/memory/architecture.md +39 -0
- package/references/memory/decision-points.md +50 -0
- package/references/memory/foundations.md +15 -0
- package/references/memory/quality-bar.md +20 -0
- package/references/tidyfactor-vision.md +66 -0
- package/references/workflow.md +49 -0
- package/references/workflows/audit-prototype.md +26 -0
- package/references/workflows/brief.md +30 -0
- package/references/workflows/clone-prototype.md +27 -0
- package/references/workflows/init-prototype.md +28 -0
- package/references/workflows/retrofit-prototype.md +25 -0
- package/scripts/__pycache__/_utils.cpython-312.pyc +0 -0
- package/scripts/_utils.py +60 -0
- package/scripts/build.py +194 -0
- package/scripts/check_alpha.py +52 -0
- package/scripts/extract_palette.py +139 -0
- package/scripts/generate_transitions.py +105 -0
- package/scripts/inspect_images.py +48 -0
- package/scripts/minify_assets.py +140 -0
- package/scripts/optimize_assets.py +91 -0
- package/scripts/optimize_images.py +176 -0
- package/scripts/prepare_images.py +67 -0
- package/scripts/remove_backgrounds.py +61 -0
- package/scripts/test_build.py +228 -0
- package/templates/design-system/base.css +75 -0
- package/templates/design-system/components.css +229 -0
- package/templates/design-system/interactions.js +31 -0
- package/templates/design-system/motion.js +42 -0
- package/templates/design-system/tokens.css +81 -0
- package/templates/design-system/utilities.css +47 -0
- package/templates/index.html +84 -0
- package/templates/proto-nav.js +56 -0
- package/tools/build-skill.js +126 -0
- package/tools/validate-skill.js +144 -0
- package/tools/validate_skill.py +121 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Asset Bundler & Minifier Script
|
|
4
|
+
Combines and minifies CSS and JavaScript files relative to the project root.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Project root is the parent directory of this script's directory (i.e. scripts/ -> root)
|
|
11
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
|
|
13
|
+
# --- BUNDLE CONFIGURATION (Relative to PROJECT_ROOT) ---
|
|
14
|
+
CSS_BUNDLES = {
|
|
15
|
+
"assets/css/style.min.css": [
|
|
16
|
+
"assets/css/style.css"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
JS_BUNDLES = {
|
|
21
|
+
"assets/js/bundle.min.js": [
|
|
22
|
+
"assets/js/gsap/3.12.5/gsap.min.js",
|
|
23
|
+
"assets/js/gsap/3.12.5/ScrollTrigger.min.js",
|
|
24
|
+
"assets/js/lenis@1.1.13/dist/lenis.min.js",
|
|
25
|
+
"assets/js/main.js"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_size_format(b, factor=1024, suffix="B"):
|
|
31
|
+
for unit in ["", "K", "M", "G"]:
|
|
32
|
+
if b < factor:
|
|
33
|
+
return f"{b:.2f}{unit}{suffix}"
|
|
34
|
+
b /= factor
|
|
35
|
+
return f"{b:.2f}T{suffix}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def minify_css(css_content):
|
|
39
|
+
# Remove comments
|
|
40
|
+
css = re.sub(r'/\*.*?\*/', '', css_content, flags=re.DOTALL)
|
|
41
|
+
# Remove extra spaces around braces and symbols
|
|
42
|
+
css = re.sub(r'\s*([\{\}:;,])\s*', r'\1', css)
|
|
43
|
+
# Replace multiple spaces with a single space
|
|
44
|
+
css = re.sub(r'\s+', ' ', css)
|
|
45
|
+
# Remove unnecessary semicolons before closing braces
|
|
46
|
+
css = re.sub(r';\}', '}', css)
|
|
47
|
+
return css.strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def minify_js(js_content):
|
|
51
|
+
# Save string literals to avoid corrupting comments inside quotes
|
|
52
|
+
strings = []
|
|
53
|
+
def save_str(match):
|
|
54
|
+
strings.append(match.group(0))
|
|
55
|
+
return f"__STR_PLACEHOLDER_{len(strings)-1}__"
|
|
56
|
+
|
|
57
|
+
string_pattern = r'("(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'|`(?:[^`\\]|\\.)*`)'
|
|
58
|
+
js = re.sub(string_pattern, save_str, js_content)
|
|
59
|
+
|
|
60
|
+
# Remove single-line comments // (protecting http:// style links)
|
|
61
|
+
js = re.sub(r'(?<!:)\/\/.*$', '', js, flags=re.MULTILINE)
|
|
62
|
+
# Remove multi-line comments /* ... */
|
|
63
|
+
js = re.sub(r'/\*.*?\*/', '', js, flags=re.DOTALL)
|
|
64
|
+
# Remove multiple spaces/newlines around operator boundaries
|
|
65
|
+
js = re.sub(r'\s*([=\+\-\*\/\:\?\{\}\(\)\[\]\,\;])\s*', r'\1', js)
|
|
66
|
+
# Replace multiple whitespaces/newlines with single line-break or space
|
|
67
|
+
js = re.sub(r'\n+', '\n', js)
|
|
68
|
+
js = re.sub(r'[ \t]+', ' ', js)
|
|
69
|
+
|
|
70
|
+
# Restore string literals
|
|
71
|
+
for i, s in enumerate(strings):
|
|
72
|
+
js = js.replace(f"__STR_PLACEHOLDER_{i}__", s)
|
|
73
|
+
|
|
74
|
+
return js.strip()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_bundle(dest_path_rel, src_files_rel, file_type):
|
|
78
|
+
dest_path = PROJECT_ROOT / dest_path_rel
|
|
79
|
+
print(f"Building bundle -> {dest_path.relative_to(PROJECT_ROOT)}")
|
|
80
|
+
combined_content = []
|
|
81
|
+
total_src_size = 0
|
|
82
|
+
|
|
83
|
+
for src_file in src_files_rel:
|
|
84
|
+
src_path = PROJECT_ROOT / src_file
|
|
85
|
+
if not src_path.exists():
|
|
86
|
+
print(f" [ERROR] Source file not found: {src_file}")
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
size = src_path.stat().st_size
|
|
90
|
+
total_src_size += size
|
|
91
|
+
print(f" + {src_path.name} ({get_size_format(size)})")
|
|
92
|
+
|
|
93
|
+
with open(src_path, "r", encoding="utf-8") as f:
|
|
94
|
+
content = f.read()
|
|
95
|
+
|
|
96
|
+
is_already_minified = src_path.name.endswith(".min.js") or src_path.name.endswith(".min.css")
|
|
97
|
+
|
|
98
|
+
if is_already_minified:
|
|
99
|
+
combined_content.append(content)
|
|
100
|
+
else:
|
|
101
|
+
if file_type == "css":
|
|
102
|
+
minified = minify_css(content)
|
|
103
|
+
elif file_type == "js":
|
|
104
|
+
minified = minify_js(content)
|
|
105
|
+
else:
|
|
106
|
+
minified = content
|
|
107
|
+
combined_content.append(minified)
|
|
108
|
+
|
|
109
|
+
output_content = "\n".join(combined_content)
|
|
110
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
with open(dest_path, "w", encoding="utf-8") as f:
|
|
113
|
+
f.write(output_content)
|
|
114
|
+
|
|
115
|
+
final_size = dest_path.stat().st_size
|
|
116
|
+
saved = total_src_size - final_size
|
|
117
|
+
pct = (saved / total_src_size) * 100 if total_src_size > 0 else 0
|
|
118
|
+
|
|
119
|
+
print(f" [SUCCESS] Combined Size: {get_size_format(total_src_size)} -> {get_size_format(final_size)} (-{pct:.1f}%)")
|
|
120
|
+
print(f" Saved: {get_size_format(saved)}\n")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
print("="*50)
|
|
125
|
+
print("Beginning Asset Bundler and Minifier (Scripts Folder)...")
|
|
126
|
+
print("="*50 + "\n")
|
|
127
|
+
|
|
128
|
+
for dest, sources in CSS_BUNDLES.items():
|
|
129
|
+
build_bundle(dest, sources, "css")
|
|
130
|
+
|
|
131
|
+
for dest, sources in JS_BUNDLES.items():
|
|
132
|
+
build_bundle(dest, sources, "js")
|
|
133
|
+
|
|
134
|
+
print("="*50)
|
|
135
|
+
print("Bundling process completed successfully!")
|
|
136
|
+
print("="*50)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
main()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from PIL import Image
|
|
8
|
+
except ImportError:
|
|
9
|
+
print("Required package not installed. Run: pip install Pillow", file=sys.stderr)
|
|
10
|
+
sys.exit(1)
|
|
11
|
+
|
|
12
|
+
def resize_image_max_dim(path, max_dim, output=None):
|
|
13
|
+
if not os.path.exists(path):
|
|
14
|
+
print(f"[skip] {path} not found")
|
|
15
|
+
return
|
|
16
|
+
try:
|
|
17
|
+
img = Image.open(path)
|
|
18
|
+
w, h = img.size
|
|
19
|
+
if w <= max_dim and h <= max_dim:
|
|
20
|
+
print(f"[ok] {path} already within {max_dim}px ({w}x{h})")
|
|
21
|
+
return
|
|
22
|
+
if w > h:
|
|
23
|
+
new_w = max_dim
|
|
24
|
+
new_h = int(h * (max_dim / w))
|
|
25
|
+
else:
|
|
26
|
+
new_h = max_dim
|
|
27
|
+
new_w = int(w * (max_dim / h))
|
|
28
|
+
print(f"[resize] {path} {w}x{h} -> {new_w}x{new_h}")
|
|
29
|
+
img = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
|
30
|
+
dest = output or path
|
|
31
|
+
img.save(dest)
|
|
32
|
+
print(f"[saved] {dest}")
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"[error] {path}: {e}")
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
description="Resize images so their largest dimension does not exceed a given pixel limit. "
|
|
39
|
+
"Overwrites in place unless --output is specified.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"inputs",
|
|
43
|
+
nargs="*",
|
|
44
|
+
help="Image file paths. Each can be followed by a colon and a max-dim value "
|
|
45
|
+
"(e.g. assets/logo.png:240). The colon suffix is only recognised when the "
|
|
46
|
+
"trailing segment is purely numeric, so Windows paths like C:\\images\\photo.png "
|
|
47
|
+
"work without ambiguity. If no colon value is given, --max-dim is used.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--max-dim",
|
|
51
|
+
type=int,
|
|
52
|
+
default=1200,
|
|
53
|
+
help="Default max dimension in pixels (default: 1200)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--output",
|
|
57
|
+
"-o",
|
|
58
|
+
help="Optional output path (only valid when a single input is given)",
|
|
59
|
+
)
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
|
|
62
|
+
if not args.inputs:
|
|
63
|
+
parser.print_help()
|
|
64
|
+
sys.exit(0)
|
|
65
|
+
|
|
66
|
+
if args.output and len(args.inputs) > 1:
|
|
67
|
+
parser.error("--output can only be used with a single input file")
|
|
68
|
+
|
|
69
|
+
output_for_single = args.output if len(args.inputs) == 1 else None
|
|
70
|
+
for spec in args.inputs:
|
|
71
|
+
# Split spec into path + optional max-dim ONLY when the trailing segment
|
|
72
|
+
# after the last colon is purely numeric. This avoids corrupting
|
|
73
|
+
# Windows absolute paths like C:\images\photo.png (which contain a
|
|
74
|
+
# drive-letter colon that is NOT a dimension modifier).
|
|
75
|
+
path = spec
|
|
76
|
+
dim = args.max_dim
|
|
77
|
+
if ":" in spec:
|
|
78
|
+
left, right = spec.rsplit(":", 1)
|
|
79
|
+
if right.isdigit():
|
|
80
|
+
path, dim = left, int(right)
|
|
81
|
+
|
|
82
|
+
dest = output_for_single or path
|
|
83
|
+
if not output_for_single and os.path.exists(path):
|
|
84
|
+
backup = path + ".bak"
|
|
85
|
+
print(f"[warn] overwriting in-place: {path} (backup -> {backup})")
|
|
86
|
+
shutil.copy2(path, backup)
|
|
87
|
+
|
|
88
|
+
resize_image_max_dim(path, dim, output=dest)
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Image Optimization Utility
|
|
4
|
+
Optimizes JPEGs, PNGs, and WebP images in a directory using Python's Pillow library.
|
|
5
|
+
Resolves paths relative to the project root.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import argparse
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# Project root is parent of scripts/
|
|
14
|
+
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from PIL import Image
|
|
18
|
+
except ImportError:
|
|
19
|
+
print("Error: The 'Pillow' library is required to run this script.")
|
|
20
|
+
print("Install it by running: pip install Pillow")
|
|
21
|
+
sys.exit(1)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_size_format(b, factor=1024, suffix="B"):
|
|
25
|
+
for unit in ["", "K", "M", "G", "T", "P"]:
|
|
26
|
+
if b < factor:
|
|
27
|
+
return f"{b:.2f}{unit}{suffix}"
|
|
28
|
+
b /= factor
|
|
29
|
+
return f"{b:.2f}Y{suffix}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def optimize_image(file_path, quality=85, convert_to_webp=False, replace=True, backup=True):
|
|
33
|
+
path = Path(file_path)
|
|
34
|
+
ext = path.suffix.lower()
|
|
35
|
+
|
|
36
|
+
if ext not in [".jpg", ".jpeg", ".png", ".webp"]:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
orig_size = path.stat().st_size
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Determine destination path
|
|
43
|
+
if convert_to_webp:
|
|
44
|
+
dest_path = path.with_suffix(".webp")
|
|
45
|
+
else:
|
|
46
|
+
dest_path = path
|
|
47
|
+
|
|
48
|
+
# Backup handling: Rename the original file BEFORE opening it
|
|
49
|
+
# This prevents WinError 32 (file in use) on Windows
|
|
50
|
+
backup_path = None
|
|
51
|
+
source_path = path
|
|
52
|
+
|
|
53
|
+
if replace and dest_path == path and backup:
|
|
54
|
+
bp = path.with_name(f"{path.stem}_backup{ext}")
|
|
55
|
+
if not bp.exists():
|
|
56
|
+
os.rename(path, bp)
|
|
57
|
+
backup_path = bp
|
|
58
|
+
source_path = bp
|
|
59
|
+
else:
|
|
60
|
+
source_path = bp
|
|
61
|
+
|
|
62
|
+
# Now process the image from source_path and write to dest_path
|
|
63
|
+
with Image.open(source_path) as img:
|
|
64
|
+
# Handle conversion for JPEG
|
|
65
|
+
if (not convert_to_webp and ext in [".jpg", ".jpeg"]) or (convert_to_webp and ext in [".jpg", ".jpeg"]):
|
|
66
|
+
if img.mode != "RGB":
|
|
67
|
+
img = img.convert("RGB")
|
|
68
|
+
|
|
69
|
+
if convert_to_webp:
|
|
70
|
+
img.save(dest_path, "WEBP", quality=quality, method=6)
|
|
71
|
+
elif ext in [".jpg", ".jpeg"]:
|
|
72
|
+
img.save(dest_path, "JPEG", optimize=True, quality=quality, progressive=True)
|
|
73
|
+
elif ext == ".png":
|
|
74
|
+
if img.mode != "P" and img.mode in ("RGB", "RGBA"):
|
|
75
|
+
try:
|
|
76
|
+
quantized = img.quantize(colors=256)
|
|
77
|
+
quantized.save(dest_path, "PNG", optimize=True)
|
|
78
|
+
except Exception:
|
|
79
|
+
img.save(dest_path, "PNG", optimize=True, compress_level=9)
|
|
80
|
+
else:
|
|
81
|
+
img.save(dest_path, "PNG", optimize=True, compress_level=9)
|
|
82
|
+
elif ext == ".webp":
|
|
83
|
+
img.save(dest_path, "WEBP", quality=quality, method=6)
|
|
84
|
+
|
|
85
|
+
new_size = dest_path.stat().st_size
|
|
86
|
+
saved_bytes = orig_size - new_size
|
|
87
|
+
|
|
88
|
+
if saved_bytes > 0:
|
|
89
|
+
saving_pct = (saved_bytes / orig_size) * 100
|
|
90
|
+
print(f"[OPTIMIZED] {path.name} -> {dest_path.name}")
|
|
91
|
+
print(f" Size: {get_size_format(orig_size)} -> {get_size_format(new_size)} (-{saving_pct:.1f}%)")
|
|
92
|
+
return orig_size, new_size
|
|
93
|
+
else:
|
|
94
|
+
# If the file didn't compress further (new size >= original), restore original
|
|
95
|
+
if backup_path and backup_path.exists():
|
|
96
|
+
if dest_path.exists():
|
|
97
|
+
os.remove(dest_path)
|
|
98
|
+
os.rename(backup_path, dest_path)
|
|
99
|
+
print(f"[SKIPPED] {path.name} (Already optimized)")
|
|
100
|
+
return orig_size, orig_size
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
print(f"[ERROR] Could not optimize {path.name}: {e}")
|
|
104
|
+
# Restore backup in case of half-saved/failed operations
|
|
105
|
+
if 'backup_path' in locals() and backup_path and backup_path.exists():
|
|
106
|
+
if path.exists():
|
|
107
|
+
os.remove(path)
|
|
108
|
+
os.rename(backup_path, path)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main():
|
|
113
|
+
parser = argparse.ArgumentParser(description="Optimize assets in a folder using Pillow.")
|
|
114
|
+
parser.add_argument("directory", help="Path to the directory containing images (relative to project root)")
|
|
115
|
+
parser.add_argument("--quality", type=int, default=85, help="JPEG/WebP quality setting (0-100, default: 85)")
|
|
116
|
+
parser.add_argument("--webp", action="store_true", help="Convert JPEGs and PNGs to WebP format")
|
|
117
|
+
parser.add_argument("--no-backup", action="store_true", help="Do not keep backups of replaced images")
|
|
118
|
+
|
|
119
|
+
args = parser.parse_args()
|
|
120
|
+
|
|
121
|
+
target_dir = PROJECT_ROOT / args.directory
|
|
122
|
+
if not target_dir.exists() or not target_dir.is_dir():
|
|
123
|
+
print(f"Error: '{target_dir}' is not a valid directory.")
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
print(f"Scanning '{target_dir.relative_to(PROJECT_ROOT)}' for images...")
|
|
127
|
+
|
|
128
|
+
supported_extensions = {".png", ".jpg", ".jpeg", ".webp"}
|
|
129
|
+
image_files = []
|
|
130
|
+
|
|
131
|
+
for root, _, files in os.walk(target_dir):
|
|
132
|
+
for file in files:
|
|
133
|
+
file_path = Path(root) / file
|
|
134
|
+
if file_path.suffix.lower() in supported_extensions and "_backup" not in file_path.name:
|
|
135
|
+
image_files.append(file_path)
|
|
136
|
+
|
|
137
|
+
if not image_files:
|
|
138
|
+
print("No supported images found in the directory.")
|
|
139
|
+
sys.exit(0)
|
|
140
|
+
|
|
141
|
+
print(f"Found {len(image_files)} image files. Beginning optimization...\n")
|
|
142
|
+
|
|
143
|
+
total_original = 0
|
|
144
|
+
total_optimized = 0
|
|
145
|
+
optimized_count = 0
|
|
146
|
+
|
|
147
|
+
for file_path in image_files:
|
|
148
|
+
result = optimize_image(
|
|
149
|
+
file_path,
|
|
150
|
+
quality=args.quality,
|
|
151
|
+
convert_to_webp=args.webp,
|
|
152
|
+
replace=True,
|
|
153
|
+
backup=not args.no_backup
|
|
154
|
+
)
|
|
155
|
+
if result:
|
|
156
|
+
orig, new = result
|
|
157
|
+
total_original += orig
|
|
158
|
+
total_optimized += new
|
|
159
|
+
optimized_count += 1
|
|
160
|
+
|
|
161
|
+
print("\n" + "="*40)
|
|
162
|
+
print("Optimization Summary:")
|
|
163
|
+
print("="*40)
|
|
164
|
+
print(f"Total Images Scanned: {len(image_files)}")
|
|
165
|
+
print(f"Total Optimized/Saved: {optimized_count}")
|
|
166
|
+
if total_original > 0:
|
|
167
|
+
saved = total_original - total_optimized
|
|
168
|
+
pct = (saved / total_original) * 100
|
|
169
|
+
print(f"Total Original Size: {get_size_format(total_original)}")
|
|
170
|
+
print(f"Total Optimized Size: {get_size_format(total_optimized)}")
|
|
171
|
+
print(f"Total Disk Space Saved: {get_size_format(saved)} (-{pct:.1f}%)")
|
|
172
|
+
print("="*40)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
main()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from PIL import Image
|
|
7
|
+
except ImportError:
|
|
8
|
+
print("Required package not installed. Run: pip install Pillow", file=sys.stderr)
|
|
9
|
+
sys.exit(1)
|
|
10
|
+
|
|
11
|
+
PNG_EXTS = {".png", ".webp", ".tga", ".bmp", ".tiff"}
|
|
12
|
+
|
|
13
|
+
def convert_to_jpg(src, dest, quality=87):
|
|
14
|
+
if not os.path.exists(src):
|
|
15
|
+
print(f"[skip] {src} not found")
|
|
16
|
+
return
|
|
17
|
+
try:
|
|
18
|
+
img = Image.open(src)
|
|
19
|
+
rgb_img = img.convert("RGB")
|
|
20
|
+
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
21
|
+
rgb_img.save(dest, "JPEG", quality=quality)
|
|
22
|
+
print(f"[ok] {src} -> {dest} (q={quality})")
|
|
23
|
+
except Exception as e:
|
|
24
|
+
print(f"[error] {src}: {e}")
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
description="Convert PNG/WEBP images to optimised JPEG. "
|
|
29
|
+
"Accepts explicit src:dest pairs or an input/output directory pair.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"pairs",
|
|
33
|
+
nargs="*",
|
|
34
|
+
help="src:dest pairs e.g. assets/hero.png:assets/hero.jpg",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--input-dir",
|
|
38
|
+
help="Scan a directory for PNG/WEBP files (alternative to positional pairs)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--output-dir", help="Directory for converted JPEGs (default: same as input)")
|
|
41
|
+
parser.add_argument("--quality", "-q", type=int, default=87, help="JPEG quality 1-100 (default: 87)")
|
|
42
|
+
args = parser.parse_args()
|
|
43
|
+
|
|
44
|
+
if args.input_dir and args.pairs:
|
|
45
|
+
parser.error("Use either --input-dir OR positional pairs, not both.")
|
|
46
|
+
|
|
47
|
+
if args.input_dir:
|
|
48
|
+
out_dir = args.output_dir or args.input_dir
|
|
49
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
50
|
+
for f in sorted(os.listdir(args.input_dir)):
|
|
51
|
+
if os.path.splitext(f)[1].lower() in PNG_EXTS:
|
|
52
|
+
src = os.path.join(args.input_dir, f)
|
|
53
|
+
dest = os.path.join(out_dir, os.path.splitext(f)[0] + ".jpg")
|
|
54
|
+
convert_to_jpg(src, dest, args.quality)
|
|
55
|
+
elif args.pairs:
|
|
56
|
+
for pair in args.pairs:
|
|
57
|
+
if ":" not in pair:
|
|
58
|
+
print(f"[skip] '{pair}' — expected format src:dest", file=sys.stderr)
|
|
59
|
+
continue
|
|
60
|
+
src, dest = pair.split(":", 1)
|
|
61
|
+
convert_to_jpg(src.strip(), dest.strip(), args.quality)
|
|
62
|
+
else:
|
|
63
|
+
parser.print_help()
|
|
64
|
+
sys.exit(0)
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
main()
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from rembg import remove
|
|
7
|
+
from PIL import Image
|
|
8
|
+
except ImportError:
|
|
9
|
+
print("Required packages not installed. Run: pip install rembg pillow", file=sys.stderr)
|
|
10
|
+
sys.exit(1)
|
|
11
|
+
|
|
12
|
+
from _utils import split_spec
|
|
13
|
+
|
|
14
|
+
def remove_bg(src, dest):
|
|
15
|
+
if not os.path.exists(src):
|
|
16
|
+
print(f"[skip] {src} not found")
|
|
17
|
+
return
|
|
18
|
+
try:
|
|
19
|
+
print(f"[work] {src} -> {dest}")
|
|
20
|
+
img = Image.open(src)
|
|
21
|
+
out = remove(img)
|
|
22
|
+
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
|
|
23
|
+
out.save(dest)
|
|
24
|
+
print(f"[ok] {dest}")
|
|
25
|
+
except Exception as e:
|
|
26
|
+
print(f"[error] {src}: {e}")
|
|
27
|
+
|
|
28
|
+
def main():
|
|
29
|
+
parser = argparse.ArgumentParser(
|
|
30
|
+
description="Remove backgrounds from images using rembg. "
|
|
31
|
+
"Accepts src:dest pairs or a single file with --output.",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"inputs",
|
|
35
|
+
nargs="*",
|
|
36
|
+
help="Image paths. Use src:dest syntax for explicit output names, "
|
|
37
|
+
"or plain paths (output appends '_cut' before the extension).",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("--output", "-o", help="Output path (valid only with a single input)")
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
|
|
42
|
+
if not args.inputs:
|
|
43
|
+
parser.print_help()
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
|
|
46
|
+
if args.output and len(args.inputs) > 1:
|
|
47
|
+
parser.error("--output can only be used with a single input file")
|
|
48
|
+
|
|
49
|
+
for spec in args.inputs:
|
|
50
|
+
if ":" in spec:
|
|
51
|
+
parts = split_spec(spec)
|
|
52
|
+
src, dest = parts[0], parts[1] if len(parts) > 1 else parts[0]
|
|
53
|
+
elif args.output:
|
|
54
|
+
src, dest = spec, args.output
|
|
55
|
+
else:
|
|
56
|
+
root, ext = os.path.splitext(spec)
|
|
57
|
+
src, dest = spec, f"{root}_cut{ext}"
|
|
58
|
+
remove_bg(src.strip(), dest.strip())
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|