Initial commit
This commit is contained in:
202
skills/slack-gif-creator/LICENSE.txt
Normal file
202
skills/slack-gif-creator/LICENSE.txt
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
254
skills/slack-gif-creator/SKILL.md
Normal file
254
skills/slack-gif-creator/SKILL.md
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
name: slack-gif-creator
|
||||
description: Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack."
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Slack GIF Creator
|
||||
|
||||
A toolkit providing utilities and knowledge for creating animated GIFs optimized for Slack.
|
||||
|
||||
## Slack Requirements
|
||||
|
||||
**Dimensions:**
|
||||
- Emoji GIFs: 128x128 (recommended)
|
||||
- Message GIFs: 480x480
|
||||
|
||||
**Parameters:**
|
||||
- FPS: 10-30 (lower is smaller file size)
|
||||
- Colors: 48-128 (fewer = smaller file size)
|
||||
- Duration: Keep under 3 seconds for emoji GIFs
|
||||
|
||||
## Core Workflow
|
||||
|
||||
```python
|
||||
from core.gif_builder import GIFBuilder
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# 1. Create builder
|
||||
builder = GIFBuilder(width=128, height=128, fps=10)
|
||||
|
||||
# 2. Generate frames
|
||||
for i in range(12):
|
||||
frame = Image.new('RGB', (128, 128), (240, 248, 255))
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Draw your animation using PIL primitives
|
||||
# (circles, polygons, lines, etc.)
|
||||
|
||||
builder.add_frame(frame)
|
||||
|
||||
# 3. Save with optimization
|
||||
builder.save('output.gif', num_colors=48, optimize_for_emoji=True)
|
||||
```
|
||||
|
||||
## Drawing Graphics
|
||||
|
||||
### Working with User-Uploaded Images
|
||||
If a user uploads an image, consider whether they want to:
|
||||
- **Use it directly** (e.g., "animate this", "split this into frames")
|
||||
- **Use it as inspiration** (e.g., "make something like this")
|
||||
|
||||
Load and work with images using PIL:
|
||||
```python
|
||||
from PIL import Image
|
||||
|
||||
uploaded = Image.open('file.png')
|
||||
# Use directly, or just as reference for colors/style
|
||||
```
|
||||
|
||||
### Drawing from Scratch
|
||||
When drawing graphics from scratch, use PIL ImageDraw primitives:
|
||||
|
||||
```python
|
||||
from PIL import ImageDraw
|
||||
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Circles/ovals
|
||||
draw.ellipse([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
|
||||
|
||||
# Stars, triangles, any polygon
|
||||
points = [(x1, y1), (x2, y2), (x3, y3), ...]
|
||||
draw.polygon(points, fill=(r, g, b), outline=(r, g, b), width=3)
|
||||
|
||||
# Lines
|
||||
draw.line([(x1, y1), (x2, y2)], fill=(r, g, b), width=5)
|
||||
|
||||
# Rectangles
|
||||
draw.rectangle([x1, y1, x2, y2], fill=(r, g, b), outline=(r, g, b), width=3)
|
||||
```
|
||||
|
||||
**Don't use:** Emoji fonts (unreliable across platforms) or assume pre-packaged graphics exist in this skill.
|
||||
|
||||
### Making Graphics Look Good
|
||||
|
||||
Graphics should look polished and creative, not basic. Here's how:
|
||||
|
||||
**Use thicker lines** - Always set `width=2` or higher for outlines and lines. Thin lines (width=1) look choppy and amateurish.
|
||||
|
||||
**Add visual depth**:
|
||||
- Use gradients for backgrounds (`create_gradient_background`)
|
||||
- Layer multiple shapes for complexity (e.g., a star with a smaller star inside)
|
||||
|
||||
**Make shapes more interesting**:
|
||||
- Don't just draw a plain circle - add highlights, rings, or patterns
|
||||
- Stars can have glows (draw larger, semi-transparent versions behind)
|
||||
- Combine multiple shapes (stars + sparkles, circles + rings)
|
||||
|
||||
**Pay attention to colors**:
|
||||
- Use vibrant, complementary colors
|
||||
- Add contrast (dark outlines on light shapes, light outlines on dark shapes)
|
||||
- Consider the overall composition
|
||||
|
||||
**For complex shapes** (hearts, snowflakes, etc.):
|
||||
- Use combinations of polygons and ellipses
|
||||
- Calculate points carefully for symmetry
|
||||
- Add details (a heart can have a highlight curve, snowflakes have intricate branches)
|
||||
|
||||
Be creative and detailed! A good Slack GIF should look polished, not like placeholder graphics.
|
||||
|
||||
## Available Utilities
|
||||
|
||||
### GIFBuilder (`core.gif_builder`)
|
||||
Assembles frames and optimizes for Slack:
|
||||
```python
|
||||
builder = GIFBuilder(width=128, height=128, fps=10)
|
||||
builder.add_frame(frame) # Add PIL Image
|
||||
builder.add_frames(frames) # Add list of frames
|
||||
builder.save('out.gif', num_colors=48, optimize_for_emoji=True, remove_duplicates=True)
|
||||
```
|
||||
|
||||
### Validators (`core.validators`)
|
||||
Check if GIF meets Slack requirements:
|
||||
```python
|
||||
from core.validators import validate_gif, is_slack_ready
|
||||
|
||||
# Detailed validation
|
||||
passes, info = validate_gif('my.gif', is_emoji=True, verbose=True)
|
||||
|
||||
# Quick check
|
||||
if is_slack_ready('my.gif'):
|
||||
print("Ready!")
|
||||
```
|
||||
|
||||
### Easing Functions (`core.easing`)
|
||||
Smooth motion instead of linear:
|
||||
```python
|
||||
from core.easing import interpolate
|
||||
|
||||
# Progress from 0.0 to 1.0
|
||||
t = i / (num_frames - 1)
|
||||
|
||||
# Apply easing
|
||||
y = interpolate(start=0, end=400, t=t, easing='ease_out')
|
||||
|
||||
# Available: linear, ease_in, ease_out, ease_in_out,
|
||||
# bounce_out, elastic_out, back_out
|
||||
```
|
||||
|
||||
### Frame Helpers (`core.frame_composer`)
|
||||
Convenience functions for common needs:
|
||||
```python
|
||||
from core.frame_composer import (
|
||||
create_blank_frame, # Solid color background
|
||||
create_gradient_background, # Vertical gradient
|
||||
draw_circle, # Helper for circles
|
||||
draw_text, # Simple text rendering
|
||||
draw_star # 5-pointed star
|
||||
)
|
||||
```
|
||||
|
||||
## Animation Concepts
|
||||
|
||||
### Shake/Vibrate
|
||||
Offset object position with oscillation:
|
||||
- Use `math.sin()` or `math.cos()` with frame index
|
||||
- Add small random variations for natural feel
|
||||
- Apply to x and/or y position
|
||||
|
||||
### Pulse/Heartbeat
|
||||
Scale object size rhythmically:
|
||||
- Use `math.sin(t * frequency * 2 * math.pi)` for smooth pulse
|
||||
- For heartbeat: two quick pulses then pause (adjust sine wave)
|
||||
- Scale between 0.8 and 1.2 of base size
|
||||
|
||||
### Bounce
|
||||
Object falls and bounces:
|
||||
- Use `interpolate()` with `easing='bounce_out'` for landing
|
||||
- Use `easing='ease_in'` for falling (accelerating)
|
||||
- Apply gravity by increasing y velocity each frame
|
||||
|
||||
### Spin/Rotate
|
||||
Rotate object around center:
|
||||
- PIL: `image.rotate(angle, resample=Image.BICUBIC)`
|
||||
- For wobble: use sine wave for angle instead of linear
|
||||
|
||||
### Fade In/Out
|
||||
Gradually appear or disappear:
|
||||
- Create RGBA image, adjust alpha channel
|
||||
- Or use `Image.blend(image1, image2, alpha)`
|
||||
- Fade in: alpha from 0 to 1
|
||||
- Fade out: alpha from 1 to 0
|
||||
|
||||
### Slide
|
||||
Move object from off-screen to position:
|
||||
- Start position: outside frame bounds
|
||||
- End position: target location
|
||||
- Use `interpolate()` with `easing='ease_out'` for smooth stop
|
||||
- For overshoot: use `easing='back_out'`
|
||||
|
||||
### Zoom
|
||||
Scale and position for zoom effect:
|
||||
- Zoom in: scale from 0.1 to 2.0, crop center
|
||||
- Zoom out: scale from 2.0 to 1.0
|
||||
- Can add motion blur for drama (PIL filter)
|
||||
|
||||
### Explode/Particle Burst
|
||||
Create particles radiating outward:
|
||||
- Generate particles with random angles and velocities
|
||||
- Update each particle: `x += vx`, `y += vy`
|
||||
- Add gravity: `vy += gravity_constant`
|
||||
- Fade out particles over time (reduce alpha)
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
Only when asked to make the file size smaller, implement a few of the following methods:
|
||||
|
||||
1. **Fewer frames** - Lower FPS (10 instead of 20) or shorter duration
|
||||
2. **Fewer colors** - `num_colors=48` instead of 128
|
||||
3. **Smaller dimensions** - 128x128 instead of 480x480
|
||||
4. **Remove duplicates** - `remove_duplicates=True` in save()
|
||||
5. **Emoji mode** - `optimize_for_emoji=True` auto-optimizes
|
||||
|
||||
```python
|
||||
# Maximum optimization for emoji
|
||||
builder.save(
|
||||
'emoji.gif',
|
||||
num_colors=48,
|
||||
optimize_for_emoji=True,
|
||||
remove_duplicates=True
|
||||
)
|
||||
```
|
||||
|
||||
## Philosophy
|
||||
|
||||
This skill provides:
|
||||
- **Knowledge**: Slack's requirements and animation concepts
|
||||
- **Utilities**: GIFBuilder, validators, easing functions
|
||||
- **Flexibility**: Create the animation logic using PIL primitives
|
||||
|
||||
It does NOT provide:
|
||||
- Rigid animation templates or pre-made functions
|
||||
- Emoji font rendering (unreliable across platforms)
|
||||
- A library of pre-packaged graphics built into the skill
|
||||
|
||||
**Note on user uploads**: This skill doesn't include pre-built graphics, but if a user uploads an image, use PIL to load and work with it - interpret based on their request whether they want it used directly or just as inspiration.
|
||||
|
||||
Be creative! Combine concepts (bouncing + rotating, pulsing + sliding, etc.) and use PIL's full capabilities.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
pip install pillow imageio numpy
|
||||
```
|
||||
302
skills/slack-gif-creator/core/color_palettes.py
Executable file
302
skills/slack-gif-creator/core/color_palettes.py
Executable file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Color Palettes - Professional, harmonious color schemes for GIFs.
|
||||
|
||||
Using consistent, well-designed color palettes makes GIFs look professional
|
||||
and polished instead of random and amateurish.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import colorsys
|
||||
|
||||
|
||||
# Professional color palettes - hand-picked for GIF compression and visual appeal
|
||||
|
||||
VIBRANT = {
|
||||
'primary': (255, 68, 68), # Bright red
|
||||
'secondary': (255, 168, 0), # Bright orange
|
||||
'accent': (0, 168, 255), # Bright blue
|
||||
'success': (68, 255, 68), # Bright green
|
||||
'background': (240, 248, 255), # Alice blue
|
||||
'text': (30, 30, 30), # Almost black
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
PASTEL = {
|
||||
'primary': (255, 179, 186), # Pastel pink
|
||||
'secondary': (255, 223, 186), # Pastel peach
|
||||
'accent': (186, 225, 255), # Pastel blue
|
||||
'success': (186, 255, 201), # Pastel green
|
||||
'background': (255, 250, 240), # Floral white
|
||||
'text': (80, 80, 80), # Dark gray
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
DARK = {
|
||||
'primary': (255, 100, 100), # Muted red
|
||||
'secondary': (100, 200, 255), # Muted blue
|
||||
'accent': (255, 200, 100), # Muted gold
|
||||
'success': (100, 255, 150), # Muted green
|
||||
'background': (30, 30, 35), # Almost black
|
||||
'text': (220, 220, 220), # Light gray
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
NEON = {
|
||||
'primary': (255, 16, 240), # Neon pink
|
||||
'secondary': (0, 255, 255), # Cyan
|
||||
'accent': (255, 255, 0), # Yellow
|
||||
'success': (57, 255, 20), # Neon green
|
||||
'background': (20, 20, 30), # Dark blue-black
|
||||
'text': (255, 255, 255), # White
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
PROFESSIONAL = {
|
||||
'primary': (0, 122, 255), # System blue
|
||||
'secondary': (88, 86, 214), # System purple
|
||||
'accent': (255, 149, 0), # System orange
|
||||
'success': (52, 199, 89), # System green
|
||||
'background': (255, 255, 255), # White
|
||||
'text': (0, 0, 0), # Black
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
WARM = {
|
||||
'primary': (255, 107, 107), # Coral red
|
||||
'secondary': (255, 159, 64), # Orange
|
||||
'accent': (255, 218, 121), # Yellow
|
||||
'success': (106, 176, 76), # Olive green
|
||||
'background': (255, 246, 229), # Warm white
|
||||
'text': (51, 51, 51), # Charcoal
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
COOL = {
|
||||
'primary': (107, 185, 240), # Sky blue
|
||||
'secondary': (130, 202, 157), # Mint
|
||||
'accent': (162, 155, 254), # Lavender
|
||||
'success': (86, 217, 150), # Aqua green
|
||||
'background': (240, 248, 255), # Alice blue
|
||||
'text': (45, 55, 72), # Dark slate
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
MONOCHROME = {
|
||||
'primary': (80, 80, 80), # Dark gray
|
||||
'secondary': (130, 130, 130), # Medium gray
|
||||
'accent': (180, 180, 180), # Light gray
|
||||
'success': (100, 100, 100), # Gray
|
||||
'background': (245, 245, 245), # Off-white
|
||||
'text': (30, 30, 30), # Almost black
|
||||
'text_light': (255, 255, 255), # White
|
||||
}
|
||||
|
||||
# Map of palette names
|
||||
PALETTES = {
|
||||
'vibrant': VIBRANT,
|
||||
'pastel': PASTEL,
|
||||
'dark': DARK,
|
||||
'neon': NEON,
|
||||
'professional': PROFESSIONAL,
|
||||
'warm': WARM,
|
||||
'cool': COOL,
|
||||
'monochrome': MONOCHROME,
|
||||
}
|
||||
|
||||
|
||||
def get_palette(name: str = 'vibrant') -> dict:
|
||||
"""
|
||||
Get a color palette by name.
|
||||
|
||||
Args:
|
||||
name: Palette name (vibrant, pastel, dark, neon, professional, warm, cool, monochrome)
|
||||
|
||||
Returns:
|
||||
Dictionary of color roles to RGB tuples
|
||||
"""
|
||||
return PALETTES.get(name.lower(), VIBRANT)
|
||||
|
||||
|
||||
def get_text_color_for_background(bg_color: tuple[int, int, int]) -> tuple[int, int, int]:
|
||||
"""
|
||||
Get the best text color (black or white) for a given background.
|
||||
|
||||
Uses luminance calculation to ensure readability.
|
||||
|
||||
Args:
|
||||
bg_color: Background RGB color
|
||||
|
||||
Returns:
|
||||
Text color (black or white) that contrasts well
|
||||
"""
|
||||
# Calculate relative luminance
|
||||
r, g, b = bg_color
|
||||
luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
|
||||
# Return black for light backgrounds, white for dark
|
||||
return (0, 0, 0) if luminance > 0.5 else (255, 255, 255)
|
||||
|
||||
|
||||
def get_complementary_color(color: tuple[int, int, int]) -> tuple[int, int, int]:
|
||||
"""
|
||||
Get the complementary (opposite) color on the color wheel.
|
||||
|
||||
Args:
|
||||
color: RGB color tuple
|
||||
|
||||
Returns:
|
||||
Complementary RGB color
|
||||
"""
|
||||
# Convert to HSV
|
||||
r, g, b = [x / 255.0 for x in color]
|
||||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||||
|
||||
# Rotate hue by 180 degrees (0.5 in 0-1 scale)
|
||||
h_comp = (h + 0.5) % 1.0
|
||||
|
||||
# Convert back to RGB
|
||||
r_comp, g_comp, b_comp = colorsys.hsv_to_rgb(h_comp, s, v)
|
||||
return (int(r_comp * 255), int(g_comp * 255), int(b_comp * 255))
|
||||
|
||||
|
||||
def lighten_color(color: tuple[int, int, int], amount: float = 0.3) -> tuple[int, int, int]:
|
||||
"""
|
||||
Lighten a color by a given amount.
|
||||
|
||||
Args:
|
||||
color: RGB color tuple
|
||||
amount: Amount to lighten (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Lightened RGB color
|
||||
"""
|
||||
r, g, b = color
|
||||
r = min(255, int(r + (255 - r) * amount))
|
||||
g = min(255, int(g + (255 - g) * amount))
|
||||
b = min(255, int(b + (255 - b) * amount))
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def darken_color(color: tuple[int, int, int], amount: float = 0.3) -> tuple[int, int, int]:
|
||||
"""
|
||||
Darken a color by a given amount.
|
||||
|
||||
Args:
|
||||
color: RGB color tuple
|
||||
amount: Amount to darken (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Darkened RGB color
|
||||
"""
|
||||
r, g, b = color
|
||||
r = max(0, int(r * (1 - amount)))
|
||||
g = max(0, int(g * (1 - amount)))
|
||||
b = max(0, int(b * (1 - amount)))
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def blend_colors(color1: tuple[int, int, int], color2: tuple[int, int, int],
|
||||
ratio: float = 0.5) -> tuple[int, int, int]:
|
||||
"""
|
||||
Blend two colors together.
|
||||
|
||||
Args:
|
||||
color1: First RGB color
|
||||
color2: Second RGB color
|
||||
ratio: Blend ratio (0.0 = all color1, 1.0 = all color2)
|
||||
|
||||
Returns:
|
||||
Blended RGB color
|
||||
"""
|
||||
r1, g1, b1 = color1
|
||||
r2, g2, b2 = color2
|
||||
|
||||
r = int(r1 * (1 - ratio) + r2 * ratio)
|
||||
g = int(g1 * (1 - ratio) + g2 * ratio)
|
||||
b = int(b1 * (1 - ratio) + b2 * ratio)
|
||||
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def create_gradient_colors(start_color: tuple[int, int, int],
|
||||
end_color: tuple[int, int, int],
|
||||
steps: int) -> list[tuple[int, int, int]]:
|
||||
"""
|
||||
Create a gradient of colors between two colors.
|
||||
|
||||
Args:
|
||||
start_color: Starting RGB color
|
||||
end_color: Ending RGB color
|
||||
steps: Number of gradient steps
|
||||
|
||||
Returns:
|
||||
List of RGB colors forming gradient
|
||||
"""
|
||||
colors = []
|
||||
for i in range(steps):
|
||||
ratio = i / (steps - 1) if steps > 1 else 0
|
||||
colors.append(blend_colors(start_color, end_color, ratio))
|
||||
return colors
|
||||
|
||||
|
||||
# Impact/emphasis colors that work well across palettes
|
||||
IMPACT_COLORS = {
|
||||
'flash': (255, 255, 240), # Bright flash (cream)
|
||||
'explosion': (255, 150, 0), # Orange explosion
|
||||
'electricity': (100, 200, 255), # Electric blue
|
||||
'fire': (255, 100, 0), # Fire orange-red
|
||||
'success': (50, 255, 100), # Success green
|
||||
'error': (255, 50, 50), # Error red
|
||||
'warning': (255, 200, 0), # Warning yellow
|
||||
'magic': (200, 100, 255), # Magic purple
|
||||
}
|
||||
|
||||
|
||||
def get_impact_color(effect_type: str = 'flash') -> tuple[int, int, int]:
|
||||
"""
|
||||
Get a color for impact/emphasis effects.
|
||||
|
||||
Args:
|
||||
effect_type: Type of effect (flash, explosion, electricity, etc.)
|
||||
|
||||
Returns:
|
||||
RGB color for effect
|
||||
"""
|
||||
return IMPACT_COLORS.get(effect_type, IMPACT_COLORS['flash'])
|
||||
|
||||
|
||||
# Emoji-safe palettes (work well at 128x128 with 32-64 colors)
|
||||
EMOJI_PALETTES = {
|
||||
'simple': [
|
||||
(255, 255, 255), # White
|
||||
(0, 0, 0), # Black
|
||||
(255, 100, 100), # Red
|
||||
(100, 255, 100), # Green
|
||||
(100, 100, 255), # Blue
|
||||
(255, 255, 100), # Yellow
|
||||
],
|
||||
'vibrant_emoji': [
|
||||
(255, 255, 255), # White
|
||||
(30, 30, 30), # Black
|
||||
(255, 68, 68), # Red
|
||||
(68, 255, 68), # Green
|
||||
(68, 68, 255), # Blue
|
||||
(255, 200, 68), # Gold
|
||||
(255, 68, 200), # Pink
|
||||
(68, 255, 200), # Cyan
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def get_emoji_palette(name: str = 'simple') -> list[tuple[int, int, int]]:
|
||||
"""
|
||||
Get a limited color palette optimized for emoji GIFs (<64KB).
|
||||
|
||||
Args:
|
||||
name: Palette name (simple, vibrant_emoji)
|
||||
|
||||
Returns:
|
||||
List of RGB colors (6-8 colors)
|
||||
"""
|
||||
return EMOJI_PALETTES.get(name, EMOJI_PALETTES['simple'])
|
||||
234
skills/slack-gif-creator/core/easing.py
Executable file
234
skills/slack-gif-creator/core/easing.py
Executable file
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Easing Functions - Timing functions for smooth animations.
|
||||
|
||||
Provides various easing functions for natural motion and timing.
|
||||
All functions take a value t (0.0 to 1.0) and return eased value (0.0 to 1.0).
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
|
||||
def linear(t: float) -> float:
|
||||
"""Linear interpolation (no easing)."""
|
||||
return t
|
||||
|
||||
|
||||
def ease_in_quad(t: float) -> float:
|
||||
"""Quadratic ease-in (slow start, accelerating)."""
|
||||
return t * t
|
||||
|
||||
|
||||
def ease_out_quad(t: float) -> float:
|
||||
"""Quadratic ease-out (fast start, decelerating)."""
|
||||
return t * (2 - t)
|
||||
|
||||
|
||||
def ease_in_out_quad(t: float) -> float:
|
||||
"""Quadratic ease-in-out (slow start and end)."""
|
||||
if t < 0.5:
|
||||
return 2 * t * t
|
||||
return -1 + (4 - 2 * t) * t
|
||||
|
||||
|
||||
def ease_in_cubic(t: float) -> float:
|
||||
"""Cubic ease-in (slow start)."""
|
||||
return t * t * t
|
||||
|
||||
|
||||
def ease_out_cubic(t: float) -> float:
|
||||
"""Cubic ease-out (fast start)."""
|
||||
return (t - 1) * (t - 1) * (t - 1) + 1
|
||||
|
||||
|
||||
def ease_in_out_cubic(t: float) -> float:
|
||||
"""Cubic ease-in-out."""
|
||||
if t < 0.5:
|
||||
return 4 * t * t * t
|
||||
return (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
|
||||
|
||||
|
||||
def ease_in_bounce(t: float) -> float:
|
||||
"""Bounce ease-in (bouncy start)."""
|
||||
return 1 - ease_out_bounce(1 - t)
|
||||
|
||||
|
||||
def ease_out_bounce(t: float) -> float:
|
||||
"""Bounce ease-out (bouncy end)."""
|
||||
if t < 1 / 2.75:
|
||||
return 7.5625 * t * t
|
||||
elif t < 2 / 2.75:
|
||||
t -= 1.5 / 2.75
|
||||
return 7.5625 * t * t + 0.75
|
||||
elif t < 2.5 / 2.75:
|
||||
t -= 2.25 / 2.75
|
||||
return 7.5625 * t * t + 0.9375
|
||||
else:
|
||||
t -= 2.625 / 2.75
|
||||
return 7.5625 * t * t + 0.984375
|
||||
|
||||
|
||||
def ease_in_out_bounce(t: float) -> float:
|
||||
"""Bounce ease-in-out."""
|
||||
if t < 0.5:
|
||||
return ease_in_bounce(t * 2) * 0.5
|
||||
return ease_out_bounce(t * 2 - 1) * 0.5 + 0.5
|
||||
|
||||
|
||||
def ease_in_elastic(t: float) -> float:
|
||||
"""Elastic ease-in (spring effect)."""
|
||||
if t == 0 or t == 1:
|
||||
return t
|
||||
return -math.pow(2, 10 * (t - 1)) * math.sin((t - 1.1) * 5 * math.pi)
|
||||
|
||||
|
||||
def ease_out_elastic(t: float) -> float:
|
||||
"""Elastic ease-out (spring effect)."""
|
||||
if t == 0 or t == 1:
|
||||
return t
|
||||
return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) + 1
|
||||
|
||||
|
||||
def ease_in_out_elastic(t: float) -> float:
|
||||
"""Elastic ease-in-out."""
|
||||
if t == 0 or t == 1:
|
||||
return t
|
||||
t = t * 2 - 1
|
||||
if t < 0:
|
||||
return -0.5 * math.pow(2, 10 * t) * math.sin((t - 0.1) * 5 * math.pi)
|
||||
return math.pow(2, -10 * t) * math.sin((t - 0.1) * 5 * math.pi) * 0.5 + 1
|
||||
|
||||
|
||||
# Convenience mapping
|
||||
EASING_FUNCTIONS = {
|
||||
"linear": linear,
|
||||
"ease_in": ease_in_quad,
|
||||
"ease_out": ease_out_quad,
|
||||
"ease_in_out": ease_in_out_quad,
|
||||
"bounce_in": ease_in_bounce,
|
||||
"bounce_out": ease_out_bounce,
|
||||
"bounce": ease_in_out_bounce,
|
||||
"elastic_in": ease_in_elastic,
|
||||
"elastic_out": ease_out_elastic,
|
||||
"elastic": ease_in_out_elastic,
|
||||
}
|
||||
|
||||
|
||||
def get_easing(name: str = "linear"):
|
||||
"""Get easing function by name."""
|
||||
return EASING_FUNCTIONS.get(name, linear)
|
||||
|
||||
|
||||
def interpolate(start: float, end: float, t: float, easing: str = "linear") -> float:
|
||||
"""
|
||||
Interpolate between two values with easing.
|
||||
|
||||
Args:
|
||||
start: Start value
|
||||
end: End value
|
||||
t: Progress from 0.0 to 1.0
|
||||
easing: Name of easing function
|
||||
|
||||
Returns:
|
||||
Interpolated value
|
||||
"""
|
||||
ease_func = get_easing(easing)
|
||||
eased_t = ease_func(t)
|
||||
return start + (end - start) * eased_t
|
||||
|
||||
|
||||
def ease_back_in(t: float) -> float:
|
||||
"""Back ease-in (slight overshoot backward before forward motion)."""
|
||||
c1 = 1.70158
|
||||
c3 = c1 + 1
|
||||
return c3 * t * t * t - c1 * t * t
|
||||
|
||||
|
||||
def ease_back_out(t: float) -> float:
|
||||
"""Back ease-out (overshoot forward then settle back)."""
|
||||
c1 = 1.70158
|
||||
c3 = c1 + 1
|
||||
return 1 + c3 * pow(t - 1, 3) + c1 * pow(t - 1, 2)
|
||||
|
||||
|
||||
def ease_back_in_out(t: float) -> float:
|
||||
"""Back ease-in-out (overshoot at both ends)."""
|
||||
c1 = 1.70158
|
||||
c2 = c1 * 1.525
|
||||
if t < 0.5:
|
||||
return (pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
|
||||
return (pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2
|
||||
|
||||
|
||||
def apply_squash_stretch(
|
||||
base_scale: tuple[float, float], intensity: float, direction: str = "vertical"
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate squash and stretch scales for more dynamic animation.
|
||||
|
||||
Args:
|
||||
base_scale: (width_scale, height_scale) base scales
|
||||
intensity: Squash/stretch intensity (0.0-1.0)
|
||||
direction: 'vertical', 'horizontal', or 'both'
|
||||
|
||||
Returns:
|
||||
(width_scale, height_scale) with squash/stretch applied
|
||||
"""
|
||||
width_scale, height_scale = base_scale
|
||||
|
||||
if direction == "vertical":
|
||||
# Compress vertically, expand horizontally (preserve volume)
|
||||
height_scale *= 1 - intensity * 0.5
|
||||
width_scale *= 1 + intensity * 0.5
|
||||
elif direction == "horizontal":
|
||||
# Compress horizontally, expand vertically
|
||||
width_scale *= 1 - intensity * 0.5
|
||||
height_scale *= 1 + intensity * 0.5
|
||||
elif direction == "both":
|
||||
# General squash (both dimensions)
|
||||
width_scale *= 1 - intensity * 0.3
|
||||
height_scale *= 1 - intensity * 0.3
|
||||
|
||||
return (width_scale, height_scale)
|
||||
|
||||
|
||||
def calculate_arc_motion(
|
||||
start: tuple[float, float], end: tuple[float, float], height: float, t: float
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Calculate position along a parabolic arc (natural motion path).
|
||||
|
||||
Args:
|
||||
start: (x, y) starting position
|
||||
end: (x, y) ending position
|
||||
height: Arc height at midpoint (positive = upward)
|
||||
t: Progress (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
(x, y) position along arc
|
||||
"""
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
|
||||
# Linear interpolation for x
|
||||
x = x1 + (x2 - x1) * t
|
||||
|
||||
# Parabolic interpolation for y
|
||||
# y = start + progress * (end - start) + arc_offset
|
||||
# Arc offset peaks at t=0.5
|
||||
arc_offset = 4 * height * t * (1 - t)
|
||||
y = y1 + (y2 - y1) * t - arc_offset
|
||||
|
||||
return (x, y)
|
||||
|
||||
|
||||
# Add new easing functions to the convenience mapping
|
||||
EASING_FUNCTIONS.update(
|
||||
{
|
||||
"back_in": ease_back_in,
|
||||
"back_out": ease_back_out,
|
||||
"back_in_out": ease_back_in_out,
|
||||
"anticipate": ease_back_in, # Alias
|
||||
"overshoot": ease_back_out, # Alias
|
||||
}
|
||||
)
|
||||
176
skills/slack-gif-creator/core/frame_composer.py
Executable file
176
skills/slack-gif-creator/core/frame_composer.py
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Frame Composer - Utilities for composing visual elements into frames.
|
||||
|
||||
Provides functions for drawing shapes, text, emojis, and compositing elements
|
||||
together to create animation frames.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def create_blank_frame(
|
||||
width: int, height: int, color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Create a blank frame with solid color background.
|
||||
|
||||
Args:
|
||||
width: Frame width
|
||||
height: Frame height
|
||||
color: RGB color tuple (default: white)
|
||||
|
||||
Returns:
|
||||
PIL Image
|
||||
"""
|
||||
return Image.new("RGB", (width, height), color)
|
||||
|
||||
|
||||
def draw_circle(
|
||||
frame: Image.Image,
|
||||
center: tuple[int, int],
|
||||
radius: int,
|
||||
fill_color: Optional[tuple[int, int, int]] = None,
|
||||
outline_color: Optional[tuple[int, int, int]] = None,
|
||||
outline_width: int = 1,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw a circle on a frame.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
center: (x, y) center position
|
||||
radius: Circle radius
|
||||
fill_color: RGB fill color (None for no fill)
|
||||
outline_color: RGB outline color (None for no outline)
|
||||
outline_width: Outline width in pixels
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x, y = center
|
||||
bbox = [x - radius, y - radius, x + radius, y + radius]
|
||||
draw.ellipse(bbox, fill=fill_color, outline=outline_color, width=outline_width)
|
||||
return frame
|
||||
|
||||
|
||||
def draw_text(
|
||||
frame: Image.Image,
|
||||
text: str,
|
||||
position: tuple[int, int],
|
||||
color: tuple[int, int, int] = (0, 0, 0),
|
||||
centered: bool = False,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw text on a frame.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
text: Text to draw
|
||||
position: (x, y) position (top-left unless centered=True)
|
||||
color: RGB text color
|
||||
centered: If True, center text at position
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Uses Pillow's default font.
|
||||
# If the font should be changed for the emoji, add additional logic here.
|
||||
font = ImageFont.load_default()
|
||||
|
||||
if centered:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = position[0] - text_width // 2
|
||||
y = position[1] - text_height // 2
|
||||
position = (x, y)
|
||||
|
||||
draw.text(position, text, fill=color, font=font)
|
||||
return frame
|
||||
|
||||
|
||||
def create_gradient_background(
|
||||
width: int,
|
||||
height: int,
|
||||
top_color: tuple[int, int, int],
|
||||
bottom_color: tuple[int, int, int],
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Create a vertical gradient background.
|
||||
|
||||
Args:
|
||||
width: Frame width
|
||||
height: Frame height
|
||||
top_color: RGB color at top
|
||||
bottom_color: RGB color at bottom
|
||||
|
||||
Returns:
|
||||
PIL Image with gradient
|
||||
"""
|
||||
frame = Image.new("RGB", (width, height))
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Calculate color step for each row
|
||||
r1, g1, b1 = top_color
|
||||
r2, g2, b2 = bottom_color
|
||||
|
||||
for y in range(height):
|
||||
# Interpolate color
|
||||
ratio = y / height
|
||||
r = int(r1 * (1 - ratio) + r2 * ratio)
|
||||
g = int(g1 * (1 - ratio) + g2 * ratio)
|
||||
b = int(b1 * (1 - ratio) + b2 * ratio)
|
||||
|
||||
# Draw horizontal line
|
||||
draw.line([(0, y), (width, y)], fill=(r, g, b))
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def draw_star(
|
||||
frame: Image.Image,
|
||||
center: tuple[int, int],
|
||||
size: int,
|
||||
fill_color: tuple[int, int, int],
|
||||
outline_color: Optional[tuple[int, int, int]] = None,
|
||||
outline_width: int = 1,
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw a 5-pointed star.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
center: (x, y) center position
|
||||
size: Star size (outer radius)
|
||||
fill_color: RGB fill color
|
||||
outline_color: RGB outline color (None for no outline)
|
||||
outline_width: Outline width
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
import math
|
||||
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x, y = center
|
||||
|
||||
# Calculate star points
|
||||
points = []
|
||||
for i in range(10):
|
||||
angle = (i * 36 - 90) * math.pi / 180 # 36 degrees per point, start at top
|
||||
radius = size if i % 2 == 0 else size * 0.4 # Alternate between outer and inner
|
||||
px = x + radius * math.cos(angle)
|
||||
py = y + radius * math.sin(angle)
|
||||
points.append((px, py))
|
||||
|
||||
# Draw star
|
||||
draw.polygon(points, fill=fill_color, outline=outline_color, width=outline_width)
|
||||
|
||||
return frame
|
||||
269
skills/slack-gif-creator/core/gif_builder.py
Executable file
269
skills/slack-gif-creator/core/gif_builder.py
Executable file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GIF Builder - Core module for assembling frames into GIFs optimized for Slack.
|
||||
|
||||
This module provides the main interface for creating GIFs from programmatically
|
||||
generated frames, with automatic optimization for Slack's requirements.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import imageio.v3 as imageio
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class GIFBuilder:
|
||||
"""Builder for creating optimized GIFs from frames."""
|
||||
|
||||
def __init__(self, width: int = 480, height: int = 480, fps: int = 15):
|
||||
"""
|
||||
Initialize GIF builder.
|
||||
|
||||
Args:
|
||||
width: Frame width in pixels
|
||||
height: Frame height in pixels
|
||||
fps: Frames per second
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.fps = fps
|
||||
self.frames: list[np.ndarray] = []
|
||||
|
||||
def add_frame(self, frame: np.ndarray | Image.Image):
|
||||
"""
|
||||
Add a frame to the GIF.
|
||||
|
||||
Args:
|
||||
frame: Frame as numpy array or PIL Image (will be converted to RGB)
|
||||
"""
|
||||
if isinstance(frame, Image.Image):
|
||||
frame = np.array(frame.convert("RGB"))
|
||||
|
||||
# Ensure frame is correct size
|
||||
if frame.shape[:2] != (self.height, self.width):
|
||||
pil_frame = Image.fromarray(frame)
|
||||
pil_frame = pil_frame.resize(
|
||||
(self.width, self.height), Image.Resampling.LANCZOS
|
||||
)
|
||||
frame = np.array(pil_frame)
|
||||
|
||||
self.frames.append(frame)
|
||||
|
||||
def add_frames(self, frames: list[np.ndarray | Image.Image]):
|
||||
"""Add multiple frames at once."""
|
||||
for frame in frames:
|
||||
self.add_frame(frame)
|
||||
|
||||
def optimize_colors(
|
||||
self, num_colors: int = 128, use_global_palette: bool = True
|
||||
) -> list[np.ndarray]:
|
||||
"""
|
||||
Reduce colors in all frames using quantization.
|
||||
|
||||
Args:
|
||||
num_colors: Target number of colors (8-256)
|
||||
use_global_palette: Use a single palette for all frames (better compression)
|
||||
|
||||
Returns:
|
||||
List of color-optimized frames
|
||||
"""
|
||||
optimized = []
|
||||
|
||||
if use_global_palette and len(self.frames) > 1:
|
||||
# Create a global palette from all frames
|
||||
# Sample frames to build palette
|
||||
sample_size = min(5, len(self.frames))
|
||||
sample_indices = [
|
||||
int(i * len(self.frames) / sample_size) for i in range(sample_size)
|
||||
]
|
||||
sample_frames = [self.frames[i] for i in sample_indices]
|
||||
|
||||
# Combine sample frames into a single image for palette generation
|
||||
# Flatten each frame to get all pixels, then stack them
|
||||
all_pixels = np.vstack(
|
||||
[f.reshape(-1, 3) for f in sample_frames]
|
||||
) # (total_pixels, 3)
|
||||
|
||||
# Create a properly-shaped RGB image from the pixel data
|
||||
# We'll make a roughly square image from all the pixels
|
||||
total_pixels = len(all_pixels)
|
||||
width = min(512, int(np.sqrt(total_pixels))) # Reasonable width, max 512
|
||||
height = (total_pixels + width - 1) // width # Ceiling division
|
||||
|
||||
# Pad if necessary to fill the rectangle
|
||||
pixels_needed = width * height
|
||||
if pixels_needed > total_pixels:
|
||||
padding = np.zeros((pixels_needed - total_pixels, 3), dtype=np.uint8)
|
||||
all_pixels = np.vstack([all_pixels, padding])
|
||||
|
||||
# Reshape to proper RGB image format (H, W, 3)
|
||||
img_array = (
|
||||
all_pixels[:pixels_needed].reshape(height, width, 3).astype(np.uint8)
|
||||
)
|
||||
combined_img = Image.fromarray(img_array, mode="RGB")
|
||||
|
||||
# Generate global palette
|
||||
global_palette = combined_img.quantize(colors=num_colors, method=2)
|
||||
|
||||
# Apply global palette to all frames
|
||||
for frame in self.frames:
|
||||
pil_frame = Image.fromarray(frame)
|
||||
quantized = pil_frame.quantize(palette=global_palette, dither=1)
|
||||
optimized.append(np.array(quantized.convert("RGB")))
|
||||
else:
|
||||
# Use per-frame quantization
|
||||
for frame in self.frames:
|
||||
pil_frame = Image.fromarray(frame)
|
||||
quantized = pil_frame.quantize(colors=num_colors, method=2, dither=1)
|
||||
optimized.append(np.array(quantized.convert("RGB")))
|
||||
|
||||
return optimized
|
||||
|
||||
def deduplicate_frames(self, threshold: float = 0.9995) -> int:
|
||||
"""
|
||||
Remove duplicate or near-duplicate consecutive frames.
|
||||
|
||||
Args:
|
||||
threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.9995 = nearly identical).
|
||||
Use 0.9995+ to preserve subtle animations, 0.98 for aggressive removal.
|
||||
|
||||
Returns:
|
||||
Number of frames removed
|
||||
"""
|
||||
if len(self.frames) < 2:
|
||||
return 0
|
||||
|
||||
deduplicated = [self.frames[0]]
|
||||
removed_count = 0
|
||||
|
||||
for i in range(1, len(self.frames)):
|
||||
# Compare with previous frame
|
||||
prev_frame = np.array(deduplicated[-1], dtype=np.float32)
|
||||
curr_frame = np.array(self.frames[i], dtype=np.float32)
|
||||
|
||||
# Calculate similarity (normalized)
|
||||
diff = np.abs(prev_frame - curr_frame)
|
||||
similarity = 1.0 - (np.mean(diff) / 255.0)
|
||||
|
||||
# Keep frame if sufficiently different
|
||||
# High threshold (0.9995+) means only remove nearly identical frames
|
||||
if similarity < threshold:
|
||||
deduplicated.append(self.frames[i])
|
||||
else:
|
||||
removed_count += 1
|
||||
|
||||
self.frames = deduplicated
|
||||
return removed_count
|
||||
|
||||
def save(
|
||||
self,
|
||||
output_path: str | Path,
|
||||
num_colors: int = 128,
|
||||
optimize_for_emoji: bool = False,
|
||||
remove_duplicates: bool = False,
|
||||
) -> dict:
|
||||
"""
|
||||
Save frames as optimized GIF for Slack.
|
||||
|
||||
Args:
|
||||
output_path: Where to save the GIF
|
||||
num_colors: Number of colors to use (fewer = smaller file)
|
||||
optimize_for_emoji: If True, optimize for emoji size (128x128, fewer colors)
|
||||
remove_duplicates: If True, remove duplicate consecutive frames (opt-in)
|
||||
|
||||
Returns:
|
||||
Dictionary with file info (path, size, dimensions, frame_count)
|
||||
"""
|
||||
if not self.frames:
|
||||
raise ValueError("No frames to save. Add frames with add_frame() first.")
|
||||
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Remove duplicate frames to reduce file size
|
||||
if remove_duplicates:
|
||||
removed = self.deduplicate_frames(threshold=0.9995)
|
||||
if removed > 0:
|
||||
print(
|
||||
f" Removed {removed} nearly identical frames (preserved subtle animations)"
|
||||
)
|
||||
|
||||
# Optimize for emoji if requested
|
||||
if optimize_for_emoji:
|
||||
if self.width > 128 or self.height > 128:
|
||||
print(
|
||||
f" Resizing from {self.width}x{self.height} to 128x128 for emoji"
|
||||
)
|
||||
self.width = 128
|
||||
self.height = 128
|
||||
# Resize all frames
|
||||
resized_frames = []
|
||||
for frame in self.frames:
|
||||
pil_frame = Image.fromarray(frame)
|
||||
pil_frame = pil_frame.resize((128, 128), Image.Resampling.LANCZOS)
|
||||
resized_frames.append(np.array(pil_frame))
|
||||
self.frames = resized_frames
|
||||
num_colors = min(num_colors, 48) # More aggressive color limit for emoji
|
||||
|
||||
# More aggressive FPS reduction for emoji
|
||||
if len(self.frames) > 12:
|
||||
print(
|
||||
f" Reducing frames from {len(self.frames)} to ~12 for emoji size"
|
||||
)
|
||||
# Keep every nth frame to get close to 12 frames
|
||||
keep_every = max(1, len(self.frames) // 12)
|
||||
self.frames = [
|
||||
self.frames[i] for i in range(0, len(self.frames), keep_every)
|
||||
]
|
||||
|
||||
# Optimize colors with global palette
|
||||
optimized_frames = self.optimize_colors(num_colors, use_global_palette=True)
|
||||
|
||||
# Calculate frame duration in milliseconds
|
||||
frame_duration = 1000 / self.fps
|
||||
|
||||
# Save GIF
|
||||
imageio.imwrite(
|
||||
output_path,
|
||||
optimized_frames,
|
||||
duration=frame_duration,
|
||||
loop=0, # Infinite loop
|
||||
)
|
||||
|
||||
# Get file info
|
||||
file_size_kb = output_path.stat().st_size / 1024
|
||||
file_size_mb = file_size_kb / 1024
|
||||
|
||||
info = {
|
||||
"path": str(output_path),
|
||||
"size_kb": file_size_kb,
|
||||
"size_mb": file_size_mb,
|
||||
"dimensions": f"{self.width}x{self.height}",
|
||||
"frame_count": len(optimized_frames),
|
||||
"fps": self.fps,
|
||||
"duration_seconds": len(optimized_frames) / self.fps,
|
||||
"colors": num_colors,
|
||||
}
|
||||
|
||||
# Print info
|
||||
print(f"\n✓ GIF created successfully!")
|
||||
print(f" Path: {output_path}")
|
||||
print(f" Size: {file_size_kb:.1f} KB ({file_size_mb:.2f} MB)")
|
||||
print(f" Dimensions: {self.width}x{self.height}")
|
||||
print(f" Frames: {len(optimized_frames)} @ {self.fps} fps")
|
||||
print(f" Duration: {info['duration_seconds']:.1f}s")
|
||||
print(f" Colors: {num_colors}")
|
||||
|
||||
# Size info
|
||||
if optimize_for_emoji:
|
||||
print(f" Optimized for emoji (128x128, reduced colors)")
|
||||
if file_size_mb > 1.0:
|
||||
print(f"\n Note: Large file size ({file_size_kb:.1f} KB)")
|
||||
print(" Consider: fewer frames, smaller dimensions, or fewer colors")
|
||||
|
||||
return info
|
||||
|
||||
def clear(self):
|
||||
"""Clear all frames (useful for creating multiple GIFs)."""
|
||||
self.frames = []
|
||||
357
skills/slack-gif-creator/core/typography.py
Executable file
357
skills/slack-gif-creator/core/typography.py
Executable file
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Typography System - Professional text rendering with outlines, shadows, and effects.
|
||||
|
||||
This module provides high-quality text rendering that looks crisp and professional
|
||||
in GIFs, with outlines for readability and effects for visual impact.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Typography scale - proportional sizing system
|
||||
TYPOGRAPHY_SCALE = {
|
||||
'h1': 60, # Large headers
|
||||
'h2': 48, # Medium headers
|
||||
'h3': 36, # Small headers
|
||||
'title': 50, # Title text
|
||||
'body': 28, # Body text
|
||||
'small': 20, # Small text
|
||||
'tiny': 16, # Tiny text
|
||||
}
|
||||
|
||||
|
||||
def get_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||
"""
|
||||
Get a font with fallback support.
|
||||
|
||||
Args:
|
||||
size: Font size in pixels
|
||||
bold: Use bold variant if available
|
||||
|
||||
Returns:
|
||||
ImageFont object
|
||||
"""
|
||||
# Try multiple font paths for cross-platform support
|
||||
font_paths = [
|
||||
# macOS fonts
|
||||
"/System/Library/Fonts/Helvetica.ttc",
|
||||
"/System/Library/Fonts/SF-Pro.ttf",
|
||||
"/Library/Fonts/Arial Bold.ttf" if bold else "/Library/Fonts/Arial.ttf",
|
||||
# Linux fonts
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
# Windows fonts
|
||||
"C:\\Windows\\Fonts\\arialbd.ttf" if bold else "C:\\Windows\\Fonts\\arial.ttf",
|
||||
]
|
||||
|
||||
for font_path in font_paths:
|
||||
try:
|
||||
return ImageFont.truetype(font_path, size)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Ultimate fallback
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_text_with_outline(
|
||||
frame: Image.Image,
|
||||
text: str,
|
||||
position: tuple[int, int],
|
||||
font_size: int = 40,
|
||||
text_color: tuple[int, int, int] = (255, 255, 255),
|
||||
outline_color: tuple[int, int, int] = (0, 0, 0),
|
||||
outline_width: int = 3,
|
||||
centered: bool = False,
|
||||
bold: bool = True
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw text with outline for maximum readability.
|
||||
|
||||
This is THE most important function for professional-looking text in GIFs.
|
||||
The outline ensures text is readable on any background.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
text: Text to draw
|
||||
position: (x, y) position
|
||||
font_size: Font size in pixels
|
||||
text_color: RGB color for text fill
|
||||
outline_color: RGB color for outline
|
||||
outline_width: Width of outline in pixels (2-4 recommended)
|
||||
centered: If True, center text at position
|
||||
bold: Use bold font variant
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
font = get_font(font_size, bold=bold)
|
||||
|
||||
# Calculate position for centering
|
||||
if centered:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = position[0] - text_width // 2
|
||||
y = position[1] - text_height // 2
|
||||
position = (x, y)
|
||||
|
||||
# Draw outline by drawing text multiple times offset in all directions
|
||||
x, y = position
|
||||
for offset_x in range(-outline_width, outline_width + 1):
|
||||
for offset_y in range(-outline_width, outline_width + 1):
|
||||
if offset_x != 0 or offset_y != 0:
|
||||
draw.text((x + offset_x, y + offset_y), text, fill=outline_color, font=font)
|
||||
|
||||
# Draw main text on top
|
||||
draw.text(position, text, fill=text_color, font=font)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def draw_text_with_shadow(
|
||||
frame: Image.Image,
|
||||
text: str,
|
||||
position: tuple[int, int],
|
||||
font_size: int = 40,
|
||||
text_color: tuple[int, int, int] = (255, 255, 255),
|
||||
shadow_color: tuple[int, int, int] = (0, 0, 0),
|
||||
shadow_offset: tuple[int, int] = (3, 3),
|
||||
centered: bool = False,
|
||||
bold: bool = True
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw text with drop shadow for depth.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
text: Text to draw
|
||||
position: (x, y) position
|
||||
font_size: Font size in pixels
|
||||
text_color: RGB color for text
|
||||
shadow_color: RGB color for shadow
|
||||
shadow_offset: (x, y) offset for shadow
|
||||
centered: If True, center text at position
|
||||
bold: Use bold font variant
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
font = get_font(font_size, bold=bold)
|
||||
|
||||
# Calculate position for centering
|
||||
if centered:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = position[0] - text_width // 2
|
||||
y = position[1] - text_height // 2
|
||||
position = (x, y)
|
||||
|
||||
# Draw shadow
|
||||
shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
|
||||
draw.text(shadow_pos, text, fill=shadow_color, font=font)
|
||||
|
||||
# Draw main text
|
||||
draw.text(position, text, fill=text_color, font=font)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def draw_text_with_glow(
|
||||
frame: Image.Image,
|
||||
text: str,
|
||||
position: tuple[int, int],
|
||||
font_size: int = 40,
|
||||
text_color: tuple[int, int, int] = (255, 255, 255),
|
||||
glow_color: tuple[int, int, int] = (255, 200, 0),
|
||||
glow_radius: int = 5,
|
||||
centered: bool = False,
|
||||
bold: bool = True
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw text with glow effect for emphasis.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
text: Text to draw
|
||||
position: (x, y) position
|
||||
font_size: Font size in pixels
|
||||
text_color: RGB color for text
|
||||
glow_color: RGB color for glow
|
||||
glow_radius: Radius of glow effect
|
||||
centered: If True, center text at position
|
||||
bold: Use bold font variant
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
font = get_font(font_size, bold=bold)
|
||||
|
||||
# Calculate position for centering
|
||||
if centered:
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = position[0] - text_width // 2
|
||||
y = position[1] - text_height // 2
|
||||
position = (x, y)
|
||||
|
||||
# Draw glow layers with decreasing opacity (simulated with same color at different offsets)
|
||||
x, y = position
|
||||
for radius in range(glow_radius, 0, -1):
|
||||
for offset_x in range(-radius, radius + 1):
|
||||
for offset_y in range(-radius, radius + 1):
|
||||
if offset_x != 0 or offset_y != 0:
|
||||
draw.text((x + offset_x, y + offset_y), text, fill=glow_color, font=font)
|
||||
|
||||
# Draw main text
|
||||
draw.text(position, text, fill=text_color, font=font)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def draw_text_in_box(
|
||||
frame: Image.Image,
|
||||
text: str,
|
||||
position: tuple[int, int],
|
||||
font_size: int = 40,
|
||||
text_color: tuple[int, int, int] = (255, 255, 255),
|
||||
box_color: tuple[int, int, int] = (0, 0, 0),
|
||||
box_alpha: float = 0.7,
|
||||
padding: int = 10,
|
||||
centered: bool = True,
|
||||
bold: bool = True
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Draw text in a semi-transparent box for guaranteed readability.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
text: Text to draw
|
||||
position: (x, y) position
|
||||
font_size: Font size in pixels
|
||||
text_color: RGB color for text
|
||||
box_color: RGB color for background box
|
||||
box_alpha: Opacity of box (0.0-1.0)
|
||||
padding: Padding around text in pixels
|
||||
centered: If True, center at position
|
||||
bold: Use bold font variant
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
# Create a separate layer for the box with alpha
|
||||
overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
|
||||
draw_overlay = ImageDraw.Draw(overlay)
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
font = get_font(font_size, bold=bold)
|
||||
|
||||
# Get text dimensions
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# Calculate box position
|
||||
if centered:
|
||||
box_x = position[0] - (text_width + padding * 2) // 2
|
||||
box_y = position[1] - (text_height + padding * 2) // 2
|
||||
text_x = position[0] - text_width // 2
|
||||
text_y = position[1] - text_height // 2
|
||||
else:
|
||||
box_x = position[0] - padding
|
||||
box_y = position[1] - padding
|
||||
text_x = position[0]
|
||||
text_y = position[1]
|
||||
|
||||
# Draw semi-transparent box
|
||||
box_coords = [
|
||||
box_x,
|
||||
box_y,
|
||||
box_x + text_width + padding * 2,
|
||||
box_y + text_height + padding * 2
|
||||
]
|
||||
alpha_value = int(255 * box_alpha)
|
||||
draw_overlay.rectangle(box_coords, fill=(*box_color, alpha_value))
|
||||
|
||||
# Composite overlay onto frame
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, overlay)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
|
||||
# Draw text on top
|
||||
draw = ImageDraw.Draw(frame)
|
||||
draw.text((text_x, text_y), text, fill=text_color, font=font)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def get_text_size(text: str, font_size: int, bold: bool = True) -> tuple[int, int]:
|
||||
"""
|
||||
Get the dimensions of text without drawing it.
|
||||
|
||||
Args:
|
||||
text: Text to measure
|
||||
font_size: Font size in pixels
|
||||
bold: Use bold font variant
|
||||
|
||||
Returns:
|
||||
(width, height) tuple
|
||||
"""
|
||||
font = get_font(font_size, bold=bold)
|
||||
# Create temporary image to measure
|
||||
temp_img = Image.new('RGB', (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
return (width, height)
|
||||
|
||||
|
||||
def get_optimal_font_size(text: str, max_width: int, max_height: int,
|
||||
start_size: int = 60) -> int:
|
||||
"""
|
||||
Find the largest font size that fits within given dimensions.
|
||||
|
||||
Args:
|
||||
text: Text to size
|
||||
max_width: Maximum width in pixels
|
||||
max_height: Maximum height in pixels
|
||||
start_size: Starting font size to try
|
||||
|
||||
Returns:
|
||||
Optimal font size
|
||||
"""
|
||||
font_size = start_size
|
||||
while font_size > 10:
|
||||
width, height = get_text_size(text, font_size)
|
||||
if width <= max_width and height <= max_height:
|
||||
return font_size
|
||||
font_size -= 2
|
||||
return 10 # Minimum font size
|
||||
|
||||
|
||||
def scale_font_for_frame(base_size: int, frame_width: int, frame_height: int) -> int:
|
||||
"""
|
||||
Scale font size proportionally to frame dimensions.
|
||||
|
||||
Useful for maintaining relative text size across different GIF dimensions.
|
||||
|
||||
Args:
|
||||
base_size: Base font size for 480x480 frame
|
||||
frame_width: Actual frame width
|
||||
frame_height: Actual frame height
|
||||
|
||||
Returns:
|
||||
Scaled font size
|
||||
"""
|
||||
# Use average dimension for scaling
|
||||
avg_dimension = (frame_width + frame_height) / 2
|
||||
base_dimension = 480 # Reference dimension
|
||||
scale_factor = avg_dimension / base_dimension
|
||||
return max(10, int(base_size * scale_factor))
|
||||
136
skills/slack-gif-creator/core/validators.py
Executable file
136
skills/slack-gif-creator/core/validators.py
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validators - Check if GIFs meet Slack's requirements.
|
||||
|
||||
These validators help ensure your GIFs meet Slack's size and dimension constraints.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_gif(
|
||||
gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
|
||||
) -> tuple[bool, dict]:
|
||||
"""
|
||||
Validate GIF for Slack (dimensions, size, frame count).
|
||||
|
||||
Args:
|
||||
gif_path: Path to GIF file
|
||||
is_emoji: True for emoji (128x128 recommended), False for message GIF
|
||||
verbose: Print validation details
|
||||
|
||||
Returns:
|
||||
Tuple of (passes: bool, results: dict with all details)
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
gif_path = Path(gif_path)
|
||||
|
||||
if not gif_path.exists():
|
||||
return False, {"error": f"File not found: {gif_path}"}
|
||||
|
||||
# Get file size
|
||||
size_bytes = gif_path.stat().st_size
|
||||
size_kb = size_bytes / 1024
|
||||
size_mb = size_kb / 1024
|
||||
|
||||
# Get dimensions and frame info
|
||||
try:
|
||||
with Image.open(gif_path) as img:
|
||||
width, height = img.size
|
||||
|
||||
# Count frames
|
||||
frame_count = 0
|
||||
try:
|
||||
while True:
|
||||
img.seek(frame_count)
|
||||
frame_count += 1
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
# Get duration
|
||||
try:
|
||||
duration_ms = img.info.get("duration", 100)
|
||||
total_duration = (duration_ms * frame_count) / 1000
|
||||
fps = frame_count / total_duration if total_duration > 0 else 0
|
||||
except:
|
||||
total_duration = None
|
||||
fps = None
|
||||
|
||||
except Exception as e:
|
||||
return False, {"error": f"Failed to read GIF: {e}"}
|
||||
|
||||
# Validate dimensions
|
||||
if is_emoji:
|
||||
optimal = width == height == 128
|
||||
acceptable = width == height and 64 <= width <= 128
|
||||
dim_pass = acceptable
|
||||
else:
|
||||
aspect_ratio = (
|
||||
max(width, height) / min(width, height)
|
||||
if min(width, height) > 0
|
||||
else float("inf")
|
||||
)
|
||||
dim_pass = aspect_ratio <= 2.0 and 320 <= min(width, height) <= 640
|
||||
|
||||
results = {
|
||||
"file": str(gif_path),
|
||||
"passes": dim_pass,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"size_kb": size_kb,
|
||||
"size_mb": size_mb,
|
||||
"frame_count": frame_count,
|
||||
"duration_seconds": total_duration,
|
||||
"fps": fps,
|
||||
"is_emoji": is_emoji,
|
||||
"optimal": optimal if is_emoji else None,
|
||||
}
|
||||
|
||||
# Print if verbose
|
||||
if verbose:
|
||||
print(f"\nValidating {gif_path.name}:")
|
||||
print(
|
||||
f" Dimensions: {width}x{height}"
|
||||
+ (
|
||||
f" ({'optimal' if optimal else 'acceptable'})"
|
||||
if is_emoji and acceptable
|
||||
else ""
|
||||
)
|
||||
)
|
||||
print(
|
||||
f" Size: {size_kb:.1f} KB"
|
||||
+ (f" ({size_mb:.2f} MB)" if size_mb >= 1.0 else "")
|
||||
)
|
||||
print(
|
||||
f" Frames: {frame_count}"
|
||||
+ (f" @ {fps:.1f} fps ({total_duration:.1f}s)" if fps else "")
|
||||
)
|
||||
|
||||
if not dim_pass:
|
||||
print(
|
||||
f" Note: {'Emoji should be 128x128' if is_emoji else 'Unusual dimensions for Slack'}"
|
||||
)
|
||||
|
||||
if size_mb > 5.0:
|
||||
print(f" Note: Large file size - consider fewer frames/colors")
|
||||
|
||||
return dim_pass, results
|
||||
|
||||
|
||||
def is_slack_ready(
|
||||
gif_path: str | Path, is_emoji: bool = True, verbose: bool = True
|
||||
) -> bool:
|
||||
"""
|
||||
Quick check if GIF is ready for Slack.
|
||||
|
||||
Args:
|
||||
gif_path: Path to GIF file
|
||||
is_emoji: True for emoji GIF, False for message GIF
|
||||
verbose: Print feedback
|
||||
|
||||
Returns:
|
||||
True if dimensions are acceptable
|
||||
"""
|
||||
passes, _ = validate_gif(gif_path, is_emoji, verbose)
|
||||
return passes
|
||||
494
skills/slack-gif-creator/core/visual_effects.py
Executable file
494
skills/slack-gif-creator/core/visual_effects.py
Executable file
@@ -0,0 +1,494 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Visual Effects - Particles, motion blur, impacts, and other effects for GIFs.
|
||||
|
||||
This module provides high-impact visual effects that make animations feel
|
||||
professional and dynamic while keeping file sizes reasonable.
|
||||
"""
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
import numpy as np
|
||||
import math
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Particle:
|
||||
"""A single particle in a particle system."""
|
||||
|
||||
def __init__(self, x: float, y: float, vx: float, vy: float,
|
||||
lifetime: float, color: tuple[int, int, int],
|
||||
size: int = 3, shape: str = 'circle'):
|
||||
"""
|
||||
Initialize a particle.
|
||||
|
||||
Args:
|
||||
x, y: Starting position
|
||||
vx, vy: Velocity
|
||||
lifetime: How long particle lives (in frames)
|
||||
color: RGB color
|
||||
size: Particle size in pixels
|
||||
shape: 'circle', 'square', or 'star'
|
||||
"""
|
||||
self.x = x
|
||||
self.y = y
|
||||
self.vx = vx
|
||||
self.vy = vy
|
||||
self.lifetime = lifetime
|
||||
self.max_lifetime = lifetime
|
||||
self.color = color
|
||||
self.size = size
|
||||
self.shape = shape
|
||||
self.gravity = 0.5 # Pixels per frame squared
|
||||
self.drag = 0.98 # Velocity multiplier per frame
|
||||
|
||||
def update(self):
|
||||
"""Update particle position and lifetime."""
|
||||
# Apply physics
|
||||
self.vy += self.gravity
|
||||
self.vx *= self.drag
|
||||
self.vy *= self.drag
|
||||
|
||||
# Update position
|
||||
self.x += self.vx
|
||||
self.y += self.vy
|
||||
|
||||
# Decrease lifetime
|
||||
self.lifetime -= 1
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if particle is still alive."""
|
||||
return self.lifetime > 0
|
||||
|
||||
def get_alpha(self) -> float:
|
||||
"""Get particle opacity based on lifetime."""
|
||||
return max(0, min(1, self.lifetime / self.max_lifetime))
|
||||
|
||||
def render(self, frame: Image.Image):
|
||||
"""
|
||||
Render particle to frame.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
"""
|
||||
if not self.is_alive():
|
||||
return
|
||||
|
||||
draw = ImageDraw.Draw(frame)
|
||||
alpha = self.get_alpha()
|
||||
|
||||
# Calculate faded color
|
||||
color = tuple(int(c * alpha) for c in self.color)
|
||||
|
||||
# Draw based on shape
|
||||
x, y = int(self.x), int(self.y)
|
||||
size = max(1, int(self.size * alpha))
|
||||
|
||||
if self.shape == 'circle':
|
||||
bbox = [x - size, y - size, x + size, y + size]
|
||||
draw.ellipse(bbox, fill=color)
|
||||
elif self.shape == 'square':
|
||||
bbox = [x - size, y - size, x + size, y + size]
|
||||
draw.rectangle(bbox, fill=color)
|
||||
elif self.shape == 'star':
|
||||
# Simple 4-point star
|
||||
points = [
|
||||
(x, y - size),
|
||||
(x - size // 2, y),
|
||||
(x, y),
|
||||
(x, y + size),
|
||||
(x, y),
|
||||
(x + size // 2, y),
|
||||
]
|
||||
draw.line(points, fill=color, width=2)
|
||||
|
||||
|
||||
class ParticleSystem:
|
||||
"""Manages a collection of particles."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize particle system."""
|
||||
self.particles: list[Particle] = []
|
||||
|
||||
def emit(self, x: int, y: int, count: int = 10,
|
||||
spread: float = 2.0, speed: float = 5.0,
|
||||
color: tuple[int, int, int] = (255, 200, 0),
|
||||
lifetime: float = 20.0, size: int = 3, shape: str = 'circle'):
|
||||
"""
|
||||
Emit a burst of particles.
|
||||
|
||||
Args:
|
||||
x, y: Emission position
|
||||
count: Number of particles to emit
|
||||
spread: Angle spread (radians)
|
||||
speed: Initial speed
|
||||
color: Particle color
|
||||
lifetime: Particle lifetime in frames
|
||||
size: Particle size
|
||||
shape: Particle shape
|
||||
"""
|
||||
for _ in range(count):
|
||||
# Random angle and speed
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
vel_mag = random.uniform(speed * 0.5, speed * 1.5)
|
||||
vx = math.cos(angle) * vel_mag
|
||||
vy = math.sin(angle) * vel_mag
|
||||
|
||||
# Random lifetime variation
|
||||
life = random.uniform(lifetime * 0.7, lifetime * 1.3)
|
||||
|
||||
particle = Particle(x, y, vx, vy, life, color, size, shape)
|
||||
self.particles.append(particle)
|
||||
|
||||
def emit_confetti(self, x: int, y: int, count: int = 20,
|
||||
colors: Optional[list[tuple[int, int, int]]] = None):
|
||||
"""
|
||||
Emit confetti particles (colorful, falling).
|
||||
|
||||
Args:
|
||||
x, y: Emission position
|
||||
count: Number of confetti pieces
|
||||
colors: List of colors (random if None)
|
||||
"""
|
||||
if colors is None:
|
||||
colors = [
|
||||
(255, 107, 107), (255, 159, 64), (255, 218, 121),
|
||||
(107, 185, 240), (162, 155, 254), (255, 182, 193)
|
||||
]
|
||||
|
||||
for _ in range(count):
|
||||
color = random.choice(colors)
|
||||
vx = random.uniform(-3, 3)
|
||||
vy = random.uniform(-8, -2)
|
||||
shape = random.choice(['square', 'circle'])
|
||||
size = random.randint(2, 4)
|
||||
lifetime = random.uniform(40, 60)
|
||||
|
||||
particle = Particle(x, y, vx, vy, lifetime, color, size, shape)
|
||||
particle.gravity = 0.3 # Lighter gravity for confetti
|
||||
self.particles.append(particle)
|
||||
|
||||
def emit_sparkles(self, x: int, y: int, count: int = 15):
|
||||
"""
|
||||
Emit sparkle particles (twinkling stars).
|
||||
|
||||
Args:
|
||||
x, y: Emission position
|
||||
count: Number of sparkles
|
||||
"""
|
||||
colors = [(255, 255, 200), (255, 255, 255), (255, 255, 150)]
|
||||
|
||||
for _ in range(count):
|
||||
color = random.choice(colors)
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
speed = random.uniform(1, 3)
|
||||
vx = math.cos(angle) * speed
|
||||
vy = math.sin(angle) * speed
|
||||
lifetime = random.uniform(15, 30)
|
||||
|
||||
particle = Particle(x, y, vx, vy, lifetime, color, 2, 'star')
|
||||
particle.gravity = 0
|
||||
particle.drag = 0.95
|
||||
self.particles.append(particle)
|
||||
|
||||
def update(self):
|
||||
"""Update all particles."""
|
||||
# Update alive particles
|
||||
for particle in self.particles:
|
||||
particle.update()
|
||||
|
||||
# Remove dead particles
|
||||
self.particles = [p for p in self.particles if p.is_alive()]
|
||||
|
||||
def render(self, frame: Image.Image):
|
||||
"""Render all particles to frame."""
|
||||
for particle in self.particles:
|
||||
particle.render(frame)
|
||||
|
||||
def get_particle_count(self) -> int:
|
||||
"""Get number of active particles."""
|
||||
return len(self.particles)
|
||||
|
||||
|
||||
def add_motion_blur(frame: Image.Image, prev_frame: Optional[Image.Image],
|
||||
blur_amount: float = 0.5) -> Image.Image:
|
||||
"""
|
||||
Add motion blur by blending with previous frame.
|
||||
|
||||
Args:
|
||||
frame: Current frame
|
||||
prev_frame: Previous frame (None for first frame)
|
||||
blur_amount: Amount of blur (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Frame with motion blur applied
|
||||
"""
|
||||
if prev_frame is None:
|
||||
return frame
|
||||
|
||||
# Blend current frame with previous frame
|
||||
frame_array = np.array(frame, dtype=np.float32)
|
||||
prev_array = np.array(prev_frame, dtype=np.float32)
|
||||
|
||||
blended = frame_array * (1 - blur_amount) + prev_array * blur_amount
|
||||
blended = np.clip(blended, 0, 255).astype(np.uint8)
|
||||
|
||||
return Image.fromarray(blended)
|
||||
|
||||
|
||||
def create_impact_flash(frame: Image.Image, position: tuple[int, int],
|
||||
radius: int = 100, intensity: float = 0.7) -> Image.Image:
|
||||
"""
|
||||
Create a bright flash effect at impact point.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
position: Center of flash
|
||||
radius: Flash radius
|
||||
intensity: Flash intensity (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
# Create overlay
|
||||
overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
x, y = position
|
||||
|
||||
# Draw concentric circles with decreasing opacity
|
||||
num_circles = 5
|
||||
for i in range(num_circles):
|
||||
alpha = int(255 * intensity * (1 - i / num_circles))
|
||||
r = radius * (1 - i / num_circles)
|
||||
color = (255, 255, 240, alpha) # Warm white
|
||||
|
||||
bbox = [x - r, y - r, x + r, y + r]
|
||||
draw.ellipse(bbox, fill=color)
|
||||
|
||||
# Composite onto frame
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, overlay)
|
||||
return frame_rgba.convert('RGB')
|
||||
|
||||
|
||||
def create_shockwave_rings(frame: Image.Image, position: tuple[int, int],
|
||||
radii: list[int], color: tuple[int, int, int] = (255, 200, 0),
|
||||
width: int = 3) -> Image.Image:
|
||||
"""
|
||||
Create expanding ring effects.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
position: Center of rings
|
||||
radii: List of ring radii
|
||||
color: Ring color
|
||||
width: Ring width
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x, y = position
|
||||
|
||||
for radius in radii:
|
||||
bbox = [x - radius, y - radius, x + radius, y + radius]
|
||||
draw.ellipse(bbox, outline=color, width=width)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def create_explosion_effect(frame: Image.Image, position: tuple[int, int],
|
||||
radius: int, progress: float,
|
||||
color: tuple[int, int, int] = (255, 150, 0)) -> Image.Image:
|
||||
"""
|
||||
Create an explosion effect that expands and fades.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
position: Explosion center
|
||||
radius: Maximum radius
|
||||
progress: Animation progress (0.0-1.0)
|
||||
color: Explosion color
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
current_radius = int(radius * progress)
|
||||
fade = 1 - progress
|
||||
|
||||
# Create overlay
|
||||
overlay = Image.new('RGBA', frame.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
|
||||
x, y = position
|
||||
|
||||
# Draw expanding circle with fade
|
||||
alpha = int(255 * fade)
|
||||
r, g, b = color
|
||||
circle_color = (r, g, b, alpha)
|
||||
|
||||
bbox = [x - current_radius, y - current_radius, x + current_radius, y + current_radius]
|
||||
draw.ellipse(bbox, fill=circle_color)
|
||||
|
||||
# Composite
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, overlay)
|
||||
return frame_rgba.convert('RGB')
|
||||
|
||||
|
||||
def add_glow_effect(frame: Image.Image, mask_color: tuple[int, int, int],
|
||||
glow_color: tuple[int, int, int],
|
||||
blur_radius: int = 10) -> Image.Image:
|
||||
"""
|
||||
Add a glow effect to areas of a specific color.
|
||||
|
||||
Args:
|
||||
frame: PIL Image
|
||||
mask_color: Color to create glow around
|
||||
glow_color: Color of glow
|
||||
blur_radius: Blur amount
|
||||
|
||||
Returns:
|
||||
Frame with glow
|
||||
"""
|
||||
# Create mask of target color
|
||||
frame_array = np.array(frame)
|
||||
mask = np.all(frame_array == mask_color, axis=-1)
|
||||
|
||||
# Create glow layer
|
||||
glow = Image.new('RGB', frame.size, (0, 0, 0))
|
||||
glow_array = np.array(glow)
|
||||
glow_array[mask] = glow_color
|
||||
glow = Image.fromarray(glow_array)
|
||||
|
||||
# Blur the glow
|
||||
glow = glow.filter(ImageFilter.GaussianBlur(blur_radius))
|
||||
|
||||
# Blend with original
|
||||
blended = Image.blend(frame, glow, 0.5)
|
||||
return blended
|
||||
|
||||
|
||||
def add_drop_shadow(frame: Image.Image, object_bounds: tuple[int, int, int, int],
|
||||
shadow_offset: tuple[int, int] = (5, 5),
|
||||
shadow_color: tuple[int, int, int] = (0, 0, 0),
|
||||
blur: int = 5) -> Image.Image:
|
||||
"""
|
||||
Add drop shadow to an object.
|
||||
|
||||
Args:
|
||||
frame: PIL Image
|
||||
object_bounds: (x1, y1, x2, y2) bounds of object
|
||||
shadow_offset: (x, y) offset of shadow
|
||||
shadow_color: Shadow color
|
||||
blur: Shadow blur amount
|
||||
|
||||
Returns:
|
||||
Frame with shadow
|
||||
"""
|
||||
# Extract object
|
||||
x1, y1, x2, y2 = object_bounds
|
||||
obj = frame.crop((x1, y1, x2, y2))
|
||||
|
||||
# Create shadow
|
||||
shadow = Image.new('RGBA', obj.size, (*shadow_color, 180))
|
||||
|
||||
# Create frame with alpha
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
|
||||
# Paste shadow
|
||||
shadow_pos = (x1 + shadow_offset[0], y1 + shadow_offset[1])
|
||||
frame_rgba.paste(shadow, shadow_pos, shadow)
|
||||
|
||||
# Paste object on top
|
||||
frame_rgba.paste(obj, (x1, y1))
|
||||
|
||||
return frame_rgba.convert('RGB')
|
||||
|
||||
|
||||
def create_speed_lines(frame: Image.Image, position: tuple[int, int],
|
||||
direction: float, length: int = 50,
|
||||
count: int = 5, color: tuple[int, int, int] = (200, 200, 200)) -> Image.Image:
|
||||
"""
|
||||
Create speed lines for motion effect.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
position: Center position
|
||||
direction: Angle in radians (0 = right, pi/2 = down)
|
||||
length: Line length
|
||||
count: Number of lines
|
||||
color: Line color
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x, y = position
|
||||
|
||||
# Opposite direction (lines trail behind)
|
||||
trail_angle = direction + math.pi
|
||||
|
||||
for i in range(count):
|
||||
# Offset from center
|
||||
offset_angle = trail_angle + random.uniform(-0.3, 0.3)
|
||||
offset_dist = random.uniform(10, 30)
|
||||
start_x = x + math.cos(offset_angle) * offset_dist
|
||||
start_y = y + math.sin(offset_angle) * offset_dist
|
||||
|
||||
# End point
|
||||
line_length = random.uniform(length * 0.7, length * 1.3)
|
||||
end_x = start_x + math.cos(trail_angle) * line_length
|
||||
end_y = start_y + math.sin(trail_angle) * line_length
|
||||
|
||||
# Draw line with varying opacity
|
||||
alpha = random.randint(100, 200)
|
||||
width = random.randint(1, 3)
|
||||
|
||||
# Simple line (full opacity simulation)
|
||||
draw.line([(start_x, start_y), (end_x, end_y)], fill=color, width=width)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def create_screen_shake_offset(intensity: int, frame_index: int) -> tuple[int, int]:
|
||||
"""
|
||||
Calculate screen shake offset for a frame.
|
||||
|
||||
Args:
|
||||
intensity: Shake intensity in pixels
|
||||
frame_index: Current frame number
|
||||
|
||||
Returns:
|
||||
(x, y) offset tuple
|
||||
"""
|
||||
# Use frame index for deterministic but random-looking shake
|
||||
random.seed(frame_index)
|
||||
offset_x = random.randint(-intensity, intensity)
|
||||
offset_y = random.randint(-intensity, intensity)
|
||||
random.seed() # Reset seed
|
||||
return (offset_x, offset_y)
|
||||
|
||||
|
||||
def apply_screen_shake(frame: Image.Image, intensity: int, frame_index: int) -> Image.Image:
|
||||
"""
|
||||
Apply screen shake effect to entire frame.
|
||||
|
||||
Args:
|
||||
frame: PIL Image
|
||||
intensity: Shake intensity
|
||||
frame_index: Current frame number
|
||||
|
||||
Returns:
|
||||
Shaken frame
|
||||
"""
|
||||
offset_x, offset_y = create_screen_shake_offset(intensity, frame_index)
|
||||
|
||||
# Create new frame with background
|
||||
shaken = Image.new('RGB', frame.size, (0, 0, 0))
|
||||
|
||||
# Paste original frame with offset
|
||||
shaken.paste(frame, (offset_x, offset_y))
|
||||
|
||||
return shaken
|
||||
4
skills/slack-gif-creator/requirements.txt
Normal file
4
skills/slack-gif-creator/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
pillow>=10.0.0
|
||||
imageio>=2.31.0
|
||||
imageio-ffmpeg>=0.4.9
|
||||
numpy>=1.24.0
|
||||
106
skills/slack-gif-creator/templates/bounce.py
Executable file
106
skills/slack-gif-creator/templates/bounce.py
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Bounce Animation Template - Creates bouncing motion for objects.
|
||||
|
||||
Use this to make objects bounce up and down or horizontally with realistic physics.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_circle, draw_emoji
|
||||
from core.easing import ease_out_bounce, interpolate
|
||||
|
||||
|
||||
def create_bounce_animation(
|
||||
object_type: str = 'circle',
|
||||
object_data: dict = None,
|
||||
num_frames: int = 30,
|
||||
bounce_height: int = 150,
|
||||
ground_y: int = 350,
|
||||
start_x: int = 240,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list:
|
||||
"""
|
||||
Create frames for a bouncing animation.
|
||||
|
||||
Args:
|
||||
object_type: 'circle', 'emoji', or 'custom'
|
||||
object_data: Data for the object (e.g., {'radius': 30, 'color': (255, 0, 0)})
|
||||
num_frames: Number of frames in the animation
|
||||
bounce_height: Maximum height of bounce
|
||||
ground_y: Y position of ground
|
||||
start_x: X position (or starting X if moving horizontally)
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'circle':
|
||||
object_data = {'radius': 30, 'color': (255, 100, 100)}
|
||||
elif object_type == 'emoji':
|
||||
object_data = {'emoji': '⚽', 'size': 60}
|
||||
|
||||
for i in range(num_frames):
|
||||
# Create blank frame
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
# Calculate progress (0.0 to 1.0)
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Calculate Y position using bounce easing
|
||||
y = ground_y - int(ease_out_bounce(t) * bounce_height)
|
||||
|
||||
# Draw object
|
||||
if object_type == 'circle':
|
||||
draw_circle(
|
||||
frame,
|
||||
center=(start_x, y),
|
||||
radius=object_data['radius'],
|
||||
fill_color=object_data['color']
|
||||
)
|
||||
elif object_type == 'emoji':
|
||||
draw_emoji(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(start_x - object_data['size'] // 2, y - object_data['size'] // 2),
|
||||
size=object_data['size']
|
||||
)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating bouncing ball GIF...")
|
||||
|
||||
# Create GIF builder
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Generate bounce animation
|
||||
frames = create_bounce_animation(
|
||||
object_type='circle',
|
||||
object_data={'radius': 40, 'color': (255, 100, 100)},
|
||||
num_frames=40,
|
||||
bounce_height=200
|
||||
)
|
||||
|
||||
# Add frames to builder
|
||||
builder.add_frames(frames)
|
||||
|
||||
# Save GIF
|
||||
builder.save('bounce_test.gif', num_colors=64)
|
||||
331
skills/slack-gif-creator/templates/explode.py
Executable file
331
skills/slack-gif-creator/templates/explode.py
Executable file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Explode Animation - Break objects into pieces that fly outward.
|
||||
|
||||
Creates explosion, shatter, and particle burst effects.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
import random
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced
|
||||
from core.visual_effects import ParticleSystem
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_explode_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
explode_type: str = 'burst', # 'burst', 'shatter', 'dissolve', 'implode'
|
||||
num_pieces: int = 20,
|
||||
explosion_speed: float = 5.0,
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create explosion animation.
|
||||
|
||||
Args:
|
||||
object_type: 'emoji', 'circle', 'text'
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
explode_type: Type of explosion
|
||||
num_pieces: Number of pieces/particles
|
||||
explosion_speed: Speed of explosion
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '💣', 'size': 100}
|
||||
|
||||
# Generate pieces/particles
|
||||
pieces = []
|
||||
for _ in range(num_pieces):
|
||||
angle = random.uniform(0, 2 * math.pi)
|
||||
speed = random.uniform(explosion_speed * 0.5, explosion_speed * 1.5)
|
||||
vx = math.cos(angle) * speed
|
||||
vy = math.sin(angle) * speed
|
||||
size = random.randint(3, 12)
|
||||
color = (
|
||||
random.randint(100, 255),
|
||||
random.randint(100, 255),
|
||||
random.randint(100, 255)
|
||||
)
|
||||
rotation_speed = random.uniform(-20, 20)
|
||||
|
||||
pieces.append({
|
||||
'vx': vx,
|
||||
'vy': vy,
|
||||
'size': size,
|
||||
'color': color,
|
||||
'rotation': 0,
|
||||
'rotation_speed': rotation_speed
|
||||
})
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
if explode_type == 'burst':
|
||||
# Show object at start, then explode
|
||||
if t < 0.2:
|
||||
# Object still intact
|
||||
scale = interpolate(1.0, 1.2, t / 0.2, 'ease_out')
|
||||
if object_type == 'emoji':
|
||||
size = int(object_data['size'] * scale)
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(center_pos[0] - size // 2, center_pos[1] - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
else:
|
||||
# Exploded - draw pieces
|
||||
explosion_t = (t - 0.2) / 0.8
|
||||
for piece in pieces:
|
||||
# Update position
|
||||
x = center_pos[0] + piece['vx'] * explosion_t * 50
|
||||
y = center_pos[1] + piece['vy'] * explosion_t * 50 + 0.5 * 300 * explosion_t ** 2 # Gravity
|
||||
|
||||
# Fade out
|
||||
alpha = 1.0 - explosion_t
|
||||
if alpha > 0:
|
||||
color = tuple(int(c * alpha) for c in piece['color'])
|
||||
size = int(piece['size'] * (1 - explosion_t * 0.5))
|
||||
|
||||
draw.ellipse(
|
||||
[x - size, y - size, x + size, y + size],
|
||||
fill=color
|
||||
)
|
||||
|
||||
elif explode_type == 'shatter':
|
||||
# Break into geometric pieces
|
||||
if t < 0.15:
|
||||
# Object intact
|
||||
if object_type == 'emoji':
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(center_pos[0] - object_data['size'] // 2,
|
||||
center_pos[1] - object_data['size'] // 2),
|
||||
size=object_data['size'],
|
||||
shadow=False
|
||||
)
|
||||
else:
|
||||
# Shattered
|
||||
shatter_t = (t - 0.15) / 0.85
|
||||
|
||||
# Draw triangular shards
|
||||
for piece in pieces[:min(10, len(pieces))]:
|
||||
x = center_pos[0] + piece['vx'] * shatter_t * 30
|
||||
y = center_pos[1] + piece['vy'] * shatter_t * 30 + 0.5 * 200 * shatter_t ** 2
|
||||
|
||||
# Update rotation
|
||||
rotation = piece['rotation_speed'] * shatter_t * 100
|
||||
|
||||
# Draw triangle shard
|
||||
shard_size = piece['size'] * 2
|
||||
points = []
|
||||
for j in range(3):
|
||||
angle = (rotation + j * 120) * math.pi / 180
|
||||
px = x + shard_size * math.cos(angle)
|
||||
py = y + shard_size * math.sin(angle)
|
||||
points.append((px, py))
|
||||
|
||||
alpha = 1.0 - shatter_t
|
||||
if alpha > 0:
|
||||
color = tuple(int(c * alpha) for c in piece['color'])
|
||||
draw.polygon(points, fill=color)
|
||||
|
||||
elif explode_type == 'dissolve':
|
||||
# Dissolve into particles
|
||||
dissolve_scale = interpolate(1.0, 0.0, t, 'ease_in')
|
||||
|
||||
if dissolve_scale > 0.1:
|
||||
# Draw fading object
|
||||
if object_type == 'emoji':
|
||||
size = int(object_data['size'] * dissolve_scale)
|
||||
size = max(12, size)
|
||||
|
||||
emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=object_data['emoji'],
|
||||
position=(center_pos[0] - size // 2, center_pos[1] - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Apply opacity
|
||||
from templates.fade import apply_opacity
|
||||
emoji_canvas = apply_opacity(emoji_canvas, dissolve_scale)
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_rgba, emoji_canvas)
|
||||
frame = frame.convert('RGB')
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Draw outward-moving particles
|
||||
for piece in pieces:
|
||||
x = center_pos[0] + piece['vx'] * t * 40
|
||||
y = center_pos[1] + piece['vy'] * t * 40
|
||||
|
||||
alpha = 1.0 - t
|
||||
if alpha > 0:
|
||||
color = tuple(int(c * alpha) for c in piece['color'])
|
||||
size = int(piece['size'] * (1 - t * 0.5))
|
||||
draw.ellipse(
|
||||
[x - size, y - size, x + size, y + size],
|
||||
fill=color
|
||||
)
|
||||
|
||||
elif explode_type == 'implode':
|
||||
# Reverse explosion - pieces fly inward
|
||||
if t < 0.7:
|
||||
# Pieces converging
|
||||
implode_t = 1.0 - (t / 0.7)
|
||||
for piece in pieces:
|
||||
x = center_pos[0] + piece['vx'] * implode_t * 50
|
||||
y = center_pos[1] + piece['vy'] * implode_t * 50
|
||||
|
||||
alpha = 1.0 - (1.0 - implode_t) * 0.5
|
||||
color = tuple(int(c * alpha) for c in piece['color'])
|
||||
size = int(piece['size'] * alpha)
|
||||
|
||||
draw.ellipse(
|
||||
[x - size, y - size, x + size, y + size],
|
||||
fill=color
|
||||
)
|
||||
else:
|
||||
# Object reforms
|
||||
reform_t = (t - 0.7) / 0.3
|
||||
scale = interpolate(0.5, 1.0, reform_t, 'elastic_out')
|
||||
|
||||
if object_type == 'emoji':
|
||||
size = int(object_data['size'] * scale)
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(center_pos[0] - size // 2, center_pos[1] - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_particle_burst(
|
||||
num_frames: int = 25,
|
||||
particle_count: int = 30,
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
colors: list[tuple[int, int, int]] | None = None,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create simple particle burst effect.
|
||||
|
||||
Args:
|
||||
num_frames: Number of frames
|
||||
particle_count: Number of particles
|
||||
center_pos: Burst center
|
||||
colors: Particle colors (None for random)
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
particles = ParticleSystem()
|
||||
|
||||
# Emit particles
|
||||
if colors is None:
|
||||
from core.color_palettes import get_palette
|
||||
palette = get_palette('vibrant')
|
||||
colors = [palette['primary'], palette['secondary'], palette['accent']]
|
||||
|
||||
for _ in range(particle_count):
|
||||
color = random.choice(colors)
|
||||
particles.emit(
|
||||
center_pos[0], center_pos[1],
|
||||
count=1,
|
||||
speed=random.uniform(3, 8),
|
||||
color=color,
|
||||
lifetime=random.uniform(20, 30),
|
||||
size=random.randint(3, 8),
|
||||
shape='star'
|
||||
)
|
||||
|
||||
frames = []
|
||||
for _ in range(num_frames):
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
particles.update()
|
||||
particles.render(frame)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating explode animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Burst
|
||||
frames = create_explode_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '💣', 'size': 100},
|
||||
num_frames=30,
|
||||
explode_type='burst',
|
||||
num_pieces=25
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('explode_burst.gif', num_colors=128)
|
||||
|
||||
# Example 2: Shatter
|
||||
builder.clear()
|
||||
frames = create_explode_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🪟', 'size': 100},
|
||||
num_frames=30,
|
||||
explode_type='shatter',
|
||||
num_pieces=12
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('explode_shatter.gif', num_colors=128)
|
||||
|
||||
# Example 3: Particle burst
|
||||
builder.clear()
|
||||
frames = create_particle_burst(num_frames=25, particle_count=40)
|
||||
builder.add_frames(frames)
|
||||
builder.save('explode_particles.gif', num_colors=128)
|
||||
|
||||
print("Created explode animations!")
|
||||
329
skills/slack-gif-creator/templates/fade.py
Executable file
329
skills/slack-gif-creator/templates/fade.py
Executable file
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fade Animation - Fade in, fade out, and crossfade effects.
|
||||
|
||||
Creates smooth opacity transitions for appearing, disappearing, and transitioning.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_fade_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
fade_type: str = 'in', # 'in', 'out', 'in_out', 'blink'
|
||||
easing: str = 'ease_in_out',
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create fade animation.
|
||||
|
||||
Args:
|
||||
object_type: 'emoji', 'text', 'image'
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
fade_type: Type of fade effect
|
||||
easing: Easing function
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '✨', 'size': 100}
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Calculate opacity based on fade type
|
||||
if fade_type == 'in':
|
||||
opacity = interpolate(0, 1, t, easing)
|
||||
elif fade_type == 'out':
|
||||
opacity = interpolate(1, 0, t, easing)
|
||||
elif fade_type == 'in_out':
|
||||
if t < 0.5:
|
||||
opacity = interpolate(0, 1, t * 2, easing)
|
||||
else:
|
||||
opacity = interpolate(1, 0, (t - 0.5) * 2, easing)
|
||||
elif fade_type == 'blink':
|
||||
# Quick fade out and back in
|
||||
if t < 0.2:
|
||||
opacity = interpolate(1, 0, t / 0.2, 'ease_in')
|
||||
elif t < 0.4:
|
||||
opacity = interpolate(0, 1, (t - 0.2) / 0.2, 'ease_out')
|
||||
else:
|
||||
opacity = 1.0
|
||||
else:
|
||||
opacity = interpolate(0, 1, t, easing)
|
||||
|
||||
# Create background
|
||||
frame_bg = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
# Create object layer with transparency
|
||||
if object_type == 'emoji':
|
||||
# Create RGBA canvas for emoji
|
||||
emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
emoji_size = object_data['size']
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=object_data['emoji'],
|
||||
position=(center_pos[0] - emoji_size // 2, center_pos[1] - emoji_size // 2),
|
||||
size=emoji_size,
|
||||
shadow=object_data.get('shadow', False)
|
||||
)
|
||||
|
||||
# Apply opacity
|
||||
emoji_canvas = apply_opacity(emoji_canvas, opacity)
|
||||
|
||||
# Composite onto background
|
||||
frame_bg_rgba = frame_bg.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_bg_rgba, emoji_canvas)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
elif object_type == 'text':
|
||||
from core.typography import draw_text_with_outline
|
||||
|
||||
# Create text on separate layer
|
||||
text_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
text_canvas_rgb = text_canvas.convert('RGB')
|
||||
text_canvas_rgb.paste(bg_color, (0, 0, frame_width, frame_height))
|
||||
|
||||
draw_text_with_outline(
|
||||
text_canvas_rgb,
|
||||
text=object_data.get('text', 'FADE'),
|
||||
position=center_pos,
|
||||
font_size=object_data.get('font_size', 60),
|
||||
text_color=object_data.get('text_color', (0, 0, 0)),
|
||||
outline_color=object_data.get('outline_color', (255, 255, 255)),
|
||||
outline_width=3,
|
||||
centered=True
|
||||
)
|
||||
|
||||
# Convert to RGBA and make background transparent
|
||||
text_canvas = text_canvas_rgb.convert('RGBA')
|
||||
data = text_canvas.getdata()
|
||||
new_data = []
|
||||
for item in data:
|
||||
if item[:3] == bg_color:
|
||||
new_data.append((255, 255, 255, 0))
|
||||
else:
|
||||
new_data.append(item)
|
||||
text_canvas.putdata(new_data)
|
||||
|
||||
# Apply opacity
|
||||
text_canvas = apply_opacity(text_canvas, opacity)
|
||||
|
||||
# Composite
|
||||
frame_bg_rgba = frame_bg.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_bg_rgba, text_canvas)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
else:
|
||||
frame = frame_bg
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def apply_opacity(image: Image.Image, opacity: float) -> Image.Image:
|
||||
"""
|
||||
Apply opacity to an RGBA image.
|
||||
|
||||
Args:
|
||||
image: RGBA image
|
||||
opacity: Opacity value (0.0 to 1.0)
|
||||
|
||||
Returns:
|
||||
Image with adjusted opacity
|
||||
"""
|
||||
if image.mode != 'RGBA':
|
||||
image = image.convert('RGBA')
|
||||
|
||||
# Get alpha channel
|
||||
r, g, b, a = image.split()
|
||||
|
||||
# Multiply alpha by opacity
|
||||
a_array = np.array(a, dtype=np.float32)
|
||||
a_array = a_array * opacity
|
||||
a = Image.fromarray(a_array.astype(np.uint8))
|
||||
|
||||
# Merge back
|
||||
return Image.merge('RGBA', (r, g, b, a))
|
||||
|
||||
|
||||
def create_crossfade(
|
||||
object1_data: dict,
|
||||
object2_data: dict,
|
||||
num_frames: int = 30,
|
||||
easing: str = 'ease_in_out',
|
||||
object_type: str = 'emoji',
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Crossfade between two objects.
|
||||
|
||||
Args:
|
||||
object1_data: First object configuration
|
||||
object2_data: Second object configuration
|
||||
num_frames: Number of frames
|
||||
easing: Easing function
|
||||
object_type: Type of objects
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Calculate opacities
|
||||
opacity1 = interpolate(1, 0, t, easing)
|
||||
opacity2 = interpolate(0, 1, t, easing)
|
||||
|
||||
# Create background
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
if object_type == 'emoji':
|
||||
# Create first emoji
|
||||
emoji1_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
size1 = object1_data['size']
|
||||
draw_emoji_enhanced(
|
||||
emoji1_canvas,
|
||||
emoji=object1_data['emoji'],
|
||||
position=(center_pos[0] - size1 // 2, center_pos[1] - size1 // 2),
|
||||
size=size1,
|
||||
shadow=False
|
||||
)
|
||||
emoji1_canvas = apply_opacity(emoji1_canvas, opacity1)
|
||||
|
||||
# Create second emoji
|
||||
emoji2_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
size2 = object2_data['size']
|
||||
draw_emoji_enhanced(
|
||||
emoji2_canvas,
|
||||
emoji=object2_data['emoji'],
|
||||
position=(center_pos[0] - size2 // 2, center_pos[1] - size2 // 2),
|
||||
size=size2,
|
||||
shadow=False
|
||||
)
|
||||
emoji2_canvas = apply_opacity(emoji2_canvas, opacity2)
|
||||
|
||||
# Composite both
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, emoji1_canvas)
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, emoji2_canvas)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_fade_to_color(
|
||||
start_color: tuple[int, int, int],
|
||||
end_color: tuple[int, int, int],
|
||||
num_frames: int = 20,
|
||||
easing: str = 'linear',
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Fade from one solid color to another.
|
||||
|
||||
Args:
|
||||
start_color: Starting RGB color
|
||||
end_color: Ending RGB color
|
||||
num_frames: Number of frames
|
||||
easing: Easing function
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Interpolate each color channel
|
||||
r = int(interpolate(start_color[0], end_color[0], t, easing))
|
||||
g = int(interpolate(start_color[1], end_color[1], t, easing))
|
||||
b = int(interpolate(start_color[2], end_color[2], t, easing))
|
||||
|
||||
color = (r, g, b)
|
||||
frame = create_blank_frame(frame_width, frame_height, color)
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating fade animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Fade in
|
||||
frames = create_fade_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '✨', 'size': 120},
|
||||
num_frames=30,
|
||||
fade_type='in',
|
||||
easing='ease_out'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('fade_in.gif', num_colors=128)
|
||||
|
||||
# Example 2: Crossfade
|
||||
builder.clear()
|
||||
frames = create_crossfade(
|
||||
object1_data={'emoji': '😊', 'size': 100},
|
||||
object2_data={'emoji': '😂', 'size': 100},
|
||||
num_frames=30,
|
||||
object_type='emoji'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('fade_crossfade.gif', num_colors=128)
|
||||
|
||||
# Example 3: Blink
|
||||
builder.clear()
|
||||
frames = create_fade_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '👀', 'size': 100},
|
||||
num_frames=20,
|
||||
fade_type='blink'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('fade_blink.gif', num_colors=128)
|
||||
|
||||
print("Created fade animations!")
|
||||
291
skills/slack-gif-creator/templates/flip.py
Executable file
291
skills/slack-gif-creator/templates/flip.py
Executable file
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Flip Animation - 3D-style card flip and rotation effects.
|
||||
|
||||
Creates horizontal and vertical flips with perspective.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_flip_animation(
|
||||
object1_data: dict,
|
||||
object2_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
flip_axis: str = 'horizontal', # 'horizontal', 'vertical'
|
||||
easing: str = 'ease_in_out',
|
||||
object_type: str = 'emoji',
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create 3D-style flip animation.
|
||||
|
||||
Args:
|
||||
object1_data: First object (front side)
|
||||
object2_data: Second object (back side, None = same as front)
|
||||
num_frames: Number of frames
|
||||
flip_axis: Axis to flip around
|
||||
easing: Easing function
|
||||
object_type: Type of objects
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
if object2_data is None:
|
||||
object2_data = object1_data
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
# Calculate rotation angle (0 to 180 degrees)
|
||||
angle = interpolate(0, 180, t, easing)
|
||||
|
||||
# Determine which side is visible and calculate scale
|
||||
if angle < 90:
|
||||
# Front side visible
|
||||
current_object = object1_data
|
||||
scale_factor = math.cos(math.radians(angle))
|
||||
else:
|
||||
# Back side visible
|
||||
current_object = object2_data
|
||||
scale_factor = abs(math.cos(math.radians(angle)))
|
||||
|
||||
# Don't draw when edge-on (very thin)
|
||||
if scale_factor < 0.05:
|
||||
frames.append(frame)
|
||||
continue
|
||||
|
||||
if object_type == 'emoji':
|
||||
size = current_object['size']
|
||||
|
||||
# Create emoji on canvas
|
||||
canvas_size = size * 2
|
||||
emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=current_object['emoji'],
|
||||
position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Apply flip scaling
|
||||
if flip_axis == 'horizontal':
|
||||
# Scale horizontally for horizontal flip
|
||||
new_width = max(1, int(canvas_size * scale_factor))
|
||||
new_height = canvas_size
|
||||
else:
|
||||
# Scale vertically for vertical flip
|
||||
new_width = canvas_size
|
||||
new_height = max(1, int(canvas_size * scale_factor))
|
||||
|
||||
# Resize to simulate 3D rotation
|
||||
emoji_scaled = emoji_canvas.resize((new_width, new_height), Image.LANCZOS)
|
||||
|
||||
# Position centered
|
||||
paste_x = center_pos[0] - new_width // 2
|
||||
paste_y = center_pos[1] - new_height // 2
|
||||
|
||||
# Composite onto frame
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba.paste(emoji_scaled, (paste_x, paste_y), emoji_scaled)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
|
||||
elif object_type == 'text':
|
||||
from core.typography import draw_text_with_outline
|
||||
|
||||
# Create text on canvas
|
||||
text = current_object.get('text', 'FLIP')
|
||||
font_size = current_object.get('font_size', 50)
|
||||
|
||||
canvas_size = max(frame_width, frame_height)
|
||||
text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
# Draw on RGB for text rendering
|
||||
text_canvas_rgb = text_canvas.convert('RGB')
|
||||
text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
|
||||
|
||||
draw_text_with_outline(
|
||||
text_canvas_rgb,
|
||||
text=text,
|
||||
position=(canvas_size // 2, canvas_size // 2),
|
||||
font_size=font_size,
|
||||
text_color=current_object.get('text_color', (0, 0, 0)),
|
||||
outline_color=current_object.get('outline_color', (255, 255, 255)),
|
||||
outline_width=3,
|
||||
centered=True
|
||||
)
|
||||
|
||||
# Make background transparent
|
||||
text_canvas = text_canvas_rgb.convert('RGBA')
|
||||
data = text_canvas.getdata()
|
||||
new_data = []
|
||||
for item in data:
|
||||
if item[:3] == bg_color:
|
||||
new_data.append((255, 255, 255, 0))
|
||||
else:
|
||||
new_data.append(item)
|
||||
text_canvas.putdata(new_data)
|
||||
|
||||
# Apply flip scaling
|
||||
if flip_axis == 'horizontal':
|
||||
new_width = max(1, int(canvas_size * scale_factor))
|
||||
new_height = canvas_size
|
||||
else:
|
||||
new_width = canvas_size
|
||||
new_height = max(1, int(canvas_size * scale_factor))
|
||||
|
||||
text_scaled = text_canvas.resize((new_width, new_height), Image.LANCZOS)
|
||||
|
||||
# Center and crop
|
||||
if flip_axis == 'horizontal':
|
||||
left = (new_width - frame_width) // 2 if new_width > frame_width else 0
|
||||
top = (canvas_size - frame_height) // 2
|
||||
paste_x = center_pos[0] - min(new_width, frame_width) // 2
|
||||
paste_y = 0
|
||||
|
||||
text_cropped = text_scaled.crop((
|
||||
left,
|
||||
top,
|
||||
left + min(new_width, frame_width),
|
||||
top + frame_height
|
||||
))
|
||||
else:
|
||||
left = (canvas_size - frame_width) // 2
|
||||
top = (new_height - frame_height) // 2 if new_height > frame_height else 0
|
||||
paste_x = 0
|
||||
paste_y = center_pos[1] - min(new_height, frame_height) // 2
|
||||
|
||||
text_cropped = text_scaled.crop((
|
||||
left,
|
||||
top,
|
||||
left + frame_width,
|
||||
top + min(new_height, frame_height)
|
||||
))
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba.paste(text_cropped, (paste_x, paste_y), text_cropped)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_quick_flip(
|
||||
emoji_front: str,
|
||||
emoji_back: str,
|
||||
num_frames: int = 20,
|
||||
frame_size: int = 128
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create quick flip for emoji GIFs.
|
||||
|
||||
Args:
|
||||
emoji_front: Front emoji
|
||||
emoji_back: Back emoji
|
||||
num_frames: Number of frames
|
||||
frame_size: Frame size (square)
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
return create_flip_animation(
|
||||
object1_data={'emoji': emoji_front, 'size': 80},
|
||||
object2_data={'emoji': emoji_back, 'size': 80},
|
||||
num_frames=num_frames,
|
||||
flip_axis='horizontal',
|
||||
easing='ease_in_out',
|
||||
object_type='emoji',
|
||||
center_pos=(frame_size // 2, frame_size // 2),
|
||||
frame_width=frame_size,
|
||||
frame_height=frame_size,
|
||||
bg_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
|
||||
def create_nope_flip(
|
||||
num_frames: int = 25,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create "nope" reaction flip (like flipping table).
|
||||
|
||||
Args:
|
||||
num_frames: Number of frames
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
return create_flip_animation(
|
||||
object1_data={'text': 'NOPE', 'font_size': 80, 'text_color': (255, 50, 50)},
|
||||
object2_data={'text': 'NOPE', 'font_size': 80, 'text_color': (255, 50, 50)},
|
||||
num_frames=num_frames,
|
||||
flip_axis='horizontal',
|
||||
easing='ease_out',
|
||||
object_type='text',
|
||||
frame_width=frame_width,
|
||||
frame_height=frame_height,
|
||||
bg_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating flip animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Emoji flip
|
||||
frames = create_flip_animation(
|
||||
object1_data={'emoji': '😊', 'size': 120},
|
||||
object2_data={'emoji': '😂', 'size': 120},
|
||||
num_frames=30,
|
||||
flip_axis='horizontal',
|
||||
object_type='emoji'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('flip_emoji.gif', num_colors=128)
|
||||
|
||||
# Example 2: Text flip
|
||||
builder.clear()
|
||||
frames = create_flip_animation(
|
||||
object1_data={'text': 'YES', 'font_size': 80, 'text_color': (100, 200, 100)},
|
||||
object2_data={'text': 'NO', 'font_size': 80, 'text_color': (200, 100, 100)},
|
||||
num_frames=30,
|
||||
flip_axis='vertical',
|
||||
object_type='text'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('flip_text.gif', num_colors=128)
|
||||
|
||||
# Example 3: Quick flip (emoji size)
|
||||
builder = GIFBuilder(width=128, height=128, fps=15)
|
||||
frames = create_quick_flip('👍', '👎', num_frames=20)
|
||||
builder.add_frames(frames)
|
||||
builder.save('flip_quick.gif', num_colors=48, optimize_for_emoji=True)
|
||||
|
||||
print("Created flip animations!")
|
||||
211
skills/slack-gif-creator/templates/kaleidoscope.py
Executable file
211
skills/slack-gif-creator/templates/kaleidoscope.py
Executable file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Kaleidoscope Effect - Create mirror/rotation effects.
|
||||
|
||||
Apply kaleidoscope effects to frames or objects for psychedelic visuals.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image, ImageOps, ImageDraw
|
||||
import numpy as np
|
||||
|
||||
|
||||
def apply_kaleidoscope(frame: Image.Image, segments: int = 8,
|
||||
center: tuple[int, int] | None = None) -> Image.Image:
|
||||
"""
|
||||
Apply kaleidoscope effect by mirroring/rotating frame sections.
|
||||
|
||||
Args:
|
||||
frame: Input frame
|
||||
segments: Number of mirror segments (4, 6, 8, 12 work well)
|
||||
center: Center point for effect (None = frame center)
|
||||
|
||||
Returns:
|
||||
Frame with kaleidoscope effect
|
||||
"""
|
||||
width, height = frame.size
|
||||
|
||||
if center is None:
|
||||
center = (width // 2, height // 2)
|
||||
|
||||
# Create output frame
|
||||
output = Image.new('RGB', (width, height))
|
||||
|
||||
# Calculate angle per segment
|
||||
angle_per_segment = 360 / segments
|
||||
|
||||
# For simplicity, we'll create a radial mirror effect
|
||||
# A full implementation would rotate and mirror properly
|
||||
# This is a simplified version that creates interesting patterns
|
||||
|
||||
# Convert to numpy for easier manipulation
|
||||
frame_array = np.array(frame)
|
||||
output_array = np.zeros_like(frame_array)
|
||||
|
||||
center_x, center_y = center
|
||||
|
||||
# Create wedge mask and mirror it
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
# Calculate angle from center
|
||||
dx = x - center_x
|
||||
dy = y - center_y
|
||||
|
||||
angle = (math.degrees(math.atan2(dy, dx)) + 180) % 360
|
||||
distance = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
# Which segment does this pixel belong to?
|
||||
segment = int(angle / angle_per_segment)
|
||||
|
||||
# Mirror angle within segment
|
||||
segment_angle = angle % angle_per_segment
|
||||
if segment % 2 == 1: # Mirror every other segment
|
||||
segment_angle = angle_per_segment - segment_angle
|
||||
|
||||
# Calculate source position
|
||||
source_angle = segment_angle + (segment // 2) * angle_per_segment * 2
|
||||
source_angle_rad = math.radians(source_angle - 180)
|
||||
|
||||
source_x = int(center_x + distance * math.cos(source_angle_rad))
|
||||
source_y = int(center_y + distance * math.sin(source_angle_rad))
|
||||
|
||||
# Bounds check
|
||||
if 0 <= source_x < width and 0 <= source_y < height:
|
||||
output_array[y, x] = frame_array[source_y, source_x]
|
||||
else:
|
||||
output_array[y, x] = frame_array[y, x]
|
||||
|
||||
return Image.fromarray(output_array)
|
||||
|
||||
|
||||
def apply_simple_mirror(frame: Image.Image, mode: str = 'quad') -> Image.Image:
|
||||
"""
|
||||
Apply simple mirror effect (faster than full kaleidoscope).
|
||||
|
||||
Args:
|
||||
frame: Input frame
|
||||
mode: 'horizontal', 'vertical', 'quad' (4-way), 'radial'
|
||||
|
||||
Returns:
|
||||
Mirrored frame
|
||||
"""
|
||||
width, height = frame.size
|
||||
center_x, center_y = width // 2, height // 2
|
||||
|
||||
if mode == 'horizontal':
|
||||
# Mirror left half to right
|
||||
left_half = frame.crop((0, 0, center_x, height))
|
||||
left_flipped = ImageOps.mirror(left_half)
|
||||
result = frame.copy()
|
||||
result.paste(left_flipped, (center_x, 0))
|
||||
return result
|
||||
|
||||
elif mode == 'vertical':
|
||||
# Mirror top half to bottom
|
||||
top_half = frame.crop((0, 0, width, center_y))
|
||||
top_flipped = ImageOps.flip(top_half)
|
||||
result = frame.copy()
|
||||
result.paste(top_flipped, (0, center_y))
|
||||
return result
|
||||
|
||||
elif mode == 'quad':
|
||||
# 4-way mirror (top-left quadrant mirrored to all)
|
||||
quad = frame.crop((0, 0, center_x, center_y))
|
||||
|
||||
result = Image.new('RGB', (width, height))
|
||||
|
||||
# Top-left (original)
|
||||
result.paste(quad, (0, 0))
|
||||
|
||||
# Top-right (horizontal mirror)
|
||||
result.paste(ImageOps.mirror(quad), (center_x, 0))
|
||||
|
||||
# Bottom-left (vertical mirror)
|
||||
result.paste(ImageOps.flip(quad), (0, center_y))
|
||||
|
||||
# Bottom-right (both mirrors)
|
||||
result.paste(ImageOps.flip(ImageOps.mirror(quad)), (center_x, center_y))
|
||||
|
||||
return result
|
||||
|
||||
else:
|
||||
return frame
|
||||
|
||||
|
||||
def create_kaleidoscope_animation(
|
||||
base_frame: Image.Image | None = None,
|
||||
num_frames: int = 30,
|
||||
segments: int = 8,
|
||||
rotation_speed: float = 1.0,
|
||||
width: int = 480,
|
||||
height: int = 480
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create animated kaleidoscope effect.
|
||||
|
||||
Args:
|
||||
base_frame: Frame to apply effect to (or None for demo pattern)
|
||||
num_frames: Number of frames
|
||||
segments: Kaleidoscope segments
|
||||
rotation_speed: How fast pattern rotates (0.5-2.0)
|
||||
width: Frame width if generating demo
|
||||
height: Frame height if generating demo
|
||||
|
||||
Returns:
|
||||
List of frames with kaleidoscope effect
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Create demo pattern if no base frame
|
||||
if base_frame is None:
|
||||
base_frame = Image.new('RGB', (width, height), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(base_frame)
|
||||
|
||||
# Draw some colored shapes
|
||||
from core.color_palettes import get_palette
|
||||
palette = get_palette('vibrant')
|
||||
|
||||
colors = [palette['primary'], palette['secondary'], palette['accent']]
|
||||
|
||||
for i, color in enumerate(colors):
|
||||
x = width // 2 + int(100 * math.cos(i * 2 * math.pi / 3))
|
||||
y = height // 2 + int(100 * math.sin(i * 2 * math.pi / 3))
|
||||
draw.ellipse([x - 40, y - 40, x + 40, y + 40], fill=color)
|
||||
|
||||
# Rotate base frame and apply kaleidoscope
|
||||
for i in range(num_frames):
|
||||
angle = (i / num_frames) * 360 * rotation_speed
|
||||
|
||||
# Rotate base frame
|
||||
rotated = base_frame.rotate(angle, resample=Image.BICUBIC)
|
||||
|
||||
# Apply kaleidoscope
|
||||
kaleido_frame = apply_kaleidoscope(rotated, segments=segments)
|
||||
|
||||
frames.append(kaleido_frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
from core.gif_builder import GIFBuilder
|
||||
|
||||
print("Creating kaleidoscope GIF...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Create kaleidoscope animation
|
||||
frames = create_kaleidoscope_animation(
|
||||
num_frames=40,
|
||||
segments=8,
|
||||
rotation_speed=0.5
|
||||
)
|
||||
|
||||
builder.add_frames(frames)
|
||||
builder.save('kaleidoscope_test.gif', num_colors=128)
|
||||
329
skills/slack-gif-creator/templates/morph.py
Executable file
329
skills/slack-gif-creator/templates/morph.py
Executable file
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Morph Animation - Transform between different emojis or shapes.
|
||||
|
||||
Creates smooth transitions and transformations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced, draw_circle
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_morph_animation(
|
||||
object1_data: dict,
|
||||
object2_data: dict,
|
||||
num_frames: int = 30,
|
||||
morph_type: str = 'crossfade', # 'crossfade', 'scale', 'spin_morph'
|
||||
easing: str = 'ease_in_out',
|
||||
object_type: str = 'emoji',
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create morphing animation between two objects.
|
||||
|
||||
Args:
|
||||
object1_data: First object configuration
|
||||
object2_data: Second object configuration
|
||||
num_frames: Number of frames
|
||||
morph_type: Type of morph effect
|
||||
easing: Easing function
|
||||
object_type: Type of objects
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
if morph_type == 'crossfade':
|
||||
# Simple crossfade between two objects
|
||||
opacity1 = interpolate(1, 0, t, easing)
|
||||
opacity2 = interpolate(0, 1, t, easing)
|
||||
|
||||
if object_type == 'emoji':
|
||||
# Create first emoji
|
||||
emoji1_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
size1 = object1_data['size']
|
||||
draw_emoji_enhanced(
|
||||
emoji1_canvas,
|
||||
emoji=object1_data['emoji'],
|
||||
position=(center_pos[0] - size1 // 2, center_pos[1] - size1 // 2),
|
||||
size=size1,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Apply opacity
|
||||
from templates.fade import apply_opacity
|
||||
emoji1_canvas = apply_opacity(emoji1_canvas, opacity1)
|
||||
|
||||
# Create second emoji
|
||||
emoji2_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
size2 = object2_data['size']
|
||||
draw_emoji_enhanced(
|
||||
emoji2_canvas,
|
||||
emoji=object2_data['emoji'],
|
||||
position=(center_pos[0] - size2 // 2, center_pos[1] - size2 // 2),
|
||||
size=size2,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
emoji2_canvas = apply_opacity(emoji2_canvas, opacity2)
|
||||
|
||||
# Composite both
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, emoji1_canvas)
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, emoji2_canvas)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
|
||||
elif object_type == 'circle':
|
||||
# Morph between two circles
|
||||
radius1 = object1_data['radius']
|
||||
radius2 = object2_data['radius']
|
||||
color1 = object1_data['color']
|
||||
color2 = object2_data['color']
|
||||
|
||||
# Interpolate properties
|
||||
current_radius = int(interpolate(radius1, radius2, t, easing))
|
||||
current_color = tuple(
|
||||
int(interpolate(color1[i], color2[i], t, easing))
|
||||
for i in range(3)
|
||||
)
|
||||
|
||||
draw_circle(frame, center_pos, current_radius, fill_color=current_color)
|
||||
|
||||
elif morph_type == 'scale':
|
||||
# First object scales down as second scales up
|
||||
if object_type == 'emoji':
|
||||
scale1 = interpolate(1.0, 0.0, t, easing)
|
||||
scale2 = interpolate(0.0, 1.0, t, easing)
|
||||
|
||||
# Draw first emoji (shrinking)
|
||||
if scale1 > 0.05:
|
||||
size1 = int(object1_data['size'] * scale1)
|
||||
size1 = max(12, size1)
|
||||
emoji1_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
draw_emoji_enhanced(
|
||||
emoji1_canvas,
|
||||
emoji=object1_data['emoji'],
|
||||
position=(center_pos[0] - size1 // 2, center_pos[1] - size1 // 2),
|
||||
size=size1,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_rgba, emoji1_canvas)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
# Draw second emoji (growing)
|
||||
if scale2 > 0.05:
|
||||
size2 = int(object2_data['size'] * scale2)
|
||||
size2 = max(12, size2)
|
||||
emoji2_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
draw_emoji_enhanced(
|
||||
emoji2_canvas,
|
||||
emoji=object2_data['emoji'],
|
||||
position=(center_pos[0] - size2 // 2, center_pos[1] - size2 // 2),
|
||||
size=size2,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_rgba, emoji2_canvas)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
elif morph_type == 'spin_morph':
|
||||
# Spin while morphing (flip-like)
|
||||
import math
|
||||
|
||||
# Calculate rotation (0 to 180 degrees)
|
||||
angle = interpolate(0, 180, t, easing)
|
||||
scale_factor = abs(math.cos(math.radians(angle)))
|
||||
|
||||
# Determine which object to show
|
||||
if angle < 90:
|
||||
current_object = object1_data
|
||||
else:
|
||||
current_object = object2_data
|
||||
|
||||
# Skip when edge-on
|
||||
if scale_factor < 0.05:
|
||||
frames.append(frame)
|
||||
continue
|
||||
|
||||
if object_type == 'emoji':
|
||||
size = current_object['size']
|
||||
canvas_size = size * 2
|
||||
emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=current_object['emoji'],
|
||||
position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Scale horizontally for spin effect
|
||||
new_width = max(1, int(canvas_size * scale_factor))
|
||||
emoji_scaled = emoji_canvas.resize((new_width, canvas_size), Image.LANCZOS)
|
||||
|
||||
paste_x = center_pos[0] - new_width // 2
|
||||
paste_y = center_pos[1] - canvas_size // 2
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba.paste(emoji_scaled, (paste_x, paste_y), emoji_scaled)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_reaction_morph(
|
||||
emoji_start: str,
|
||||
emoji_end: str,
|
||||
num_frames: int = 20,
|
||||
frame_size: int = 128
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create quick emoji reaction morph (for emoji GIFs).
|
||||
|
||||
Args:
|
||||
emoji_start: Starting emoji
|
||||
emoji_end: Ending emoji
|
||||
num_frames: Number of frames
|
||||
frame_size: Frame size (square)
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
return create_morph_animation(
|
||||
object1_data={'emoji': emoji_start, 'size': 80},
|
||||
object2_data={'emoji': emoji_end, 'size': 80},
|
||||
num_frames=num_frames,
|
||||
morph_type='crossfade',
|
||||
easing='ease_in_out',
|
||||
object_type='emoji',
|
||||
center_pos=(frame_size // 2, frame_size // 2),
|
||||
frame_width=frame_size,
|
||||
frame_height=frame_size,
|
||||
bg_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
|
||||
def create_shape_morph(
|
||||
shapes: list[dict],
|
||||
num_frames: int = 60,
|
||||
frames_per_shape: int = 20,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Morph through a sequence of shapes.
|
||||
|
||||
Args:
|
||||
shapes: List of shape dicts with 'radius' and 'color'
|
||||
num_frames: Total number of frames
|
||||
frames_per_shape: Frames to spend on each morph
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
center = (frame_width // 2, frame_height // 2)
|
||||
|
||||
for i in range(num_frames):
|
||||
# Determine which shapes we're morphing between
|
||||
cycle_progress = (i % (frames_per_shape * len(shapes))) / frames_per_shape
|
||||
shape_idx = int(cycle_progress) % len(shapes)
|
||||
next_shape_idx = (shape_idx + 1) % len(shapes)
|
||||
|
||||
# Progress between these two shapes
|
||||
t = cycle_progress - shape_idx
|
||||
|
||||
shape1 = shapes[shape_idx]
|
||||
shape2 = shapes[next_shape_idx]
|
||||
|
||||
# Interpolate properties
|
||||
radius = int(interpolate(shape1['radius'], shape2['radius'], t, 'ease_in_out'))
|
||||
color = tuple(
|
||||
int(interpolate(shape1['color'][j], shape2['color'][j], t, 'ease_in_out'))
|
||||
for j in range(3)
|
||||
)
|
||||
|
||||
# Draw frame
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
draw_circle(frame, center, radius, fill_color=color)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating morph animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Crossfade morph
|
||||
frames = create_morph_animation(
|
||||
object1_data={'emoji': '😊', 'size': 100},
|
||||
object2_data={'emoji': '😂', 'size': 100},
|
||||
num_frames=30,
|
||||
morph_type='crossfade',
|
||||
object_type='emoji'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('morph_crossfade.gif', num_colors=128)
|
||||
|
||||
# Example 2: Scale morph
|
||||
builder.clear()
|
||||
frames = create_morph_animation(
|
||||
object1_data={'emoji': '🌙', 'size': 100},
|
||||
object2_data={'emoji': '☀️', 'size': 100},
|
||||
num_frames=40,
|
||||
morph_type='scale',
|
||||
object_type='emoji'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('morph_scale.gif', num_colors=128)
|
||||
|
||||
# Example 3: Shape morph cycle
|
||||
builder.clear()
|
||||
from core.color_palettes import get_palette
|
||||
palette = get_palette('vibrant')
|
||||
|
||||
shapes = [
|
||||
{'radius': 60, 'color': palette['primary']},
|
||||
{'radius': 80, 'color': palette['secondary']},
|
||||
{'radius': 50, 'color': palette['accent']},
|
||||
{'radius': 70, 'color': palette['success']}
|
||||
]
|
||||
frames = create_shape_morph(shapes, num_frames=80, frames_per_shape=20)
|
||||
builder.add_frames(frames)
|
||||
builder.save('morph_shapes.gif', num_colors=64)
|
||||
|
||||
print("Created morph animations!")
|
||||
293
skills/slack-gif-creator/templates/move.py
Executable file
293
skills/slack-gif-creator/templates/move.py
Executable file
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Move Animation - Move objects along paths with various motion types.
|
||||
|
||||
Provides flexible movement primitives for objects along linear, arc, or custom paths.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_circle, draw_emoji_enhanced
|
||||
from core.easing import interpolate, calculate_arc_motion
|
||||
|
||||
|
||||
def create_move_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
start_pos: tuple[int, int] = (50, 240),
|
||||
end_pos: tuple[int, int] = (430, 240),
|
||||
num_frames: int = 30,
|
||||
motion_type: str = 'linear', # 'linear', 'arc', 'bezier', 'circle', 'wave'
|
||||
easing: str = 'ease_out',
|
||||
motion_params: dict | None = None,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list:
|
||||
"""
|
||||
Create frames showing object moving along a path.
|
||||
|
||||
Args:
|
||||
object_type: 'circle', 'emoji', or 'custom'
|
||||
object_data: Data for the object
|
||||
start_pos: Starting (x, y) position
|
||||
end_pos: Ending (x, y) position
|
||||
num_frames: Number of frames
|
||||
motion_type: Type of motion path
|
||||
easing: Easing function name
|
||||
motion_params: Additional parameters for motion (e.g., {'arc_height': 100})
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'circle':
|
||||
object_data = {'radius': 30, 'color': (100, 150, 255)}
|
||||
elif object_type == 'emoji':
|
||||
object_data = {'emoji': '🚀', 'size': 60}
|
||||
|
||||
# Default motion params
|
||||
if motion_params is None:
|
||||
motion_params = {}
|
||||
|
||||
for i in range(num_frames):
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Calculate position based on motion type
|
||||
if motion_type == 'linear':
|
||||
# Straight line with easing
|
||||
x = interpolate(start_pos[0], end_pos[0], t, easing)
|
||||
y = interpolate(start_pos[1], end_pos[1], t, easing)
|
||||
|
||||
elif motion_type == 'arc':
|
||||
# Parabolic arc
|
||||
arc_height = motion_params.get('arc_height', 100)
|
||||
x, y = calculate_arc_motion(start_pos, end_pos, arc_height, t)
|
||||
|
||||
elif motion_type == 'circle':
|
||||
# Circular motion around a center
|
||||
center = motion_params.get('center', (frame_width // 2, frame_height // 2))
|
||||
radius = motion_params.get('radius', 150)
|
||||
start_angle = motion_params.get('start_angle', 0)
|
||||
angle_range = motion_params.get('angle_range', 360) # Full circle
|
||||
|
||||
angle = start_angle + (angle_range * t)
|
||||
angle_rad = math.radians(angle)
|
||||
|
||||
x = center[0] + radius * math.cos(angle_rad)
|
||||
y = center[1] + radius * math.sin(angle_rad)
|
||||
|
||||
elif motion_type == 'wave':
|
||||
# Move in straight line but add wave motion
|
||||
wave_amplitude = motion_params.get('wave_amplitude', 50)
|
||||
wave_frequency = motion_params.get('wave_frequency', 2)
|
||||
|
||||
# Base linear motion
|
||||
base_x = interpolate(start_pos[0], end_pos[0], t, easing)
|
||||
base_y = interpolate(start_pos[1], end_pos[1], t, easing)
|
||||
|
||||
# Add wave offset perpendicular to motion direction
|
||||
dx = end_pos[0] - start_pos[0]
|
||||
dy = end_pos[1] - start_pos[1]
|
||||
length = math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if length > 0:
|
||||
# Perpendicular direction
|
||||
perp_x = -dy / length
|
||||
perp_y = dx / length
|
||||
|
||||
# Wave offset
|
||||
wave_offset = math.sin(t * wave_frequency * 2 * math.pi) * wave_amplitude
|
||||
|
||||
x = base_x + perp_x * wave_offset
|
||||
y = base_y + perp_y * wave_offset
|
||||
else:
|
||||
x, y = base_x, base_y
|
||||
|
||||
elif motion_type == 'bezier':
|
||||
# Quadratic bezier curve
|
||||
control_point = motion_params.get('control_point', (
|
||||
(start_pos[0] + end_pos[0]) // 2,
|
||||
(start_pos[1] + end_pos[1]) // 2 - 100
|
||||
))
|
||||
|
||||
# Quadratic Bezier formula: B(t) = (1-t)²P0 + 2(1-t)tP1 + t²P2
|
||||
x = (1 - t) ** 2 * start_pos[0] + 2 * (1 - t) * t * control_point[0] + t ** 2 * end_pos[0]
|
||||
y = (1 - t) ** 2 * start_pos[1] + 2 * (1 - t) * t * control_point[1] + t ** 2 * end_pos[1]
|
||||
|
||||
else:
|
||||
# Default to linear
|
||||
x = interpolate(start_pos[0], end_pos[0], t, easing)
|
||||
y = interpolate(start_pos[1], end_pos[1], t, easing)
|
||||
|
||||
# Draw object at calculated position
|
||||
x, y = int(x), int(y)
|
||||
|
||||
if object_type == 'circle':
|
||||
draw_circle(
|
||||
frame,
|
||||
center=(x, y),
|
||||
radius=object_data['radius'],
|
||||
fill_color=object_data['color']
|
||||
)
|
||||
elif object_type == 'emoji':
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(x - object_data['size'] // 2, y - object_data['size'] // 2),
|
||||
size=object_data['size'],
|
||||
shadow=object_data.get('shadow', True)
|
||||
)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_path_from_points(points: list[tuple[int, int]],
|
||||
num_frames: int = 60,
|
||||
easing: str = 'ease_in_out') -> list[tuple[int, int]]:
|
||||
"""
|
||||
Create a smooth path through multiple points.
|
||||
|
||||
Args:
|
||||
points: List of (x, y) waypoints
|
||||
num_frames: Total number of frames
|
||||
easing: Easing between points
|
||||
|
||||
Returns:
|
||||
List of (x, y) positions for each frame
|
||||
"""
|
||||
if len(points) < 2:
|
||||
return points * num_frames
|
||||
|
||||
path = []
|
||||
frames_per_segment = num_frames // (len(points) - 1)
|
||||
|
||||
for i in range(len(points) - 1):
|
||||
start = points[i]
|
||||
end = points[i + 1]
|
||||
|
||||
# Last segment gets remaining frames
|
||||
if i == len(points) - 2:
|
||||
segment_frames = num_frames - len(path)
|
||||
else:
|
||||
segment_frames = frames_per_segment
|
||||
|
||||
for j in range(segment_frames):
|
||||
t = j / segment_frames if segment_frames > 0 else 0
|
||||
x = interpolate(start[0], end[0], t, easing)
|
||||
y = interpolate(start[1], end[1], t, easing)
|
||||
path.append((int(x), int(y)))
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def apply_trail_effect(frames: list, trail_length: int = 5,
|
||||
fade_alpha: float = 0.3) -> list:
|
||||
"""
|
||||
Add motion trail effect to moving object.
|
||||
|
||||
Args:
|
||||
frames: List of frames with moving object
|
||||
trail_length: Number of previous frames to blend
|
||||
fade_alpha: Opacity of trail frames
|
||||
|
||||
Returns:
|
||||
List of frames with trail effect
|
||||
"""
|
||||
from PIL import Image, ImageChops
|
||||
import numpy as np
|
||||
|
||||
trailed_frames = []
|
||||
|
||||
for i, frame in enumerate(frames):
|
||||
# Start with current frame
|
||||
result = frame.copy()
|
||||
|
||||
# Blend previous frames
|
||||
for j in range(1, min(trail_length + 1, i + 1)):
|
||||
prev_frame = frames[i - j]
|
||||
|
||||
# Calculate fade
|
||||
alpha = fade_alpha ** j
|
||||
|
||||
# Blend
|
||||
result_array = np.array(result, dtype=np.float32)
|
||||
prev_array = np.array(prev_frame, dtype=np.float32)
|
||||
|
||||
blended = result_array * (1 - alpha) + prev_array * alpha
|
||||
result = Image.fromarray(blended.astype(np.uint8))
|
||||
|
||||
trailed_frames.append(result)
|
||||
|
||||
return trailed_frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating movement examples...")
|
||||
|
||||
# Example 1: Linear movement
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
frames = create_move_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🚀', 'size': 60},
|
||||
start_pos=(50, 240),
|
||||
end_pos=(430, 240),
|
||||
num_frames=30,
|
||||
motion_type='linear',
|
||||
easing='ease_out'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('move_linear.gif', num_colors=128)
|
||||
|
||||
# Example 2: Arc movement
|
||||
builder.clear()
|
||||
frames = create_move_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '⚽', 'size': 60},
|
||||
start_pos=(50, 350),
|
||||
end_pos=(430, 350),
|
||||
num_frames=30,
|
||||
motion_type='arc',
|
||||
motion_params={'arc_height': 150},
|
||||
easing='linear'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('move_arc.gif', num_colors=128)
|
||||
|
||||
# Example 3: Circular movement
|
||||
builder.clear()
|
||||
frames = create_move_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🌍', 'size': 50},
|
||||
start_pos=(0, 0), # Ignored for circle
|
||||
end_pos=(0, 0), # Ignored for circle
|
||||
num_frames=40,
|
||||
motion_type='circle',
|
||||
motion_params={
|
||||
'center': (240, 240),
|
||||
'radius': 120,
|
||||
'start_angle': 0,
|
||||
'angle_range': 360
|
||||
},
|
||||
easing='linear'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('move_circle.gif', num_colors=128)
|
||||
|
||||
print("Created movement examples!")
|
||||
268
skills/slack-gif-creator/templates/pulse.py
Executable file
268
skills/slack-gif-creator/templates/pulse.py
Executable file
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pulse Animation - Scale objects rhythmically for emphasis.
|
||||
|
||||
Creates pulsing, heartbeat, and throbbing effects.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced, draw_circle
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_pulse_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
pulse_type: str = 'smooth', # 'smooth', 'heartbeat', 'throb', 'pop'
|
||||
scale_range: tuple[float, float] = (0.8, 1.2),
|
||||
pulses: float = 2.0,
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create pulsing/scaling animation.
|
||||
|
||||
Args:
|
||||
object_type: 'emoji', 'circle', 'text'
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
pulse_type: Type of pulsing motion
|
||||
scale_range: (min_scale, max_scale) tuple
|
||||
pulses: Number of pulses in animation
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '❤️', 'size': 100}
|
||||
elif object_type == 'circle':
|
||||
object_data = {'radius': 50, 'color': (255, 100, 100)}
|
||||
|
||||
min_scale, max_scale = scale_range
|
||||
|
||||
for i in range(num_frames):
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Calculate scale based on pulse type
|
||||
if pulse_type == 'smooth':
|
||||
# Simple sinusoidal pulse
|
||||
scale = min_scale + (max_scale - min_scale) * (
|
||||
0.5 + 0.5 * math.sin(t * pulses * 2 * math.pi - math.pi / 2)
|
||||
)
|
||||
|
||||
elif pulse_type == 'heartbeat':
|
||||
# Double pump like a heartbeat
|
||||
phase = (t * pulses) % 1.0
|
||||
if phase < 0.15:
|
||||
# First pump
|
||||
scale = interpolate(min_scale, max_scale, phase / 0.15, 'ease_out')
|
||||
elif phase < 0.25:
|
||||
# First release
|
||||
scale = interpolate(max_scale, min_scale, (phase - 0.15) / 0.10, 'ease_in')
|
||||
elif phase < 0.35:
|
||||
# Second pump (smaller)
|
||||
scale = interpolate(min_scale, (min_scale + max_scale) / 2, (phase - 0.25) / 0.10, 'ease_out')
|
||||
elif phase < 0.45:
|
||||
# Second release
|
||||
scale = interpolate((min_scale + max_scale) / 2, min_scale, (phase - 0.35) / 0.10, 'ease_in')
|
||||
else:
|
||||
# Rest period
|
||||
scale = min_scale
|
||||
|
||||
elif pulse_type == 'throb':
|
||||
# Sharp pulse with quick return
|
||||
phase = (t * pulses) % 1.0
|
||||
if phase < 0.2:
|
||||
scale = interpolate(min_scale, max_scale, phase / 0.2, 'ease_out')
|
||||
else:
|
||||
scale = interpolate(max_scale, min_scale, (phase - 0.2) / 0.8, 'ease_in')
|
||||
|
||||
elif pulse_type == 'pop':
|
||||
# Pop out and back with overshoot
|
||||
phase = (t * pulses) % 1.0
|
||||
if phase < 0.3:
|
||||
# Pop out with overshoot
|
||||
scale = interpolate(min_scale, max_scale * 1.1, phase / 0.3, 'elastic_out')
|
||||
else:
|
||||
# Settle back
|
||||
scale = interpolate(max_scale * 1.1, min_scale, (phase - 0.3) / 0.7, 'ease_out')
|
||||
|
||||
else:
|
||||
scale = min_scale + (max_scale - min_scale) * (
|
||||
0.5 + 0.5 * math.sin(t * pulses * 2 * math.pi)
|
||||
)
|
||||
|
||||
# Draw object at calculated scale
|
||||
if object_type == 'emoji':
|
||||
base_size = object_data['size']
|
||||
current_size = int(base_size * scale)
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(center_pos[0] - current_size // 2, center_pos[1] - current_size // 2),
|
||||
size=current_size,
|
||||
shadow=object_data.get('shadow', True)
|
||||
)
|
||||
|
||||
elif object_type == 'circle':
|
||||
base_radius = object_data['radius']
|
||||
current_radius = int(base_radius * scale)
|
||||
draw_circle(
|
||||
frame,
|
||||
center=center_pos,
|
||||
radius=current_radius,
|
||||
fill_color=object_data['color']
|
||||
)
|
||||
|
||||
elif object_type == 'text':
|
||||
from core.typography import draw_text_with_outline
|
||||
base_size = object_data.get('font_size', 50)
|
||||
current_size = int(base_size * scale)
|
||||
draw_text_with_outline(
|
||||
frame,
|
||||
text=object_data.get('text', 'PULSE'),
|
||||
position=center_pos,
|
||||
font_size=current_size,
|
||||
text_color=object_data.get('text_color', (255, 100, 100)),
|
||||
outline_color=object_data.get('outline_color', (0, 0, 0)),
|
||||
outline_width=3,
|
||||
centered=True
|
||||
)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_attention_pulse(
|
||||
emoji: str = '⚠️',
|
||||
num_frames: int = 20,
|
||||
frame_size: int = 128,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create attention-grabbing pulse (good for emoji GIFs).
|
||||
|
||||
Args:
|
||||
emoji: Emoji to pulse
|
||||
num_frames: Number of frames
|
||||
frame_size: Frame size (square)
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames optimized for emoji size
|
||||
"""
|
||||
return create_pulse_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': emoji, 'size': 80, 'shadow': False},
|
||||
num_frames=num_frames,
|
||||
pulse_type='throb',
|
||||
scale_range=(0.85, 1.15),
|
||||
pulses=2,
|
||||
center_pos=(frame_size // 2, frame_size // 2),
|
||||
frame_width=frame_size,
|
||||
frame_height=frame_size,
|
||||
bg_color=bg_color
|
||||
)
|
||||
|
||||
|
||||
def create_breathing_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 60,
|
||||
breaths: float = 2.0,
|
||||
scale_range: tuple[float, float] = (0.9, 1.1),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (240, 248, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create slow, calming breathing animation (in and out).
|
||||
|
||||
Args:
|
||||
object_type: Type of object
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
breaths: Number of breathing cycles
|
||||
scale_range: Min/max scale
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
if object_data is None:
|
||||
object_data = {'emoji': '😌', 'size': 100}
|
||||
|
||||
return create_pulse_animation(
|
||||
object_type=object_type,
|
||||
object_data=object_data,
|
||||
num_frames=num_frames,
|
||||
pulse_type='smooth',
|
||||
scale_range=scale_range,
|
||||
pulses=breaths,
|
||||
center_pos=(frame_width // 2, frame_height // 2),
|
||||
frame_width=frame_width,
|
||||
frame_height=frame_height,
|
||||
bg_color=bg_color
|
||||
)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating pulse animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Smooth pulse
|
||||
frames = create_pulse_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '❤️', 'size': 100},
|
||||
num_frames=40,
|
||||
pulse_type='smooth',
|
||||
scale_range=(0.8, 1.2),
|
||||
pulses=2
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('pulse_smooth.gif', num_colors=128)
|
||||
|
||||
# Example 2: Heartbeat
|
||||
builder.clear()
|
||||
frames = create_pulse_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '💓', 'size': 100},
|
||||
num_frames=60,
|
||||
pulse_type='heartbeat',
|
||||
scale_range=(0.85, 1.2),
|
||||
pulses=3
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('pulse_heartbeat.gif', num_colors=128)
|
||||
|
||||
# Example 3: Attention pulse (emoji size)
|
||||
builder = GIFBuilder(width=128, height=128, fps=15)
|
||||
frames = create_attention_pulse(emoji='⚠️', num_frames=20)
|
||||
builder.add_frames(frames)
|
||||
builder.save('pulse_attention.gif', num_colors=48, optimize_for_emoji=True)
|
||||
|
||||
print("Created pulse animations!")
|
||||
127
skills/slack-gif-creator/templates/shake.py
Executable file
127
skills/slack-gif-creator/templates/shake.py
Executable file
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shake Animation Template - Creates shaking/vibrating motion.
|
||||
|
||||
Use this for impact effects, emphasis, or nervous/excited reactions.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_circle, draw_emoji, draw_text
|
||||
from core.easing import ease_out_quad
|
||||
|
||||
|
||||
def create_shake_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict = None,
|
||||
num_frames: int = 20,
|
||||
shake_intensity: int = 15,
|
||||
center_x: int = 240,
|
||||
center_y: int = 240,
|
||||
direction: str = 'horizontal', # 'horizontal', 'vertical', or 'both'
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list:
|
||||
"""
|
||||
Create frames for a shaking animation.
|
||||
|
||||
Args:
|
||||
object_type: 'circle', 'emoji', 'text', or 'custom'
|
||||
object_data: Data for the object
|
||||
num_frames: Number of frames
|
||||
shake_intensity: Maximum shake displacement in pixels
|
||||
center_x: Center X position
|
||||
center_y: Center Y position
|
||||
direction: 'horizontal', 'vertical', or 'both'
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '😱', 'size': 80}
|
||||
elif object_type == 'text':
|
||||
object_data = {'text': 'SHAKE!', 'font_size': 50, 'color': (255, 0, 0)}
|
||||
|
||||
for i in range(num_frames):
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
# Calculate progress
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Decay shake intensity over time
|
||||
intensity = shake_intensity * (1 - ease_out_quad(t))
|
||||
|
||||
# Calculate shake offset using sine wave for smooth oscillation
|
||||
freq = 3 # Oscillation frequency
|
||||
offset_x = 0
|
||||
offset_y = 0
|
||||
|
||||
if direction in ['horizontal', 'both']:
|
||||
offset_x = int(math.sin(t * freq * 2 * math.pi) * intensity)
|
||||
|
||||
if direction in ['vertical', 'both']:
|
||||
offset_y = int(math.cos(t * freq * 2 * math.pi) * intensity)
|
||||
|
||||
# Apply offset
|
||||
x = center_x + offset_x
|
||||
y = center_y + offset_y
|
||||
|
||||
# Draw object
|
||||
if object_type == 'emoji':
|
||||
draw_emoji(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(x - object_data['size'] // 2, y - object_data['size'] // 2),
|
||||
size=object_data['size']
|
||||
)
|
||||
elif object_type == 'text':
|
||||
draw_text(
|
||||
frame,
|
||||
text=object_data['text'],
|
||||
position=(x, y),
|
||||
font_size=object_data['font_size'],
|
||||
color=object_data['color'],
|
||||
centered=True
|
||||
)
|
||||
elif object_type == 'circle':
|
||||
draw_circle(
|
||||
frame,
|
||||
center=(x, y),
|
||||
radius=object_data.get('radius', 30),
|
||||
fill_color=object_data.get('color', (100, 100, 255))
|
||||
)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating shake GIF...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=24)
|
||||
|
||||
frames = create_shake_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '😱', 'size': 100},
|
||||
num_frames=30,
|
||||
shake_intensity=20,
|
||||
direction='both'
|
||||
)
|
||||
|
||||
builder.add_frames(frames)
|
||||
builder.save('shake_test.gif', num_colors=128)
|
||||
291
skills/slack-gif-creator/templates/slide.py
Executable file
291
skills/slack-gif-creator/templates/slide.py
Executable file
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Slide Animation - Slide elements in from edges with overshoot/bounce.
|
||||
|
||||
Creates smooth entrance and exit animations.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_slide_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
direction: str = 'left', # 'left', 'right', 'top', 'bottom'
|
||||
slide_type: str = 'in', # 'in', 'out', 'across'
|
||||
easing: str = 'ease_out',
|
||||
overshoot: bool = False,
|
||||
final_pos: tuple[int, int] | None = None,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create slide animation.
|
||||
|
||||
Args:
|
||||
object_type: 'emoji', 'text'
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
direction: Direction of slide
|
||||
slide_type: Type of slide (in/out/across)
|
||||
easing: Easing function
|
||||
overshoot: Add overshoot/bounce at end
|
||||
final_pos: Final position (None = center)
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '➡️', 'size': 100}
|
||||
|
||||
if final_pos is None:
|
||||
final_pos = (frame_width // 2, frame_height // 2)
|
||||
|
||||
# Calculate start and end positions based on direction
|
||||
size = object_data.get('size', 100) if object_type == 'emoji' else 100
|
||||
margin = size
|
||||
|
||||
if direction == 'left':
|
||||
start_pos = (-margin, final_pos[1])
|
||||
end_pos = final_pos if slide_type == 'in' else (frame_width + margin, final_pos[1])
|
||||
elif direction == 'right':
|
||||
start_pos = (frame_width + margin, final_pos[1])
|
||||
end_pos = final_pos if slide_type == 'in' else (-margin, final_pos[1])
|
||||
elif direction == 'top':
|
||||
start_pos = (final_pos[0], -margin)
|
||||
end_pos = final_pos if slide_type == 'in' else (final_pos[0], frame_height + margin)
|
||||
elif direction == 'bottom':
|
||||
start_pos = (final_pos[0], frame_height + margin)
|
||||
end_pos = final_pos if slide_type == 'in' else (final_pos[0], -margin)
|
||||
else:
|
||||
start_pos = (-margin, final_pos[1])
|
||||
end_pos = final_pos
|
||||
|
||||
# For 'out' type, swap start and end
|
||||
if slide_type == 'out':
|
||||
start_pos, end_pos = final_pos, end_pos
|
||||
elif slide_type == 'across':
|
||||
# Slide all the way across
|
||||
if direction == 'left':
|
||||
start_pos = (-margin, final_pos[1])
|
||||
end_pos = (frame_width + margin, final_pos[1])
|
||||
elif direction == 'right':
|
||||
start_pos = (frame_width + margin, final_pos[1])
|
||||
end_pos = (-margin, final_pos[1])
|
||||
elif direction == 'top':
|
||||
start_pos = (final_pos[0], -margin)
|
||||
end_pos = (final_pos[0], frame_height + margin)
|
||||
elif direction == 'bottom':
|
||||
start_pos = (final_pos[0], frame_height + margin)
|
||||
end_pos = (final_pos[0], -margin)
|
||||
|
||||
# Use overshoot easing if requested
|
||||
if overshoot and slide_type == 'in':
|
||||
easing = 'back_out'
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
# Calculate current position
|
||||
x = int(interpolate(start_pos[0], end_pos[0], t, easing))
|
||||
y = int(interpolate(start_pos[1], end_pos[1], t, easing))
|
||||
|
||||
# Draw object
|
||||
if object_type == 'emoji':
|
||||
size = object_data['size']
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(x - size // 2, y - size // 2),
|
||||
size=size,
|
||||
shadow=object_data.get('shadow', True)
|
||||
)
|
||||
|
||||
elif object_type == 'text':
|
||||
from core.typography import draw_text_with_outline
|
||||
draw_text_with_outline(
|
||||
frame,
|
||||
text=object_data.get('text', 'SLIDE'),
|
||||
position=(x, y),
|
||||
font_size=object_data.get('font_size', 50),
|
||||
text_color=object_data.get('text_color', (0, 0, 0)),
|
||||
outline_color=object_data.get('outline_color', (255, 255, 255)),
|
||||
outline_width=3,
|
||||
centered=True
|
||||
)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_multi_slide(
|
||||
objects: list[dict],
|
||||
num_frames: int = 30,
|
||||
stagger_delay: int = 3,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create animation with multiple objects sliding in sequence.
|
||||
|
||||
Args:
|
||||
objects: List of object configs with 'type', 'data', 'direction', 'final_pos'
|
||||
num_frames: Number of frames
|
||||
stagger_delay: Frames between each object starting
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
for i in range(num_frames):
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
for idx, obj in enumerate(objects):
|
||||
# Calculate when this object starts moving
|
||||
start_frame = idx * stagger_delay
|
||||
if i < start_frame:
|
||||
continue # Object hasn't started yet
|
||||
|
||||
# Calculate progress for this object
|
||||
obj_frame = i - start_frame
|
||||
obj_duration = num_frames - start_frame
|
||||
if obj_duration <= 0:
|
||||
continue
|
||||
|
||||
t = obj_frame / obj_duration
|
||||
|
||||
# Get object properties
|
||||
obj_type = obj.get('type', 'emoji')
|
||||
obj_data = obj.get('data', {'emoji': '➡️', 'size': 80})
|
||||
direction = obj.get('direction', 'left')
|
||||
final_pos = obj.get('final_pos', (frame_width // 2, frame_height // 2))
|
||||
easing = obj.get('easing', 'back_out')
|
||||
|
||||
# Calculate position
|
||||
size = obj_data.get('size', 80)
|
||||
margin = size
|
||||
|
||||
if direction == 'left':
|
||||
start_x = -margin
|
||||
end_x = final_pos[0]
|
||||
y = final_pos[1]
|
||||
elif direction == 'right':
|
||||
start_x = frame_width + margin
|
||||
end_x = final_pos[0]
|
||||
y = final_pos[1]
|
||||
elif direction == 'top':
|
||||
x = final_pos[0]
|
||||
start_y = -margin
|
||||
end_y = final_pos[1]
|
||||
elif direction == 'bottom':
|
||||
x = final_pos[0]
|
||||
start_y = frame_height + margin
|
||||
end_y = final_pos[1]
|
||||
else:
|
||||
start_x = -margin
|
||||
end_x = final_pos[0]
|
||||
y = final_pos[1]
|
||||
|
||||
# Interpolate position
|
||||
if direction in ['left', 'right']:
|
||||
x = int(interpolate(start_x, end_x, t, easing))
|
||||
else:
|
||||
y = int(interpolate(start_y, end_y, t, easing))
|
||||
|
||||
# Draw object
|
||||
if obj_type == 'emoji':
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=obj_data['emoji'],
|
||||
position=(x - size // 2, y - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating slide animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Slide in from left with overshoot
|
||||
frames = create_slide_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '➡️', 'size': 100},
|
||||
num_frames=30,
|
||||
direction='left',
|
||||
slide_type='in',
|
||||
overshoot=True
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('slide_in_left.gif', num_colors=128)
|
||||
|
||||
# Example 2: Slide across
|
||||
builder.clear()
|
||||
frames = create_slide_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🚀', 'size': 80},
|
||||
num_frames=40,
|
||||
direction='left',
|
||||
slide_type='across',
|
||||
easing='ease_in_out'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('slide_across.gif', num_colors=128)
|
||||
|
||||
# Example 3: Multiple objects sliding in
|
||||
builder.clear()
|
||||
objects = [
|
||||
{
|
||||
'type': 'emoji',
|
||||
'data': {'emoji': '🎯', 'size': 60},
|
||||
'direction': 'left',
|
||||
'final_pos': (120, 240)
|
||||
},
|
||||
{
|
||||
'type': 'emoji',
|
||||
'data': {'emoji': '🎪', 'size': 60},
|
||||
'direction': 'right',
|
||||
'final_pos': (240, 240)
|
||||
},
|
||||
{
|
||||
'type': 'emoji',
|
||||
'data': {'emoji': '🎨', 'size': 60},
|
||||
'direction': 'top',
|
||||
'final_pos': (360, 240)
|
||||
}
|
||||
]
|
||||
frames = create_multi_slide(objects, num_frames=50, stagger_delay=5)
|
||||
builder.add_frames(frames)
|
||||
builder.save('slide_multi.gif', num_colors=128)
|
||||
|
||||
print("Created slide animations!")
|
||||
269
skills/slack-gif-creator/templates/spin.py
Executable file
269
skills/slack-gif-creator/templates/spin.py
Executable file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Spin Animation - Rotate objects continuously or with variation.
|
||||
|
||||
Creates spinning, rotating, and wobbling effects.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced, draw_circle
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_spin_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
rotation_type: str = 'clockwise', # 'clockwise', 'counterclockwise', 'wobble', 'pendulum'
|
||||
full_rotations: float = 1.0,
|
||||
easing: str = 'linear',
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create spinning/rotating animation.
|
||||
|
||||
Args:
|
||||
object_type: 'emoji', 'image', 'text'
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
rotation_type: Type of rotation
|
||||
full_rotations: Number of complete 360° rotations
|
||||
easing: Easing function for rotation speed
|
||||
center_pos: Center position for rotation
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '🔄', 'size': 100}
|
||||
|
||||
for i in range(num_frames):
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Calculate rotation angle
|
||||
if rotation_type == 'clockwise':
|
||||
angle = interpolate(0, 360 * full_rotations, t, easing)
|
||||
elif rotation_type == 'counterclockwise':
|
||||
angle = interpolate(0, -360 * full_rotations, t, easing)
|
||||
elif rotation_type == 'wobble':
|
||||
# Back and forth rotation
|
||||
angle = math.sin(t * full_rotations * 2 * math.pi) * 45
|
||||
elif rotation_type == 'pendulum':
|
||||
# Smooth pendulum swing
|
||||
angle = math.sin(t * full_rotations * 2 * math.pi) * 90
|
||||
else:
|
||||
angle = interpolate(0, 360 * full_rotations, t, easing)
|
||||
|
||||
# Create object on transparent background to rotate
|
||||
if object_type == 'emoji':
|
||||
# For emoji, we need to create a larger canvas to avoid clipping during rotation
|
||||
emoji_size = object_data['size']
|
||||
canvas_size = int(emoji_size * 1.5)
|
||||
emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
# Draw emoji in center of canvas
|
||||
from core.frame_composer import draw_emoji_enhanced
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=object_data['emoji'],
|
||||
position=(canvas_size // 2 - emoji_size // 2, canvas_size // 2 - emoji_size // 2),
|
||||
size=emoji_size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Rotate the canvas
|
||||
rotated = emoji_canvas.rotate(angle, resample=Image.BICUBIC, expand=False)
|
||||
|
||||
# Paste onto frame
|
||||
paste_x = center_pos[0] - canvas_size // 2
|
||||
paste_y = center_pos[1] - canvas_size // 2
|
||||
frame.paste(rotated, (paste_x, paste_y), rotated)
|
||||
|
||||
elif object_type == 'text':
|
||||
from core.typography import draw_text_with_outline
|
||||
# Similar approach - create canvas, draw text, rotate
|
||||
text = object_data.get('text', 'SPIN!')
|
||||
font_size = object_data.get('font_size', 50)
|
||||
|
||||
canvas_size = max(frame_width, frame_height)
|
||||
text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
# Draw text
|
||||
text_canvas_rgb = text_canvas.convert('RGB')
|
||||
text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
|
||||
draw_text_with_outline(
|
||||
text_canvas_rgb,
|
||||
text,
|
||||
position=(canvas_size // 2, canvas_size // 2),
|
||||
font_size=font_size,
|
||||
text_color=object_data.get('text_color', (0, 0, 0)),
|
||||
outline_color=object_data.get('outline_color', (255, 255, 255)),
|
||||
outline_width=3,
|
||||
centered=True
|
||||
)
|
||||
|
||||
# Convert back to RGBA for rotation
|
||||
text_canvas = text_canvas_rgb.convert('RGBA')
|
||||
|
||||
# Make background transparent
|
||||
data = text_canvas.getdata()
|
||||
new_data = []
|
||||
for item in data:
|
||||
if item[:3] == bg_color:
|
||||
new_data.append((255, 255, 255, 0))
|
||||
else:
|
||||
new_data.append(item)
|
||||
text_canvas.putdata(new_data)
|
||||
|
||||
# Rotate
|
||||
rotated = text_canvas.rotate(angle, resample=Image.BICUBIC, expand=False)
|
||||
|
||||
# Composite onto frame
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba = Image.alpha_composite(frame_rgba, rotated)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_loading_spinner(
|
||||
num_frames: int = 20,
|
||||
spinner_type: str = 'dots', # 'dots', 'arc', 'emoji'
|
||||
size: int = 100,
|
||||
color: tuple[int, int, int] = (100, 150, 255),
|
||||
frame_width: int = 128,
|
||||
frame_height: int = 128,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create a loading spinner animation.
|
||||
|
||||
Args:
|
||||
num_frames: Number of frames
|
||||
spinner_type: Type of spinner
|
||||
size: Spinner size
|
||||
color: Spinner color
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
from PIL import ImageDraw
|
||||
frames = []
|
||||
center = (frame_width // 2, frame_height // 2)
|
||||
|
||||
for i in range(num_frames):
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
angle_offset = (i / num_frames) * 360
|
||||
|
||||
if spinner_type == 'dots':
|
||||
# Circular dots
|
||||
num_dots = 8
|
||||
for j in range(num_dots):
|
||||
angle = (j / num_dots * 360 + angle_offset) * math.pi / 180
|
||||
x = center[0] + size * 0.4 * math.cos(angle)
|
||||
y = center[1] + size * 0.4 * math.sin(angle)
|
||||
|
||||
# Fade based on position
|
||||
alpha = 1.0 - (j / num_dots)
|
||||
dot_color = tuple(int(c * alpha) for c in color)
|
||||
dot_radius = int(size * 0.1)
|
||||
|
||||
draw.ellipse(
|
||||
[x - dot_radius, y - dot_radius, x + dot_radius, y + dot_radius],
|
||||
fill=dot_color
|
||||
)
|
||||
|
||||
elif spinner_type == 'arc':
|
||||
# Rotating arc
|
||||
start_angle = angle_offset
|
||||
end_angle = angle_offset + 270
|
||||
arc_width = int(size * 0.15)
|
||||
|
||||
bbox = [
|
||||
center[0] - size // 2,
|
||||
center[1] - size // 2,
|
||||
center[0] + size // 2,
|
||||
center[1] + size // 2
|
||||
]
|
||||
draw.arc(bbox, start_angle, end_angle, fill=color, width=arc_width)
|
||||
|
||||
elif spinner_type == 'emoji':
|
||||
# Rotating emoji spinner
|
||||
angle = angle_offset
|
||||
emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji='⏳',
|
||||
position=(center[0] - size // 2, center[1] - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
rotated = emoji_canvas.rotate(angle, center=center, resample=Image.BICUBIC)
|
||||
frame.paste(rotated, (0, 0), rotated)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating spin animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Clockwise spin
|
||||
frames = create_spin_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🔄', 'size': 100},
|
||||
num_frames=30,
|
||||
rotation_type='clockwise',
|
||||
full_rotations=2
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('spin_clockwise.gif', num_colors=128)
|
||||
|
||||
# Example 2: Wobble
|
||||
builder.clear()
|
||||
frames = create_spin_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🎯', 'size': 100},
|
||||
num_frames=30,
|
||||
rotation_type='wobble',
|
||||
full_rotations=3
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('spin_wobble.gif', num_colors=128)
|
||||
|
||||
# Example 3: Loading spinner
|
||||
builder = GIFBuilder(width=128, height=128, fps=15)
|
||||
frames = create_loading_spinner(num_frames=20, spinner_type='dots')
|
||||
builder.add_frames(frames)
|
||||
builder.save('loading_spinner.gif', num_colors=64, optimize_for_emoji=True)
|
||||
|
||||
print("Created spin animations!")
|
||||
300
skills/slack-gif-creator/templates/wiggle.py
Executable file
300
skills/slack-gif-creator/templates/wiggle.py
Executable file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Wiggle Animation - Smooth, organic wobbling and jiggling motions.
|
||||
|
||||
Creates playful, elastic movements that are smoother than shake.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_wiggle_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
wiggle_type: str = 'jello', # 'jello', 'wave', 'bounce', 'sway'
|
||||
intensity: float = 1.0,
|
||||
cycles: float = 2.0,
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create wiggle/wobble animation.
|
||||
|
||||
Args:
|
||||
object_type: 'emoji', 'text'
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
wiggle_type: Type of wiggle motion
|
||||
intensity: Wiggle intensity multiplier
|
||||
cycles: Number of wiggle cycles
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '🎈', 'size': 100}
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
# Calculate wiggle transformations
|
||||
offset_x = 0
|
||||
offset_y = 0
|
||||
rotation = 0
|
||||
scale_x = 1.0
|
||||
scale_y = 1.0
|
||||
|
||||
if wiggle_type == 'jello':
|
||||
# Jello wobble - multiple frequencies
|
||||
freq1 = cycles * 2 * math.pi
|
||||
freq2 = cycles * 3 * math.pi
|
||||
freq3 = cycles * 5 * math.pi
|
||||
|
||||
decay = 1.0 - t if cycles < 1.5 else 1.0 # Decay for single wiggles
|
||||
|
||||
offset_x = (
|
||||
math.sin(freq1 * t) * 15 +
|
||||
math.sin(freq2 * t) * 8 +
|
||||
math.sin(freq3 * t) * 3
|
||||
) * intensity * decay
|
||||
|
||||
rotation = (
|
||||
math.sin(freq1 * t) * 10 +
|
||||
math.cos(freq2 * t) * 5
|
||||
) * intensity * decay
|
||||
|
||||
# Squash and stretch
|
||||
scale_y = 1.0 + math.sin(freq1 * t) * 0.1 * intensity * decay
|
||||
scale_x = 1.0 / scale_y # Preserve volume
|
||||
|
||||
elif wiggle_type == 'wave':
|
||||
# Wave motion
|
||||
freq = cycles * 2 * math.pi
|
||||
offset_y = math.sin(freq * t) * 20 * intensity
|
||||
rotation = math.sin(freq * t + math.pi / 4) * 8 * intensity
|
||||
|
||||
elif wiggle_type == 'bounce':
|
||||
# Bouncy wiggle
|
||||
freq = cycles * 2 * math.pi
|
||||
bounce = abs(math.sin(freq * t))
|
||||
|
||||
scale_y = 1.0 + bounce * 0.2 * intensity
|
||||
scale_x = 1.0 - bounce * 0.1 * intensity
|
||||
offset_y = -bounce * 10 * intensity
|
||||
|
||||
elif wiggle_type == 'sway':
|
||||
# Gentle sway back and forth
|
||||
freq = cycles * 2 * math.pi
|
||||
offset_x = math.sin(freq * t) * 25 * intensity
|
||||
rotation = math.sin(freq * t) * 12 * intensity
|
||||
|
||||
# Subtle scale change
|
||||
scale = 1.0 + math.sin(freq * t) * 0.05 * intensity
|
||||
scale_x = scale
|
||||
scale_y = scale
|
||||
|
||||
elif wiggle_type == 'tail_wag':
|
||||
# Like a wagging tail - base stays, tip moves
|
||||
freq = cycles * 2 * math.pi
|
||||
wag = math.sin(freq * t) * intensity
|
||||
|
||||
# Rotation focused at one end
|
||||
rotation = wag * 20
|
||||
offset_x = wag * 15
|
||||
|
||||
# Apply transformations
|
||||
if object_type == 'emoji':
|
||||
size = object_data['size']
|
||||
size_x = int(size * scale_x)
|
||||
size_y = int(size * scale_y)
|
||||
|
||||
# For non-uniform scaling or rotation, we need to use PIL transforms
|
||||
if abs(scale_x - scale_y) > 0.01 or abs(rotation) > 0.1:
|
||||
# Create emoji on transparent canvas
|
||||
canvas_size = int(size * 2)
|
||||
emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
# Draw emoji
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=object_data['emoji'],
|
||||
position=(canvas_size // 2 - size // 2, canvas_size // 2 - size // 2),
|
||||
size=size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Scale
|
||||
if abs(scale_x - scale_y) > 0.01:
|
||||
new_size = (int(canvas_size * scale_x), int(canvas_size * scale_y))
|
||||
emoji_canvas = emoji_canvas.resize(new_size, Image.LANCZOS)
|
||||
canvas_size_x, canvas_size_y = new_size
|
||||
else:
|
||||
canvas_size_x = canvas_size_y = canvas_size
|
||||
|
||||
# Rotate
|
||||
if abs(rotation) > 0.1:
|
||||
emoji_canvas = emoji_canvas.rotate(
|
||||
rotation,
|
||||
resample=Image.BICUBIC,
|
||||
expand=False
|
||||
)
|
||||
|
||||
# Position with offset
|
||||
paste_x = int(center_pos[0] - canvas_size_x // 2 + offset_x)
|
||||
paste_y = int(center_pos[1] - canvas_size_y // 2 + offset_y)
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame_rgba.paste(emoji_canvas, (paste_x, paste_y), emoji_canvas)
|
||||
frame = frame_rgba.convert('RGB')
|
||||
else:
|
||||
# Simple case - just offset
|
||||
pos_x = int(center_pos[0] - size // 2 + offset_x)
|
||||
pos_y = int(center_pos[1] - size // 2 + offset_y)
|
||||
draw_emoji_enhanced(
|
||||
frame,
|
||||
emoji=object_data['emoji'],
|
||||
position=(pos_x, pos_y),
|
||||
size=size,
|
||||
shadow=object_data.get('shadow', True)
|
||||
)
|
||||
|
||||
elif object_type == 'text':
|
||||
from core.typography import draw_text_with_outline
|
||||
|
||||
# Create text on canvas for transformation
|
||||
canvas_size = max(frame_width, frame_height)
|
||||
text_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
# Convert to RGB for drawing
|
||||
text_canvas_rgb = text_canvas.convert('RGB')
|
||||
text_canvas_rgb.paste(bg_color, (0, 0, canvas_size, canvas_size))
|
||||
|
||||
draw_text_with_outline(
|
||||
text_canvas_rgb,
|
||||
text=object_data.get('text', 'WIGGLE'),
|
||||
position=(canvas_size // 2, canvas_size // 2),
|
||||
font_size=object_data.get('font_size', 50),
|
||||
text_color=object_data.get('text_color', (0, 0, 0)),
|
||||
outline_color=object_data.get('outline_color', (255, 255, 255)),
|
||||
outline_width=3,
|
||||
centered=True
|
||||
)
|
||||
|
||||
# Make transparent
|
||||
text_canvas = text_canvas_rgb.convert('RGBA')
|
||||
data = text_canvas.getdata()
|
||||
new_data = []
|
||||
for item in data:
|
||||
if item[:3] == bg_color:
|
||||
new_data.append((255, 255, 255, 0))
|
||||
else:
|
||||
new_data.append(item)
|
||||
text_canvas.putdata(new_data)
|
||||
|
||||
# Apply rotation
|
||||
if abs(rotation) > 0.1:
|
||||
text_canvas = text_canvas.rotate(rotation, center=(canvas_size // 2, canvas_size // 2), resample=Image.BICUBIC)
|
||||
|
||||
# Crop to frame with offset
|
||||
left = (canvas_size - frame_width) // 2 - int(offset_x)
|
||||
top = (canvas_size - frame_height) // 2 - int(offset_y)
|
||||
text_cropped = text_canvas.crop((left, top, left + frame_width, top + frame_height))
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_rgba, text_cropped)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_excited_wiggle(
|
||||
emoji: str = '🎉',
|
||||
num_frames: int = 20,
|
||||
frame_size: int = 128
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create excited wiggle for emoji GIFs.
|
||||
|
||||
Args:
|
||||
emoji: Emoji to wiggle
|
||||
num_frames: Number of frames
|
||||
frame_size: Frame size (square)
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
return create_wiggle_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': emoji, 'size': 80, 'shadow': False},
|
||||
num_frames=num_frames,
|
||||
wiggle_type='jello',
|
||||
intensity=0.8,
|
||||
cycles=2,
|
||||
center_pos=(frame_size // 2, frame_size // 2),
|
||||
frame_width=frame_size,
|
||||
frame_height=frame_size,
|
||||
bg_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating wiggle animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Jello wiggle
|
||||
frames = create_wiggle_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🎈', 'size': 100},
|
||||
num_frames=40,
|
||||
wiggle_type='jello',
|
||||
intensity=1.0,
|
||||
cycles=2
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('wiggle_jello.gif', num_colors=128)
|
||||
|
||||
# Example 2: Wave
|
||||
builder.clear()
|
||||
frames = create_wiggle_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🌊', 'size': 100},
|
||||
num_frames=30,
|
||||
wiggle_type='wave',
|
||||
intensity=1.2,
|
||||
cycles=3
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('wiggle_wave.gif', num_colors=128)
|
||||
|
||||
# Example 3: Excited wiggle (emoji size)
|
||||
builder = GIFBuilder(width=128, height=128, fps=15)
|
||||
frames = create_excited_wiggle(emoji='🎉', num_frames=20)
|
||||
builder.add_frames(frames)
|
||||
builder.save('wiggle_excited.gif', num_colors=48, optimize_for_emoji=True)
|
||||
|
||||
print("Created wiggle animations!")
|
||||
312
skills/slack-gif-creator/templates/zoom.py
Executable file
312
skills/slack-gif-creator/templates/zoom.py
Executable file
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Zoom Animation - Scale objects dramatically for emphasis.
|
||||
|
||||
Creates zoom in, zoom out, and dramatic scaling effects.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
|
||||
from PIL import Image, ImageFilter
|
||||
from core.gif_builder import GIFBuilder
|
||||
from core.frame_composer import create_blank_frame, draw_emoji_enhanced
|
||||
from core.easing import interpolate
|
||||
|
||||
|
||||
def create_zoom_animation(
|
||||
object_type: str = 'emoji',
|
||||
object_data: dict | None = None,
|
||||
num_frames: int = 30,
|
||||
zoom_type: str = 'in', # 'in', 'out', 'in_out', 'punch'
|
||||
scale_range: tuple[float, float] = (0.1, 2.0),
|
||||
easing: str = 'ease_out',
|
||||
add_motion_blur: bool = False,
|
||||
center_pos: tuple[int, int] = (240, 240),
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create zoom animation.
|
||||
|
||||
Args:
|
||||
object_type: 'emoji', 'text', 'image'
|
||||
object_data: Object configuration
|
||||
num_frames: Number of frames
|
||||
zoom_type: Type of zoom effect
|
||||
scale_range: (start_scale, end_scale) tuple
|
||||
easing: Easing function
|
||||
add_motion_blur: Add blur for speed effect
|
||||
center_pos: Center position
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
# Default object data
|
||||
if object_data is None:
|
||||
if object_type == 'emoji':
|
||||
object_data = {'emoji': '🔍', 'size': 100}
|
||||
|
||||
base_size = object_data.get('size', 100) if object_type == 'emoji' else object_data.get('font_size', 60)
|
||||
start_scale, end_scale = scale_range
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Calculate scale based on zoom type
|
||||
if zoom_type == 'in':
|
||||
scale = interpolate(start_scale, end_scale, t, easing)
|
||||
elif zoom_type == 'out':
|
||||
scale = interpolate(end_scale, start_scale, t, easing)
|
||||
elif zoom_type == 'in_out':
|
||||
if t < 0.5:
|
||||
scale = interpolate(start_scale, end_scale, t * 2, easing)
|
||||
else:
|
||||
scale = interpolate(end_scale, start_scale, (t - 0.5) * 2, easing)
|
||||
elif zoom_type == 'punch':
|
||||
# Quick zoom in with overshoot then settle
|
||||
if t < 0.3:
|
||||
scale = interpolate(start_scale, end_scale * 1.2, t / 0.3, 'ease_out')
|
||||
else:
|
||||
scale = interpolate(end_scale * 1.2, end_scale, (t - 0.3) / 0.7, 'elastic_out')
|
||||
else:
|
||||
scale = interpolate(start_scale, end_scale, t, easing)
|
||||
|
||||
# Create frame
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
if object_type == 'emoji':
|
||||
current_size = int(base_size * scale)
|
||||
|
||||
# Clamp size to reasonable bounds
|
||||
current_size = max(12, min(current_size, frame_width * 2))
|
||||
|
||||
# Create emoji on transparent background
|
||||
canvas_size = max(frame_width, frame_height, current_size) * 2
|
||||
emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=object_data['emoji'],
|
||||
position=(canvas_size // 2 - current_size // 2, canvas_size // 2 - current_size // 2),
|
||||
size=current_size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Optional motion blur for fast zooms
|
||||
if add_motion_blur and abs(scale - 1.0) > 0.5:
|
||||
blur_amount = min(5, int(abs(scale - 1.0) * 3))
|
||||
emoji_canvas = emoji_canvas.filter(ImageFilter.GaussianBlur(blur_amount))
|
||||
|
||||
# Crop to frame size centered
|
||||
left = (canvas_size - frame_width) // 2
|
||||
top = (canvas_size - frame_height) // 2
|
||||
emoji_cropped = emoji_canvas.crop((left, top, left + frame_width, top + frame_height))
|
||||
|
||||
# Composite
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_rgba, emoji_cropped)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
elif object_type == 'text':
|
||||
from core.typography import draw_text_with_outline
|
||||
|
||||
current_size = int(base_size * scale)
|
||||
current_size = max(10, min(current_size, 500))
|
||||
|
||||
# Create oversized canvas for large text
|
||||
canvas_size = max(frame_width, frame_height, current_size * 10)
|
||||
text_canvas = Image.new('RGB', (canvas_size, canvas_size), bg_color)
|
||||
|
||||
draw_text_with_outline(
|
||||
text_canvas,
|
||||
text=object_data.get('text', 'ZOOM'),
|
||||
position=(canvas_size // 2, canvas_size // 2),
|
||||
font_size=current_size,
|
||||
text_color=object_data.get('text_color', (0, 0, 0)),
|
||||
outline_color=object_data.get('outline_color', (255, 255, 255)),
|
||||
outline_width=max(2, int(current_size * 0.05)),
|
||||
centered=True
|
||||
)
|
||||
|
||||
# Crop to frame
|
||||
left = (canvas_size - frame_width) // 2
|
||||
top = (canvas_size - frame_height) // 2
|
||||
frame = text_canvas.crop((left, top, left + frame_width, top + frame_height))
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_explosion_zoom(
|
||||
emoji: str = '💥',
|
||||
num_frames: int = 20,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create dramatic explosion zoom effect.
|
||||
|
||||
Args:
|
||||
emoji: Emoji to explode
|
||||
num_frames: Number of frames
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Exponential zoom
|
||||
scale = 0.1 * math.exp(t * 5)
|
||||
|
||||
# Add rotation for drama
|
||||
angle = t * 360 * 2
|
||||
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
current_size = int(100 * scale)
|
||||
current_size = max(12, min(current_size, frame_width * 3))
|
||||
|
||||
# Create emoji
|
||||
canvas_size = max(frame_width, frame_height, current_size) * 2
|
||||
emoji_canvas = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0))
|
||||
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=emoji,
|
||||
position=(canvas_size // 2 - current_size // 2, canvas_size // 2 - current_size // 2),
|
||||
size=current_size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
# Rotate
|
||||
emoji_canvas = emoji_canvas.rotate(angle, center=(canvas_size // 2, canvas_size // 2), resample=Image.BICUBIC)
|
||||
|
||||
# Add motion blur for later frames
|
||||
if t > 0.5:
|
||||
blur_amount = int((t - 0.5) * 10)
|
||||
emoji_canvas = emoji_canvas.filter(ImageFilter.GaussianBlur(blur_amount))
|
||||
|
||||
# Crop and composite
|
||||
left = (canvas_size - frame_width) // 2
|
||||
top = (canvas_size - frame_height) // 2
|
||||
emoji_cropped = emoji_canvas.crop((left, top, left + frame_width, top + frame_height))
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_rgba, emoji_cropped)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
def create_mind_blown_zoom(
|
||||
emoji: str = '🤯',
|
||||
num_frames: int = 30,
|
||||
frame_width: int = 480,
|
||||
frame_height: int = 480,
|
||||
bg_color: tuple[int, int, int] = (255, 255, 255)
|
||||
) -> list[Image.Image]:
|
||||
"""
|
||||
Create "mind blown" dramatic zoom with shake.
|
||||
|
||||
Args:
|
||||
emoji: Emoji to use
|
||||
num_frames: Number of frames
|
||||
frame_width: Frame width
|
||||
frame_height: Frame height
|
||||
bg_color: Background color
|
||||
|
||||
Returns:
|
||||
List of frames
|
||||
"""
|
||||
frames = []
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1) if num_frames > 1 else 0
|
||||
|
||||
# Zoom in then shake
|
||||
if t < 0.5:
|
||||
scale = interpolate(0.3, 1.2, t * 2, 'ease_out')
|
||||
shake_x = 0
|
||||
shake_y = 0
|
||||
else:
|
||||
scale = 1.2
|
||||
# Shake intensifies
|
||||
shake_intensity = (t - 0.5) * 40
|
||||
shake_x = int(math.sin(t * 50) * shake_intensity)
|
||||
shake_y = int(math.cos(t * 45) * shake_intensity)
|
||||
|
||||
frame = create_blank_frame(frame_width, frame_height, bg_color)
|
||||
|
||||
current_size = int(100 * scale)
|
||||
center_x = frame_width // 2 + shake_x
|
||||
center_y = frame_height // 2 + shake_y
|
||||
|
||||
emoji_canvas = Image.new('RGBA', (frame_width, frame_height), (0, 0, 0, 0))
|
||||
draw_emoji_enhanced(
|
||||
emoji_canvas,
|
||||
emoji=emoji,
|
||||
position=(center_x - current_size // 2, center_y - current_size // 2),
|
||||
size=current_size,
|
||||
shadow=False
|
||||
)
|
||||
|
||||
frame_rgba = frame.convert('RGBA')
|
||||
frame = Image.alpha_composite(frame_rgba, emoji_canvas)
|
||||
frame = frame.convert('RGB')
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == '__main__':
|
||||
print("Creating zoom animations...")
|
||||
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Example 1: Zoom in
|
||||
frames = create_zoom_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🔍', 'size': 100},
|
||||
num_frames=30,
|
||||
zoom_type='in',
|
||||
scale_range=(0.1, 1.5),
|
||||
easing='ease_out'
|
||||
)
|
||||
builder.add_frames(frames)
|
||||
builder.save('zoom_in.gif', num_colors=128)
|
||||
|
||||
# Example 2: Explosion zoom
|
||||
builder.clear()
|
||||
frames = create_explosion_zoom(emoji='💥', num_frames=20)
|
||||
builder.add_frames(frames)
|
||||
builder.save('zoom_explosion.gif', num_colors=128)
|
||||
|
||||
# Example 3: Mind blown
|
||||
builder.clear()
|
||||
frames = create_mind_blown_zoom(emoji='🤯', num_frames=30)
|
||||
builder.add_frames(frames)
|
||||
builder.save('zoom_mind_blown.gif', num_colors=128)
|
||||
|
||||
print("Created zoom animations!")
|
||||
Reference in New Issue
Block a user