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.
|
||||
646
skills/slack-gif-creator/SKILL.md
Normal file
646
skills/slack-gif-creator/SKILL.md
Normal file
@@ -0,0 +1,646 @@
|
||||
---
|
||||
name: slack-gif-creator
|
||||
description: Toolkit for creating animated GIFs optimized for Slack, with validators for size constraints and composable animation primitives. This skill applies when users request animated GIFs or emoji animations for Slack from descriptions like "make me a GIF for Slack of X doing Y".
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# Slack GIF Creator - Flexible Toolkit
|
||||
|
||||
A toolkit for creating animated GIFs optimized for Slack. Provides validators for Slack's constraints, composable animation primitives, and optional helper utilities. **Apply these tools however needed to achieve the creative vision.**
|
||||
|
||||
## Slack's Requirements
|
||||
|
||||
Slack has specific requirements for GIFs based on their use:
|
||||
|
||||
**Message GIFs:**
|
||||
- Max size: ~2MB
|
||||
- Optimal dimensions: 480x480
|
||||
- Typical FPS: 15-20
|
||||
- Color limit: 128-256
|
||||
- Duration: 2-5s
|
||||
|
||||
**Emoji GIFs:**
|
||||
- Max size: 64KB (strict limit)
|
||||
- Optimal dimensions: 128x128
|
||||
- Typical FPS: 10-12
|
||||
- Color limit: 32-48
|
||||
- Duration: 1-2s
|
||||
|
||||
**Emoji GIFs are challenging** - the 64KB limit is strict. Strategies that help:
|
||||
- Limit to 10-15 frames total
|
||||
- Use 32-48 colors maximum
|
||||
- Keep designs simple
|
||||
- Avoid gradients
|
||||
- Validate file size frequently
|
||||
|
||||
## Toolkit Structure
|
||||
|
||||
This skill provides three types of tools:
|
||||
|
||||
1. **Validators** - Check if a GIF meets Slack's requirements
|
||||
2. **Animation Primitives** - Composable building blocks for motion (shake, bounce, move, kaleidoscope)
|
||||
3. **Helper Utilities** - Optional functions for common needs (text, colors, effects)
|
||||
|
||||
**Complete creative freedom is available in how these tools are applied.**
|
||||
|
||||
## Core Validators
|
||||
|
||||
To ensure a GIF meets Slack's constraints, use these validators:
|
||||
|
||||
```python
|
||||
from core.gif_builder import GIFBuilder
|
||||
|
||||
# After creating your GIF, check if it meets requirements
|
||||
builder = GIFBuilder(width=128, height=128, fps=10)
|
||||
# ... add your frames however you want ...
|
||||
|
||||
# Save and check size
|
||||
info = builder.save('emoji.gif', num_colors=48, optimize_for_emoji=True)
|
||||
|
||||
# The save method automatically warns if file exceeds limits
|
||||
# info dict contains: size_kb, size_mb, frame_count, duration_seconds
|
||||
```
|
||||
|
||||
**File size validator**:
|
||||
```python
|
||||
from core.validators import check_slack_size
|
||||
|
||||
# Check if GIF meets size limits
|
||||
passes, info = check_slack_size('emoji.gif', is_emoji=True)
|
||||
# Returns: (True/False, dict with size details)
|
||||
```
|
||||
|
||||
**Dimension validator**:
|
||||
```python
|
||||
from core.validators import validate_dimensions
|
||||
|
||||
# Check dimensions
|
||||
passes, info = validate_dimensions(128, 128, is_emoji=True)
|
||||
# Returns: (True/False, dict with dimension details)
|
||||
```
|
||||
|
||||
**Complete validation**:
|
||||
```python
|
||||
from core.validators import validate_gif, is_slack_ready
|
||||
|
||||
# Run all validations
|
||||
all_pass, results = validate_gif('emoji.gif', is_emoji=True)
|
||||
|
||||
# Or quick check
|
||||
if is_slack_ready('emoji.gif', is_emoji=True):
|
||||
print("Ready to upload!")
|
||||
```
|
||||
|
||||
## Animation Primitives
|
||||
|
||||
These are composable building blocks for motion. Apply these to any object in any combination:
|
||||
|
||||
### Shake
|
||||
```python
|
||||
from templates.shake import create_shake_animation
|
||||
|
||||
# Shake an emoji
|
||||
frames = create_shake_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '😱', 'size': 80},
|
||||
num_frames=20,
|
||||
shake_intensity=15,
|
||||
direction='both' # or 'horizontal', 'vertical'
|
||||
)
|
||||
```
|
||||
|
||||
### Bounce
|
||||
```python
|
||||
from templates.bounce import create_bounce_animation
|
||||
|
||||
# Bounce a circle
|
||||
frames = create_bounce_animation(
|
||||
object_type='circle',
|
||||
object_data={'radius': 40, 'color': (255, 100, 100)},
|
||||
num_frames=30,
|
||||
bounce_height=150
|
||||
)
|
||||
```
|
||||
|
||||
### Spin / Rotate
|
||||
```python
|
||||
from templates.spin import create_spin_animation, create_loading_spinner
|
||||
|
||||
# Clockwise spin
|
||||
frames = create_spin_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🔄', 'size': 100},
|
||||
rotation_type='clockwise',
|
||||
full_rotations=2
|
||||
)
|
||||
|
||||
# Wobble rotation
|
||||
frames = create_spin_animation(rotation_type='wobble', full_rotations=3)
|
||||
|
||||
# Loading spinner
|
||||
frames = create_loading_spinner(spinner_type='dots')
|
||||
```
|
||||
|
||||
### Pulse / Heartbeat
|
||||
```python
|
||||
from templates.pulse import create_pulse_animation, create_attention_pulse
|
||||
|
||||
# Smooth pulse
|
||||
frames = create_pulse_animation(
|
||||
object_data={'emoji': '❤️', 'size': 100},
|
||||
pulse_type='smooth',
|
||||
scale_range=(0.8, 1.2)
|
||||
)
|
||||
|
||||
# Heartbeat (double-pump)
|
||||
frames = create_pulse_animation(pulse_type='heartbeat')
|
||||
|
||||
# Attention pulse for emoji GIFs
|
||||
frames = create_attention_pulse(emoji='⚠️', num_frames=20)
|
||||
```
|
||||
|
||||
### Fade
|
||||
```python
|
||||
from templates.fade import create_fade_animation, create_crossfade
|
||||
|
||||
# Fade in
|
||||
frames = create_fade_animation(fade_type='in')
|
||||
|
||||
# Fade out
|
||||
frames = create_fade_animation(fade_type='out')
|
||||
|
||||
# Crossfade between two emojis
|
||||
frames = create_crossfade(
|
||||
object1_data={'emoji': '😊', 'size': 100},
|
||||
object2_data={'emoji': '😂', 'size': 100}
|
||||
)
|
||||
```
|
||||
|
||||
### Zoom
|
||||
```python
|
||||
from templates.zoom import create_zoom_animation, create_explosion_zoom
|
||||
|
||||
# Zoom in dramatically
|
||||
frames = create_zoom_animation(
|
||||
zoom_type='in',
|
||||
scale_range=(0.1, 2.0),
|
||||
add_motion_blur=True
|
||||
)
|
||||
|
||||
# Zoom out
|
||||
frames = create_zoom_animation(zoom_type='out')
|
||||
|
||||
# Explosion zoom
|
||||
frames = create_explosion_zoom(emoji='💥')
|
||||
```
|
||||
|
||||
### Explode / Shatter
|
||||
```python
|
||||
from templates.explode import create_explode_animation, create_particle_burst
|
||||
|
||||
# Burst explosion
|
||||
frames = create_explode_animation(
|
||||
explode_type='burst',
|
||||
num_pieces=25
|
||||
)
|
||||
|
||||
# Shatter effect
|
||||
frames = create_explode_animation(explode_type='shatter')
|
||||
|
||||
# Dissolve into particles
|
||||
frames = create_explode_animation(explode_type='dissolve')
|
||||
|
||||
# Particle burst
|
||||
frames = create_particle_burst(particle_count=30)
|
||||
```
|
||||
|
||||
### Wiggle / Jiggle
|
||||
```python
|
||||
from templates.wiggle import create_wiggle_animation, create_excited_wiggle
|
||||
|
||||
# Jello wobble
|
||||
frames = create_wiggle_animation(
|
||||
wiggle_type='jello',
|
||||
intensity=1.0,
|
||||
cycles=2
|
||||
)
|
||||
|
||||
# Wave motion
|
||||
frames = create_wiggle_animation(wiggle_type='wave')
|
||||
|
||||
# Excited wiggle for emoji GIFs
|
||||
frames = create_excited_wiggle(emoji='🎉')
|
||||
```
|
||||
|
||||
### Slide
|
||||
```python
|
||||
from templates.slide import create_slide_animation, create_multi_slide
|
||||
|
||||
# Slide in from left with overshoot
|
||||
frames = create_slide_animation(
|
||||
direction='left',
|
||||
slide_type='in',
|
||||
overshoot=True
|
||||
)
|
||||
|
||||
# Slide across
|
||||
frames = create_slide_animation(direction='left', slide_type='across')
|
||||
|
||||
# Multiple objects sliding in sequence
|
||||
objects = [
|
||||
{'data': {'emoji': '🎯', 'size': 60}, 'direction': 'left', 'final_pos': (120, 240)},
|
||||
{'data': {'emoji': '🎪', 'size': 60}, 'direction': 'right', 'final_pos': (240, 240)}
|
||||
]
|
||||
frames = create_multi_slide(objects, stagger_delay=5)
|
||||
```
|
||||
|
||||
### Flip
|
||||
```python
|
||||
from templates.flip import create_flip_animation, create_quick_flip
|
||||
|
||||
# Horizontal flip between two emojis
|
||||
frames = create_flip_animation(
|
||||
object1_data={'emoji': '😊', 'size': 120},
|
||||
object2_data={'emoji': '😂', 'size': 120},
|
||||
flip_axis='horizontal'
|
||||
)
|
||||
|
||||
# Vertical flip
|
||||
frames = create_flip_animation(flip_axis='vertical')
|
||||
|
||||
# Quick flip for emoji GIFs
|
||||
frames = create_quick_flip('👍', '👎')
|
||||
```
|
||||
|
||||
### Morph / Transform
|
||||
```python
|
||||
from templates.morph import create_morph_animation, create_reaction_morph
|
||||
|
||||
# Crossfade morph
|
||||
frames = create_morph_animation(
|
||||
object1_data={'emoji': '😊', 'size': 100},
|
||||
object2_data={'emoji': '😂', 'size': 100},
|
||||
morph_type='crossfade'
|
||||
)
|
||||
|
||||
# Scale morph (shrink while other grows)
|
||||
frames = create_morph_animation(morph_type='scale')
|
||||
|
||||
# Spin morph (3D flip-like)
|
||||
frames = create_morph_animation(morph_type='spin_morph')
|
||||
```
|
||||
|
||||
### Move Effect
|
||||
```python
|
||||
from templates.move import create_move_animation
|
||||
|
||||
# Linear movement
|
||||
frames = create_move_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🚀', 'size': 60},
|
||||
start_pos=(50, 240),
|
||||
end_pos=(430, 240),
|
||||
motion_type='linear',
|
||||
easing='ease_out'
|
||||
)
|
||||
|
||||
# Arc movement (parabolic trajectory)
|
||||
frames = create_move_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '⚽', 'size': 60},
|
||||
start_pos=(50, 350),
|
||||
end_pos=(430, 350),
|
||||
motion_type='arc',
|
||||
motion_params={'arc_height': 150}
|
||||
)
|
||||
|
||||
# Circular movement
|
||||
frames = create_move_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '🌍', 'size': 50},
|
||||
motion_type='circle',
|
||||
motion_params={
|
||||
'center': (240, 240),
|
||||
'radius': 120,
|
||||
'angle_range': 360 # full circle
|
||||
}
|
||||
)
|
||||
|
||||
# Wave movement
|
||||
frames = create_move_animation(
|
||||
motion_type='wave',
|
||||
motion_params={
|
||||
'wave_amplitude': 50,
|
||||
'wave_frequency': 2
|
||||
}
|
||||
)
|
||||
|
||||
# Or use low-level easing functions
|
||||
from core.easing import interpolate, calculate_arc_motion
|
||||
|
||||
for i in range(num_frames):
|
||||
t = i / (num_frames - 1)
|
||||
x = interpolate(start_x, end_x, t, easing='ease_out')
|
||||
# Or: x, y = calculate_arc_motion(start, end, height, t)
|
||||
```
|
||||
|
||||
### Kaleidoscope Effect
|
||||
```python
|
||||
from templates.kaleidoscope import apply_kaleidoscope, create_kaleidoscope_animation
|
||||
|
||||
# Apply to a single frame
|
||||
kaleido_frame = apply_kaleidoscope(frame, segments=8)
|
||||
|
||||
# Or create animated kaleidoscope
|
||||
frames = create_kaleidoscope_animation(
|
||||
base_frame=my_frame, # or None for demo pattern
|
||||
num_frames=30,
|
||||
segments=8,
|
||||
rotation_speed=1.0
|
||||
)
|
||||
|
||||
# Simple mirror effects (faster)
|
||||
from templates.kaleidoscope import apply_simple_mirror
|
||||
|
||||
mirrored = apply_simple_mirror(frame, mode='quad') # 4-way mirror
|
||||
# modes: 'horizontal', 'vertical', 'quad', 'radial'
|
||||
```
|
||||
|
||||
**To compose primitives freely, follow these patterns:**
|
||||
```python
|
||||
# Example: Bounce + shake for impact
|
||||
for i in range(num_frames):
|
||||
frame = create_blank_frame(480, 480, bg_color)
|
||||
|
||||
# Bounce motion
|
||||
t_bounce = i / (num_frames - 1)
|
||||
y = interpolate(start_y, ground_y, t_bounce, 'bounce_out')
|
||||
|
||||
# Add shake on impact (when y reaches ground)
|
||||
if y >= ground_y - 5:
|
||||
shake_x = math.sin(i * 2) * 10
|
||||
x = center_x + shake_x
|
||||
else:
|
||||
x = center_x
|
||||
|
||||
draw_emoji(frame, '⚽', (x, y), size=60)
|
||||
builder.add_frame(frame)
|
||||
```
|
||||
|
||||
## Helper Utilities
|
||||
|
||||
These are optional helpers for common needs. **Use, modify, or replace these with custom implementations as needed.**
|
||||
|
||||
### GIF Builder (Assembly & Optimization)
|
||||
|
||||
```python
|
||||
from core.gif_builder import GIFBuilder
|
||||
|
||||
# Create builder with your chosen settings
|
||||
builder = GIFBuilder(width=480, height=480, fps=20)
|
||||
|
||||
# Add frames (however you created them)
|
||||
for frame in my_frames:
|
||||
builder.add_frame(frame)
|
||||
|
||||
# Save with optimization
|
||||
builder.save('output.gif',
|
||||
num_colors=128,
|
||||
optimize_for_emoji=False)
|
||||
```
|
||||
|
||||
Key features:
|
||||
- Automatic color quantization
|
||||
- Duplicate frame removal
|
||||
- Size warnings for Slack limits
|
||||
- Emoji mode (aggressive optimization)
|
||||
|
||||
### Text Rendering
|
||||
|
||||
For small GIFs like emojis, text readability is challenging. A common solution involves adding outlines:
|
||||
|
||||
```python
|
||||
from core.typography import draw_text_with_outline, TYPOGRAPHY_SCALE
|
||||
|
||||
# Text with outline (helps readability)
|
||||
draw_text_with_outline(
|
||||
frame, "BONK!",
|
||||
position=(240, 100),
|
||||
font_size=TYPOGRAPHY_SCALE['h1'], # 60px
|
||||
text_color=(255, 68, 68),
|
||||
outline_color=(0, 0, 0),
|
||||
outline_width=4,
|
||||
centered=True
|
||||
)
|
||||
```
|
||||
|
||||
To implement custom text rendering, use PIL's `ImageDraw.text()` which works fine for larger GIFs.
|
||||
|
||||
### Color Management
|
||||
|
||||
Professional-looking GIFs often use cohesive color palettes:
|
||||
|
||||
```python
|
||||
from core.color_palettes import get_palette
|
||||
|
||||
# Get a pre-made palette
|
||||
palette = get_palette('vibrant') # or 'pastel', 'dark', 'neon', 'professional'
|
||||
|
||||
bg_color = palette['background']
|
||||
text_color = palette['primary']
|
||||
accent_color = palette['accent']
|
||||
```
|
||||
|
||||
To work with colors directly, use RGB tuples - whatever works for the use case.
|
||||
|
||||
### Visual Effects
|
||||
|
||||
Optional effects for impact moments:
|
||||
|
||||
```python
|
||||
from core.visual_effects import ParticleSystem, create_impact_flash, create_shockwave_rings
|
||||
|
||||
# Particle system
|
||||
particles = ParticleSystem()
|
||||
particles.emit_sparkles(x=240, y=200, count=15)
|
||||
particles.emit_confetti(x=240, y=200, count=20)
|
||||
|
||||
# Update and render each frame
|
||||
particles.update()
|
||||
particles.render(frame)
|
||||
|
||||
# Flash effect
|
||||
frame = create_impact_flash(frame, position=(240, 200), radius=100)
|
||||
|
||||
# Shockwave rings
|
||||
frame = create_shockwave_rings(frame, position=(240, 200), radii=[30, 60, 90])
|
||||
```
|
||||
|
||||
### Easing Functions
|
||||
|
||||
Smooth motion uses easing instead of linear interpolation:
|
||||
|
||||
```python
|
||||
from core.easing import interpolate
|
||||
|
||||
# Object falling (accelerates)
|
||||
y = interpolate(start=0, end=400, t=progress, easing='ease_in')
|
||||
|
||||
# Object landing (decelerates)
|
||||
y = interpolate(start=0, end=400, t=progress, easing='ease_out')
|
||||
|
||||
# Bouncing
|
||||
y = interpolate(start=0, end=400, t=progress, easing='bounce_out')
|
||||
|
||||
# Overshoot (elastic)
|
||||
scale = interpolate(start=0.5, end=1.0, t=progress, easing='elastic_out')
|
||||
```
|
||||
|
||||
Available easings: `linear`, `ease_in`, `ease_out`, `ease_in_out`, `bounce_out`, `elastic_out`, `back_out` (overshoot), and more in `core/easing.py`.
|
||||
|
||||
### Frame Composition
|
||||
|
||||
Basic drawing utilities if you need them:
|
||||
|
||||
```python
|
||||
from core.frame_composer import (
|
||||
create_gradient_background, # Gradient backgrounds
|
||||
draw_emoji_enhanced, # Emoji with optional shadow
|
||||
draw_circle_with_shadow, # Shapes with depth
|
||||
draw_star # 5-pointed stars
|
||||
)
|
||||
|
||||
# Gradient background
|
||||
frame = create_gradient_background(480, 480, top_color, bottom_color)
|
||||
|
||||
# Emoji with shadow
|
||||
draw_emoji_enhanced(frame, '🎉', position=(200, 200), size=80, shadow=True)
|
||||
```
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
When your GIF is too large:
|
||||
|
||||
**For Message GIFs (>2MB):**
|
||||
1. Reduce frames (lower FPS or shorter duration)
|
||||
2. Reduce colors (128 → 64 colors)
|
||||
3. Reduce dimensions (480x480 → 320x320)
|
||||
4. Enable duplicate frame removal
|
||||
|
||||
**For Emoji GIFs (>64KB) - be aggressive:**
|
||||
1. Limit to 10-12 frames total
|
||||
2. Use 32-40 colors maximum
|
||||
3. Avoid gradients (solid colors compress better)
|
||||
4. Simplify design (fewer elements)
|
||||
5. Use `optimize_for_emoji=True` in save method
|
||||
|
||||
## Example Composition Patterns
|
||||
|
||||
### Simple Reaction (Pulsing)
|
||||
```python
|
||||
builder = GIFBuilder(128, 128, 10)
|
||||
|
||||
for i in range(12):
|
||||
frame = Image.new('RGB', (128, 128), (240, 248, 255))
|
||||
|
||||
# Pulsing scale
|
||||
scale = 1.0 + math.sin(i * 0.5) * 0.15
|
||||
size = int(60 * scale)
|
||||
|
||||
draw_emoji_enhanced(frame, '😱', position=(64-size//2, 64-size//2),
|
||||
size=size, shadow=False)
|
||||
builder.add_frame(frame)
|
||||
|
||||
builder.save('reaction.gif', num_colors=40, optimize_for_emoji=True)
|
||||
|
||||
# Validate
|
||||
from core.validators import check_slack_size
|
||||
check_slack_size('reaction.gif', is_emoji=True)
|
||||
```
|
||||
|
||||
### Action with Impact (Bounce + Flash)
|
||||
```python
|
||||
builder = GIFBuilder(480, 480, 20)
|
||||
|
||||
# Phase 1: Object falls
|
||||
for i in range(15):
|
||||
frame = create_gradient_background(480, 480, (240, 248, 255), (200, 230, 255))
|
||||
t = i / 14
|
||||
y = interpolate(0, 350, t, 'ease_in')
|
||||
draw_emoji_enhanced(frame, '⚽', position=(220, int(y)), size=80)
|
||||
builder.add_frame(frame)
|
||||
|
||||
# Phase 2: Impact + flash
|
||||
for i in range(8):
|
||||
frame = create_gradient_background(480, 480, (240, 248, 255), (200, 230, 255))
|
||||
|
||||
# Flash on first frames
|
||||
if i < 3:
|
||||
frame = create_impact_flash(frame, (240, 350), radius=120, intensity=0.6)
|
||||
|
||||
draw_emoji_enhanced(frame, '⚽', position=(220, 350), size=80)
|
||||
|
||||
# Text appears
|
||||
if i > 2:
|
||||
draw_text_with_outline(frame, "GOAL!", position=(240, 150),
|
||||
font_size=60, text_color=(255, 68, 68),
|
||||
outline_color=(0, 0, 0), outline_width=4, centered=True)
|
||||
|
||||
builder.add_frame(frame)
|
||||
|
||||
builder.save('goal.gif', num_colors=128)
|
||||
```
|
||||
|
||||
### Combining Primitives (Move + Shake)
|
||||
```python
|
||||
from templates.shake import create_shake_animation
|
||||
|
||||
# Create shake animation
|
||||
shake_frames = create_shake_animation(
|
||||
object_type='emoji',
|
||||
object_data={'emoji': '😰', 'size': 70},
|
||||
num_frames=20,
|
||||
shake_intensity=12
|
||||
)
|
||||
|
||||
# Create moving element that triggers the shake
|
||||
builder = GIFBuilder(480, 480, 20)
|
||||
for i in range(40):
|
||||
t = i / 39
|
||||
|
||||
if i < 20:
|
||||
# Before trigger - use blank frame with moving object
|
||||
frame = create_blank_frame(480, 480, (255, 255, 255))
|
||||
x = interpolate(50, 300, t * 2, 'linear')
|
||||
draw_emoji_enhanced(frame, '🚗', position=(int(x), 300), size=60)
|
||||
draw_emoji_enhanced(frame, '😰', position=(350, 200), size=70)
|
||||
else:
|
||||
# After trigger - use shake frame
|
||||
frame = shake_frames[i - 20]
|
||||
# Add the car in final position
|
||||
draw_emoji_enhanced(frame, '🚗', position=(300, 300), size=60)
|
||||
|
||||
builder.add_frame(frame)
|
||||
|
||||
builder.save('scare.gif')
|
||||
```
|
||||
|
||||
## Philosophy
|
||||
|
||||
This toolkit provides building blocks, not rigid recipes. To work with a GIF request:
|
||||
|
||||
1. **Understand the creative vision** - What should happen? What's the mood?
|
||||
2. **Design the animation** - Break it into phases (anticipation, action, reaction)
|
||||
3. **Apply primitives as needed** - Shake, bounce, move, effects - mix freely
|
||||
4. **Validate constraints** - Check file size, especially for emoji GIFs
|
||||
5. **Iterate if needed** - Reduce frames/colors if over size limits
|
||||
|
||||
**The goal is creative freedom within Slack's technical constraints.**
|
||||
|
||||
## Dependencies
|
||||
|
||||
To use this toolkit, install these dependencies only if they aren't already present:
|
||||
|
||||
```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'])
|
||||
230
skills/slack-gif-creator/core/easing.py
Executable file
230
skills/slack-gif-creator/core/easing.py
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/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
|
||||
})
|
||||
469
skills/slack-gif-creator/core/frame_composer.py
Executable file
469
skills/slack-gif-creator/core/frame_composer.py
Executable file
@@ -0,0 +1,469 @@
|
||||
#!/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 PIL import Image, ImageDraw, ImageFont
|
||||
import numpy as np
|
||||
from typing import Optional
|
||||
|
||||
|
||||
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_rectangle(frame: Image.Image, top_left: tuple[int, int], bottom_right: tuple[int, 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 rectangle on a frame.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
top_left: (x, y) top-left corner
|
||||
bottom_right: (x, y) bottom-right corner
|
||||
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)
|
||||
draw.rectangle([top_left, bottom_right], fill=fill_color, outline=outline_color, width=outline_width)
|
||||
return frame
|
||||
|
||||
|
||||
def draw_line(frame: Image.Image, start: tuple[int, int], end: tuple[int, int],
|
||||
color: tuple[int, int, int] = (0, 0, 0), width: int = 2) -> Image.Image:
|
||||
"""
|
||||
Draw a line on a frame.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
start: (x, y) start position
|
||||
end: (x, y) end position
|
||||
color: RGB line color
|
||||
width: Line width in pixels
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
draw.line([start, end], fill=color, width=width)
|
||||
return frame
|
||||
|
||||
|
||||
def draw_text(frame: Image.Image, text: str, position: tuple[int, int],
|
||||
font_size: int = 40, 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)
|
||||
font_size: Font size in pixels
|
||||
color: RGB text color
|
||||
centered: If True, center text at position
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Try to use default font, fall back to basic if not available
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
|
||||
except:
|
||||
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 draw_emoji(frame: Image.Image, emoji: str, position: tuple[int, int], size: int = 60) -> Image.Image:
|
||||
"""
|
||||
Draw emoji text on a frame (requires system emoji support).
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
emoji: Emoji character(s)
|
||||
position: (x, y) position
|
||||
size: Emoji size in pixels
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Use Apple Color Emoji font on macOS
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
|
||||
except:
|
||||
# Fallback to text-based emoji
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
|
||||
|
||||
draw.text(position, emoji, font=font, embedded_color=True)
|
||||
return frame
|
||||
|
||||
|
||||
def composite_layers(base: Image.Image, overlay: Image.Image,
|
||||
position: tuple[int, int] = (0, 0), alpha: float = 1.0) -> Image.Image:
|
||||
"""
|
||||
Composite one image on top of another.
|
||||
|
||||
Args:
|
||||
base: Base image
|
||||
overlay: Image to overlay on top
|
||||
position: (x, y) position to place overlay
|
||||
alpha: Opacity of overlay (0.0 = transparent, 1.0 = opaque)
|
||||
|
||||
Returns:
|
||||
Composite image
|
||||
"""
|
||||
# Convert to RGBA for transparency support
|
||||
base_rgba = base.convert('RGBA')
|
||||
overlay_rgba = overlay.convert('RGBA')
|
||||
|
||||
# Apply alpha
|
||||
if alpha < 1.0:
|
||||
overlay_rgba = overlay_rgba.copy()
|
||||
overlay_rgba.putalpha(int(255 * alpha))
|
||||
|
||||
# Paste overlay onto base
|
||||
base_rgba.paste(overlay_rgba, position, overlay_rgba)
|
||||
|
||||
# Convert back to RGB
|
||||
return base_rgba.convert('RGB')
|
||||
|
||||
|
||||
def draw_stick_figure(frame: Image.Image, position: tuple[int, int], scale: float = 1.0,
|
||||
color: tuple[int, int, int] = (0, 0, 0), line_width: int = 3) -> Image.Image:
|
||||
"""
|
||||
Draw a simple stick figure.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
position: (x, y) center position of head
|
||||
scale: Size multiplier
|
||||
color: RGB line color
|
||||
line_width: Line width in pixels
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x, y = position
|
||||
|
||||
# Scale dimensions
|
||||
head_radius = int(15 * scale)
|
||||
body_length = int(40 * scale)
|
||||
arm_length = int(25 * scale)
|
||||
leg_length = int(35 * scale)
|
||||
leg_spread = int(15 * scale)
|
||||
|
||||
# Head
|
||||
draw.ellipse([x - head_radius, y - head_radius, x + head_radius, y + head_radius],
|
||||
outline=color, width=line_width)
|
||||
|
||||
# Body
|
||||
body_start = y + head_radius
|
||||
body_end = body_start + body_length
|
||||
draw.line([(x, body_start), (x, body_end)], fill=color, width=line_width)
|
||||
|
||||
# Arms
|
||||
arm_y = body_start + int(body_length * 0.3)
|
||||
draw.line([(x - arm_length, arm_y), (x + arm_length, arm_y)], fill=color, width=line_width)
|
||||
|
||||
# Legs
|
||||
draw.line([(x, body_end), (x - leg_spread, body_end + leg_length)], fill=color, width=line_width)
|
||||
draw.line([(x, body_end), (x + leg_spread, body_end + leg_length)], fill=color, width=line_width)
|
||||
|
||||
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_emoji_enhanced(frame: Image.Image, emoji: str, position: tuple[int, int],
|
||||
size: int = 60, shadow: bool = True,
|
||||
shadow_offset: tuple[int, int] = (2, 2)) -> Image.Image:
|
||||
"""
|
||||
Draw emoji with optional shadow for better visual quality.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
emoji: Emoji character(s)
|
||||
position: (x, y) position
|
||||
size: Emoji size in pixels (minimum 12)
|
||||
shadow: Whether to add drop shadow
|
||||
shadow_offset: Shadow offset
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
# Ensure minimum size to avoid font rendering errors
|
||||
size = max(12, size)
|
||||
|
||||
# Use Apple Color Emoji font on macOS
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Apple Color Emoji.ttc", size)
|
||||
except:
|
||||
# Fallback to text-based emoji
|
||||
try:
|
||||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", size)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Draw shadow first if enabled
|
||||
if shadow and size >= 20: # Only draw shadow for larger emojis
|
||||
shadow_pos = (position[0] + shadow_offset[0], position[1] + shadow_offset[1])
|
||||
# Draw semi-transparent shadow (simulated by drawing multiple times)
|
||||
for offset in range(1, 3):
|
||||
try:
|
||||
draw.text((shadow_pos[0] + offset, shadow_pos[1] + offset),
|
||||
emoji, font=font, embedded_color=True, fill=(0, 0, 0, 100))
|
||||
except:
|
||||
pass # Skip shadow if it fails
|
||||
|
||||
# Draw main emoji
|
||||
try:
|
||||
draw.text(position, emoji, font=font, embedded_color=True)
|
||||
except:
|
||||
# Fallback to basic drawing if embedded color fails
|
||||
draw.text(position, emoji, font=font, fill=(0, 0, 0))
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def draw_circle_with_shadow(frame: Image.Image, center: tuple[int, int], radius: int,
|
||||
fill_color: tuple[int, int, int],
|
||||
shadow_offset: tuple[int, int] = (3, 3),
|
||||
shadow_color: tuple[int, int, int] = (0, 0, 0)) -> Image.Image:
|
||||
"""
|
||||
Draw a circle with drop shadow.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
center: (x, y) center position
|
||||
radius: Circle radius
|
||||
fill_color: RGB fill color
|
||||
shadow_offset: (x, y) shadow offset
|
||||
shadow_color: RGB shadow color
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x, y = center
|
||||
|
||||
# Draw shadow
|
||||
shadow_center = (x + shadow_offset[0], y + shadow_offset[1])
|
||||
shadow_bbox = [
|
||||
shadow_center[0] - radius,
|
||||
shadow_center[1] - radius,
|
||||
shadow_center[0] + radius,
|
||||
shadow_center[1] + radius
|
||||
]
|
||||
draw.ellipse(shadow_bbox, fill=shadow_color)
|
||||
|
||||
# Draw main circle
|
||||
bbox = [x - radius, y - radius, x + radius, y + radius]
|
||||
draw.ellipse(bbox, fill=fill_color)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def draw_rounded_rectangle(frame: Image.Image, top_left: tuple[int, int],
|
||||
bottom_right: 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 rectangle with rounded corners.
|
||||
|
||||
Args:
|
||||
frame: PIL Image to draw on
|
||||
top_left: (x, y) top-left corner
|
||||
bottom_right: (x, y) bottom-right corner
|
||||
radius: Corner radius
|
||||
fill_color: RGB fill color (None for no fill)
|
||||
outline_color: RGB outline color (None for no outline)
|
||||
outline_width: Outline width
|
||||
|
||||
Returns:
|
||||
Modified frame
|
||||
"""
|
||||
draw = ImageDraw.Draw(frame)
|
||||
x1, y1 = top_left
|
||||
x2, y2 = bottom_right
|
||||
|
||||
# Draw rounded rectangle using PIL's built-in method
|
||||
draw.rounded_rectangle([x1, y1, x2, y2], radius=radius,
|
||||
fill=fill_color, outline=outline_color, width=outline_width)
|
||||
|
||||
return frame
|
||||
|
||||
|
||||
def add_vignette(frame: Image.Image, strength: float = 0.5) -> Image.Image:
|
||||
"""
|
||||
Add a vignette effect (darkened edges) to frame.
|
||||
|
||||
Args:
|
||||
frame: PIL Image
|
||||
strength: Vignette strength (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Frame with vignette
|
||||
"""
|
||||
width, height = frame.size
|
||||
|
||||
# Create radial gradient mask
|
||||
center_x, center_y = width // 2, height // 2
|
||||
max_dist = ((width / 2) ** 2 + (height / 2) ** 2) ** 0.5
|
||||
|
||||
# Create overlay
|
||||
overlay = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
pixels = overlay.load()
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
# Calculate distance from center
|
||||
dx = x - center_x
|
||||
dy = y - center_y
|
||||
dist = (dx ** 2 + dy ** 2) ** 0.5
|
||||
|
||||
# Calculate vignette value
|
||||
vignette = min(1, (dist / max_dist) * strength)
|
||||
value = int(255 * (1 - vignette))
|
||||
pixels[x, y] = (value, value, value)
|
||||
|
||||
# Blend with original using multiply
|
||||
frame_array = np.array(frame, dtype=np.float32) / 255
|
||||
overlay_array = np.array(overlay, dtype=np.float32) / 255
|
||||
|
||||
result = frame_array * overlay_array
|
||||
result = (result * 255).astype(np.uint8)
|
||||
|
||||
return Image.fromarray(result)
|
||||
|
||||
|
||||
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
|
||||
246
skills/slack-gif-creator/core/gif_builder.py
Executable file
246
skills/slack-gif-creator/core/gif_builder.py
Executable file
@@ -0,0 +1,246 @@
|
||||
#!/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
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
|
||||
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.995) -> int:
|
||||
"""
|
||||
Remove duplicate or near-duplicate consecutive frames.
|
||||
|
||||
Args:
|
||||
threshold: Similarity threshold (0.0-1.0). Higher = more strict (0.995 = very similar).
|
||||
|
||||
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.995) means only remove truly 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 = True) -> 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 <64KB emoji size
|
||||
remove_duplicates: Remove duplicate consecutive frames
|
||||
|
||||
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)
|
||||
original_frame_count = len(self.frames)
|
||||
|
||||
# Remove duplicate frames to reduce file size
|
||||
if remove_duplicates:
|
||||
removed = self.deduplicate_frames(threshold=0.98)
|
||||
if removed > 0:
|
||||
print(f" Removed {removed} duplicate frames")
|
||||
|
||||
# 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}")
|
||||
|
||||
# Warnings
|
||||
if optimize_for_emoji and file_size_kb > 64:
|
||||
print(f"\n⚠️ WARNING: Emoji file size ({file_size_kb:.1f} KB) exceeds 64 KB limit")
|
||||
print(" Try: fewer frames, fewer colors, or simpler design")
|
||||
elif not optimize_for_emoji and file_size_kb > 2048:
|
||||
print(f"\n⚠️ WARNING: File size ({file_size_kb:.1f} KB) is large for Slack")
|
||||
print(" Try: 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))
|
||||
264
skills/slack-gif-creator/core/validators.py
Executable file
264
skills/slack-gif-creator/core/validators.py
Executable file
@@ -0,0 +1,264 @@
|
||||
#!/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 check_slack_size(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dict]:
|
||||
"""
|
||||
Check if GIF meets Slack size limits.
|
||||
|
||||
Args:
|
||||
gif_path: Path to GIF file
|
||||
is_emoji: True for emoji GIF (64KB limit), False for message GIF (2MB limit)
|
||||
|
||||
Returns:
|
||||
Tuple of (passes: bool, info: dict with details)
|
||||
"""
|
||||
gif_path = Path(gif_path)
|
||||
|
||||
if not gif_path.exists():
|
||||
return False, {'error': f'File not found: {gif_path}'}
|
||||
|
||||
size_bytes = gif_path.stat().st_size
|
||||
size_kb = size_bytes / 1024
|
||||
size_mb = size_kb / 1024
|
||||
|
||||
limit_kb = 64 if is_emoji else 2048
|
||||
limit_mb = limit_kb / 1024
|
||||
|
||||
passes = size_kb <= limit_kb
|
||||
|
||||
info = {
|
||||
'size_bytes': size_bytes,
|
||||
'size_kb': size_kb,
|
||||
'size_mb': size_mb,
|
||||
'limit_kb': limit_kb,
|
||||
'limit_mb': limit_mb,
|
||||
'passes': passes,
|
||||
'type': 'emoji' if is_emoji else 'message'
|
||||
}
|
||||
|
||||
# Print feedback
|
||||
if passes:
|
||||
print(f"✓ {size_kb:.1f} KB - within {limit_kb} KB limit")
|
||||
else:
|
||||
print(f"✗ {size_kb:.1f} KB - exceeds {limit_kb} KB limit")
|
||||
overage_kb = size_kb - limit_kb
|
||||
overage_percent = (overage_kb / limit_kb) * 100
|
||||
print(f" Over by: {overage_kb:.1f} KB ({overage_percent:.1f}%)")
|
||||
print(f" Try: fewer frames, fewer colors, or simpler design")
|
||||
|
||||
return passes, info
|
||||
|
||||
|
||||
def validate_dimensions(width: int, height: int, is_emoji: bool = True) -> tuple[bool, dict]:
|
||||
"""
|
||||
Check if dimensions are suitable for Slack.
|
||||
|
||||
Args:
|
||||
width: Frame width in pixels
|
||||
height: Frame height in pixels
|
||||
is_emoji: True for emoji GIF, False for message GIF
|
||||
|
||||
Returns:
|
||||
Tuple of (passes: bool, info: dict with details)
|
||||
"""
|
||||
info = {
|
||||
'width': width,
|
||||
'height': height,
|
||||
'is_square': width == height,
|
||||
'type': 'emoji' if is_emoji else 'message'
|
||||
}
|
||||
|
||||
if is_emoji:
|
||||
# Emoji GIFs should be 128x128
|
||||
optimal = width == height == 128
|
||||
acceptable = width == height and 64 <= width <= 128
|
||||
|
||||
info['optimal'] = optimal
|
||||
info['acceptable'] = acceptable
|
||||
|
||||
if optimal:
|
||||
print(f"✓ {width}x{height} - optimal for emoji")
|
||||
passes = True
|
||||
elif acceptable:
|
||||
print(f"⚠ {width}x{height} - acceptable but 128x128 is optimal")
|
||||
passes = True
|
||||
else:
|
||||
print(f"✗ {width}x{height} - emoji should be square, 128x128 recommended")
|
||||
passes = False
|
||||
else:
|
||||
# Message GIFs should be square-ish and reasonable size
|
||||
aspect_ratio = max(width, height) / min(width, height) if min(width, height) > 0 else float('inf')
|
||||
reasonable_size = 320 <= min(width, height) <= 640
|
||||
|
||||
info['aspect_ratio'] = aspect_ratio
|
||||
info['reasonable_size'] = reasonable_size
|
||||
|
||||
# Check if roughly square (within 2:1 ratio)
|
||||
is_square_ish = aspect_ratio <= 2.0
|
||||
|
||||
if is_square_ish and reasonable_size:
|
||||
print(f"✓ {width}x{height} - good for message GIF")
|
||||
passes = True
|
||||
elif is_square_ish:
|
||||
print(f"⚠ {width}x{height} - square-ish but unusual size")
|
||||
passes = True
|
||||
elif reasonable_size:
|
||||
print(f"⚠ {width}x{height} - good size but not square-ish")
|
||||
passes = True
|
||||
else:
|
||||
print(f"✗ {width}x{height} - unusual dimensions for Slack")
|
||||
passes = False
|
||||
|
||||
return passes, info
|
||||
|
||||
|
||||
def validate_gif(gif_path: str | Path, is_emoji: bool = True) -> tuple[bool, dict]:
|
||||
"""
|
||||
Run all validations on a GIF file.
|
||||
|
||||
Args:
|
||||
gif_path: Path to GIF file
|
||||
is_emoji: True for emoji GIF, False for message GIF
|
||||
|
||||
Returns:
|
||||
Tuple of (all_pass: bool, results: dict)
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
gif_path = Path(gif_path)
|
||||
|
||||
if not gif_path.exists():
|
||||
return False, {'error': f'File not found: {gif_path}'}
|
||||
|
||||
print(f"\nValidating {gif_path.name} as {'emoji' if is_emoji else 'message'} GIF:")
|
||||
print("=" * 60)
|
||||
|
||||
# Check file size
|
||||
size_pass, size_info = check_slack_size(gif_path, is_emoji)
|
||||
|
||||
# Check dimensions
|
||||
try:
|
||||
with Image.open(gif_path) as img:
|
||||
width, height = img.size
|
||||
dim_pass, dim_info = validate_dimensions(width, height, is_emoji)
|
||||
|
||||
# Count frames
|
||||
frame_count = 0
|
||||
try:
|
||||
while True:
|
||||
img.seek(frame_count)
|
||||
frame_count += 1
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
# Get duration if available
|
||||
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:
|
||||
duration_ms = None
|
||||
total_duration = None
|
||||
fps = None
|
||||
|
||||
except Exception as e:
|
||||
return False, {'error': f'Failed to read GIF: {e}'}
|
||||
|
||||
print(f"\nFrames: {frame_count}")
|
||||
if total_duration:
|
||||
print(f"Duration: {total_duration:.1f}s @ {fps:.1f} fps")
|
||||
|
||||
all_pass = size_pass and dim_pass
|
||||
|
||||
results = {
|
||||
'file': str(gif_path),
|
||||
'passes': all_pass,
|
||||
'size': size_info,
|
||||
'dimensions': dim_info,
|
||||
'frame_count': frame_count,
|
||||
'duration_seconds': total_duration,
|
||||
'fps': fps
|
||||
}
|
||||
|
||||
print("=" * 60)
|
||||
if all_pass:
|
||||
print("✓ All validations passed!")
|
||||
else:
|
||||
print("✗ Some validations failed")
|
||||
print()
|
||||
|
||||
return all_pass, results
|
||||
|
||||
|
||||
def get_optimization_suggestions(results: dict) -> list[str]:
|
||||
"""
|
||||
Get suggestions for optimizing a GIF based on validation results.
|
||||
|
||||
Args:
|
||||
results: Results dict from validate_gif()
|
||||
|
||||
Returns:
|
||||
List of suggestion strings
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
if not results.get('passes', False):
|
||||
size_info = results.get('size', {})
|
||||
dim_info = results.get('dimensions', {})
|
||||
|
||||
# Size suggestions
|
||||
if not size_info.get('passes', True):
|
||||
overage = size_info['size_kb'] - size_info['limit_kb']
|
||||
if size_info['type'] == 'emoji':
|
||||
suggestions.append(f"Reduce file size by {overage:.1f} KB:")
|
||||
suggestions.append(" - Limit to 10-12 frames")
|
||||
suggestions.append(" - Use 32-40 colors maximum")
|
||||
suggestions.append(" - Remove gradients (solid colors compress better)")
|
||||
suggestions.append(" - Simplify design")
|
||||
else:
|
||||
suggestions.append(f"Reduce file size by {overage:.1f} KB:")
|
||||
suggestions.append(" - Reduce frame count or FPS")
|
||||
suggestions.append(" - Use fewer colors (128 → 64)")
|
||||
suggestions.append(" - Reduce dimensions")
|
||||
|
||||
# Dimension suggestions
|
||||
if not dim_info.get('optimal', True) and dim_info.get('type') == 'emoji':
|
||||
suggestions.append("For optimal emoji GIF:")
|
||||
suggestions.append(" - Use 128x128 dimensions")
|
||||
suggestions.append(" - Ensure square aspect ratio")
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
# Convenience function for quick checks
|
||||
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 detailed feedback
|
||||
|
||||
Returns:
|
||||
True if ready, False otherwise
|
||||
"""
|
||||
if verbose:
|
||||
passes, results = validate_gif(gif_path, is_emoji)
|
||||
if not passes:
|
||||
suggestions = get_optimization_suggestions(results)
|
||||
if suggestions:
|
||||
print("\nSuggestions:")
|
||||
for suggestion in suggestions:
|
||||
print(suggestion)
|
||||
return passes
|
||||
else:
|
||||
size_pass, _ = check_slack_size(gif_path, is_emoji)
|
||||
return size_pass
|
||||
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