
Wedding Immortalist
- 123 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
Craft enduring wedding narratives, keepsake copy, and memory-focused creative assets for couples, planners, or memorial-style celebration brands.
About
Wedding-immortalist helps produce commemorative wedding storytelling, keepsake messaging, and memory-centered creative direction for brands, planners, or content products serving couples and celebration audiences.
- Keepsake narrative framing
- Emotional brand voice
- Ceremony and vow copy
- Memory-preservation themes
- Planner-ready creative briefs
Wedding Immortalist by the numbers
- 123 all-time installs (skills.sh)
- Ranked #766 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill wedding-immortalistAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 123 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
What it does
Craft enduring wedding narratives, keepsake copy, and memory-focused creative assets for couples, planners, or memorial-style celebration brands.
Files
Wedding Immortalist
Transform wedding photos and video into an eternal, immersive 3D experience. Create living memories that let couples and guests relive the magic forever.
When to Use This Skill
Use for:
- Processing thousands of wedding photos into 3DGS scenes
- Creating theatre-mode experiences where ceremony/reception moments play in-place
- Building face-clustered guest rosters with best-photo selection
- Matching design aesthetics to wedding themes (disco, rustic, beach, modern, queer celebrations)
- AI-curated photo selection per guest with aesthetic scoring
NOT for:
- General photo editing → use native-app-designer
- Non-wedding 3DGS → use drone-inspection-specialist
- Event planning → not a wedding planner
- Video editing without 3D reconstruction
Core Pipeline
┌─────────────────────────────────────────────────────────────────┐
│ WEDDING IMMORTALIST PIPELINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. INGEST 2. RECONSTRUCT 3. CLUSTER │
│ ├─ Photos (1000s) ├─ COLMAP SfM ├─ Face detect │
│ ├─ Video (hours) ├─ 3DGS training ├─ Embeddings │
│ └─ Audio/speeches └─ Scene merge └─ Identity link │
│ │
│ 4. CURATE 5. DESIGN 6. PRESENT │
│ ├─ Aesthetic score ├─ Theme extract ├─ Web viewer │
│ ├─ Per-person best ├─ Color palette ├─ Theatre mode │
│ └─ Moment detect └─ Typography └─ Guest roster │
│ │
└─────────────────────────────────────────────────────────────────┘Theme-Adaptive Design
Theme Detection & Matching
Every wedding has a unique aesthetic. Extract and honor it:
| Theme Type | Color Palette | Typography | UI Elements |
|---|---|---|---|
| 70s Disco | Gold, orange, burnt sienna, deep purple | Groovy script, bold sans | Mirror balls, starbursts, warm gradients |
| Rustic/Barn | Earth tones, sage, cream, wood | Serif, hand-lettered | Burlap textures, wildflower accents |
| Beach/Coastal | Ocean blues, sand, coral, seafoam | Light sans, script | Shell motifs, wave patterns |
| Modern Minimal | Black, white, metallics | Clean geometric sans | Sharp lines, negative space |
| Queer Joy | Rainbow spectrums, bold colors | Expressive, varied | Pride elements, celebration maximalism |
| Cultural Fusion | Per tradition | Traditional + modern | Cultural motifs, heritage patterns |
Extracting Theme from Photos
# Theme extraction signals
THEME_SIGNALS = {
'color_palette': 'Dominant colors from venue, florals, attire',
'lighting_mood': 'Warm/cool, natural/dramatic, string lights/chandeliers',
'decor_elements': 'Rustic/modern/vintage/eclectic',
'attire_style': 'Traditional/non-traditional, formal/casual',
'cultural_markers': 'Religious symbols, cultural traditions',
'era_aesthetic': '70s disco, 20s gatsby, etc.'
}3D Gaussian Splatting Pipeline
Photo/Video Ingestion
Optimal Input Strategy:
├── Video: Extract 2-3 fps (80% overlap minimum)
├── Photos: Include ALL photographer shots
├── Phone photos: Guest uploads (georeferenced bonus)
└── Coverage: Ceremony + reception + all spaces
Quality Thresholds:
├── Minimum images per space: 50-100
├── Overlap requirement: 60-80%
├── Blur rejection: Laplacian variance < 100 = skip
└── Exposure: Reject severe over/underexposureCOLMAP Structure from Motion
# Feature extraction
colmap feature_extractor \
--database_path database.db \
--image_path images/ \
--ImageReader.single_camera 0 \
--SiftExtraction.max_image_size 3200
# Exhaustive matching for comprehensive coverage
colmap exhaustive_matcher \
--database_path database.db \
--SiftMatching.guided_matching 1
# Sparse reconstruction
colmap mapper \
--database_path database.db \
--image_path images/ \
--output_path sparse/
# Dense reconstruction (optional, for mesh)
colmap image_undistorter ...
colmap patch_match_stereo ...3DGS Training
# Wedding-optimized 3DGS settings
WEDDING_3DGS_CONFIG = {
'iterations': 50_000, # High quality for permanent archive
'densify_from_iter': 500,
'densify_until_iter': 15_000,
'densification_interval': 100,
'opacity_reset_interval': 3000,
'sh_degree': 3, # Full spherical harmonics for lighting
'percent_dense': 0.01,
'densify_grad_threshold': 0.0002,
}
# Multi-space merge strategy
SPACES = ['ceremony', 'cocktail_hour', 'reception', 'photo_booth', 'dance_floor']
# Train each separately, then create unified navigationFace Clustering System
Pipeline
┌────────────────────────────────────────────────────────┐
│ FACE CLUSTERING PIPELINE │
├────────────────────────────────────────────────────────┤
│ 1. Detection (RetinaFace/MTCNN) │
│ └─ All faces in all photos │
│ 2. Alignment (5-point landmark) │
│ └─ Standardize for embedding │
│ 3. Embedding (ArcFace/AdaFace) │
│ └─ 512-dim identity vector per face │
│ 4. Clustering (HDBSCAN) │
│ └─ Group by identity, handle edge cases │
│ 5. Identity Linking │
│ └─ Match to couple, wedding party, family, guests │
│ 6. Best Photo Selection │
│ └─ Aesthetic scoring per cluster │
└────────────────────────────────────────────────────────┘Clustering Parameters
CLUSTERING_CONFIG = {
'min_cluster_size': 3, # At least 3 photos to form identity
'min_samples': 2,
'metric': 'cosine',
'cluster_selection_epsilon': 0.3,
'cluster_selection_method': 'eom',
}
# Identity priority for naming
IDENTITY_PRIORITY = [
'couple_1', 'couple_2', # The married couple
'wedding_party', # Bridesmaids, groomspeople
'parents', # Parents of the couple
'grandparents',
'siblings',
'extended_family',
'friends',
'vendors', # Photographer, DJ, etc.
]Identity Linking Workflow
1. Couple identification: User tags couple in 2-3 photos 2. Wedding party: User identifies key people 3. Auto-propagation: Embeddings match across all photos 4. Guest matching: Optional guest list import for name assignment 5. Manual corrections: UI for fixing mismatches
Aesthetic Scoring
Per-Photo Quality Metrics
AESTHETIC_FEATURES = {
# Technical quality
'sharpness': 'Laplacian variance, MTF analysis',
'exposure': 'Histogram analysis, dynamic range',
'noise': 'High-ISO detection, grain analysis',
# Composition
'rule_of_thirds': 'Subject placement scoring',
'symmetry': 'For venue/group shots',
'framing': 'Negative space, balance',
# Face-specific
'expression': 'Smile detection, eye openness',
'blink_detection': 'Eyes closed penalty',
'gaze_direction': 'Looking at camera vs. candid',
'face_occlusion': 'Nothing blocking the face',
'face_lighting': 'Even illumination, no harsh shadows',
# Emotional
'genuine_smile': 'Duchenne marker detection',
'moment_quality': 'Laughter, tears, embraces',
}Best Photo Selection Per Person
def select_best_photos(cluster_photos, n=5):
"""Select top N photos for a person across all their appearances."""
scores = []
for photo in cluster_photos:
score = (
0.25 * technical_quality(photo) +
0.25 * composition_score(photo) +
0.30 * expression_quality(photo) +
0.20 * context_diversity(photo, scores) # Avoid all similar shots
)
scores.append((photo, score))
# Select top N with diversity constraint
return diverse_top_n(scores, n, diversity_threshold=0.7)Theatre Mode
Moment Detection & Playback
KEY MOMENTS (auto-detected + user-tagged):
├── Ceremony
│ ├── Processional
│ ├── Vows exchange
│ ├── Ring ceremony
│ ├── First kiss
│ └── Recessional
├── Reception
│ ├── Grand entrance
│ ├── First dance
│ ├── Parent dances
│ ├── Toasts/speeches
│ ├── Cake cutting
│ └── Bouquet/garter
├── Party
│ ├── Dance floor highlights
│ └── Exit/sendoff
└── Candids
├── Emotional moments (tears, laughter)
└── Spontaneous joyIn-Scene Video Projection
Theatre Mode Rendering:
1. User navigates 3DGS scene freely
2. Approaches "moment marker" (glowing orb/frame)
3. Video/slideshow plays IN the 3D space
├── On walls where projector was
├── Floating frames in dance floor area
└── Photo booth backdrop location
4. Spatial audio for speeches/music
5. User can pause, scrub, exit to continue exploringWeb Viewer Architecture
// Wedding Immortalist Viewer Components
const VIEWER_FEATURES = {
// 3DGS Navigation
gaussianSplatting: {
renderer: 'three-gaussian-splat',
navigation: 'orbit + first-person',
qualityLevels: ['preview', 'standard', 'maximum'],
},
// Theatre Mode
theatreMode: {
momentMarkers: true,
videoInScene: true,
spatialAudio: true,
transitionEffects: 'theme-matched',
},
// Guest Roster
guestRoster: {
faceGrid: 'clustered by identity',
photoGallery: 'per-person best shots',
searchByName: true,
shareableLinks: 'per-guest galleries',
},
// Theme
theming: {
colorPalette: 'extracted from wedding',
typography: 'theme-matched',
uiElements: 'aesthetic-consistent',
},
};Anti-Patterns
"All Frames, All the Time"
Wrong: Extracting every video frame for 3DGS. Why: Redundant data, 10x slower processing, no quality improvement. Right: 2-3 fps extraction with motion-based keyframe selection.
"One Giant Scene"
Wrong: Training single 3DGS for entire venue. Why: Memory explosion, quality degradation, impossible on consumer hardware. Right: Train per-space, create unified navigation with seamless transitions.
"Default Clustering Threshold"
Wrong: Using default HDBSCAN settings. Why: Wedding photos have varying lighting, makeup, angles—need tuning. Right: Tune per-wedding based on photo count and quality variance.
"Ignoring Theme"
Wrong: Generic white/gray viewer UI for disco wedding. Why: Destroys the personality and joy of the event. Right: Extract and honor the couple's aesthetic choices.
"Photographer Only"
Wrong: Using only professional photos. Why: Misses candid moments, guest perspectives, coverage gaps. Right: Merge professional + guest photos for complete coverage.
Guest Experience Features
Shareable Guest Galleries
Per-Guest Experience:
├── Personalized link: yourwedding.com/guests/aunt-martha
├── Their best photos (AI-curated)
├── Photos with the couple
├── Group photos they appear in
├── Download options (full-res)
└── "Add to my memories" for their own archivesCollaborative Enhancement
Guest Contribution Portal:
├── Upload their own photos
├── Tag themselves in unidentified clusters
├── Correct misidentifications
├── Add names to unknown guests
└── Submit video moments they capturedOutput Deliverables
wedding-immortalist-output/
├── 3dgs-scenes/
│ ├── ceremony/
│ ├── cocktail/
│ ├── reception/
│ └── unified-navigation.json
├── guest-roster/
│ ├── face-clusters/
│ ├── identity-mapping.json
│ └── per-person-galleries/
├── theatre-mode/
│ ├── moment-markers.json
│ ├── video-segments/
│ └── spatial-audio/
├── web-viewer/
│ ├── index.html
│ ├── theme-config.json
│ └── assets/
└── exports/
├── full-resolution-photos/
├── guest-gallery-zips/
└── video-compilations/Integration Points
- drone-inspection-specialist: 3DGS techniques, COLMAP pipeline
- collage-layout-expert: Photo arrangement, aesthetic composition
- color-theory-palette-harmony-expert: Theme color extraction
- clip-aware-embeddings: Photo-text matching for search
- photo-composition-critic: Aesthetic quality scoring
---
Core Philosophy: A wedding happens once. The memories should live forever. This skill transforms ephemeral moments into an eternal, explorable experience that honors the couple's unique celebration—whether it's a disco dance party, a rustic barn gathering, or two grooms celebrating their love with chosen family.
Face Clustering & Aesthetic Photo Selection
Overview
Every wedding guest deserves great photos of themselves. This system automatically: 1. Detects all faces across thousands of photos 2. Clusters them by identity 3. Scores each photo for aesthetic quality 4. Selects the best N photos per person
Face Detection Pipeline
Detection Models Comparison
| Model | Speed | Accuracy | Best For |
|---|---|---|---|
| RetinaFace | Medium | Highest | Production quality |
| MTCNN | Slow | High | Fallback for hard cases |
| YOLOv8-face | Fast | Good | Quick preview |
| MediaPipe | Very Fast | Medium | Real-time applications |
RetinaFace Implementation
from retinaface import RetinaFace
import cv2
import numpy as np
def detect_faces(image_path: str, threshold: float = 0.9):
"""
Detect all faces in an image with landmarks.
Returns list of face dictionaries with:
- bbox: [x1, y1, x2, y2]
- landmarks: 5-point facial landmarks
- confidence: detection confidence
"""
faces = RetinaFace.detect_faces(image_path, threshold=threshold)
if not isinstance(faces, dict):
return []
results = []
for face_id, face_data in faces.items():
results.append({
'bbox': face_data['facial_area'], # [x1, y1, x2, y2]
'landmarks': face_data['landmarks'], # 5 points
'confidence': face_data['score']
})
return results
def extract_aligned_face(
image: np.ndarray,
landmarks: dict,
output_size: tuple = (112, 112)
) -> np.ndarray:
"""
Align face using 5-point landmarks for consistent embeddings.
Standard alignment targets (for 112x112):
- Left eye center: (38.29, 51.69)
- Right eye center: (73.53, 51.69)
- Nose tip: (56.02, 71.73)
- Left mouth: (41.54, 92.36)
- Right mouth: (70.72, 92.36)
"""
# Standard reference points
ref_pts = np.array([
[38.29, 51.69], # left eye
[73.53, 51.69], # right eye
[56.02, 71.73], # nose
[41.54, 92.36], # left mouth
[70.72, 92.36] # right mouth
], dtype=np.float32)
# Source points from detection
src_pts = np.array([
landmarks['left_eye'],
landmarks['right_eye'],
landmarks['nose'],
landmarks['mouth_left'],
landmarks['mouth_right']
], dtype=np.float32)
# Compute similarity transform
transform = cv2.estimateAffinePartial2D(src_pts, ref_pts)[0]
# Apply transformation
aligned = cv2.warpAffine(
image, transform, output_size,
borderMode=cv2.BORDER_REPLICATE
)
return alignedFace Embedding & Clustering
Embedding Models
| Model | Dimensions | Accuracy (LFW) | Speed |
|---|---|---|---|
| ArcFace | 512 | 99.83% | Fast |
| AdaFace | 512 | 99.82% | Fast |
| CosFace | 512 | 99.73% | Fast |
| FaceNet | 128/512 | 99.65% | Medium |
ArcFace Embedding
import torch
from insightface.app import FaceAnalysis
class FaceEmbedder:
def __init__(self, model_name: str = 'buffalo_l'):
"""
Initialize face embedding model.
buffalo_l: ArcFace with ResNet100 backbone
buffalo_s: Lighter version for faster processing
"""
self.app = FaceAnalysis(
name=model_name,
providers=['CUDAExecutionProvider', 'CPUExecutionProvider']
)
self.app.prepare(ctx_id=0, det_size=(640, 640))
def get_embedding(self, image: np.ndarray) -> np.ndarray:
"""
Get 512-dimensional face embedding.
"""
faces = self.app.get(image)
if len(faces) == 0:
return None
# Return embedding of largest face
largest_face = max(faces, key=lambda x: (x.bbox[2]-x.bbox[0]) * (x.bbox[3]-x.bbox[1]))
return largest_face.embedding
def get_all_embeddings(self, image: np.ndarray) -> list:
"""
Get embeddings for all faces in image.
"""
faces = self.app.get(image)
return [
{
'embedding': face.embedding,
'bbox': face.bbox.tolist(),
'landmarks': face.landmark_2d_106.tolist() if hasattr(face, 'landmark_2d_106') else None,
'age': face.age if hasattr(face, 'age') else None,
'gender': face.gender if hasattr(face, 'gender') else None
}
for face in faces
]HDBSCAN Clustering
import hdbscan
from sklearn.preprocessing import normalize
import numpy as np
def cluster_faces(
embeddings: np.ndarray,
min_cluster_size: int = 3,
min_samples: int = 2,
cluster_selection_epsilon: float = 0.3
):
"""
Cluster face embeddings using HDBSCAN.
Why HDBSCAN over K-means?
- Doesn't require knowing number of guests in advance
- Handles noise (non-face detections, strangers)
- Works with varying cluster densities
Parameters tuned for wedding photos:
- min_cluster_size=3: At least 3 photos to be considered a "person"
- min_samples=2: Robust to outliers
- cluster_selection_epsilon=0.3: Allow some variation in embeddings
"""
# Normalize embeddings to unit sphere (cosine similarity)
embeddings_norm = normalize(embeddings)
# Cluster
clusterer = hdbscan.HDBSCAN(
min_cluster_size=min_cluster_size,
min_samples=min_samples,
metric='euclidean', # On normalized vectors = cosine
cluster_selection_epsilon=cluster_selection_epsilon,
cluster_selection_method='eom', # Excess of mass
prediction_data=True # For adding new faces later
)
labels = clusterer.fit_predict(embeddings_norm)
# Get cluster centers for each identity
unique_labels = set(labels) - {-1} # -1 is noise
centers = {}
for label in unique_labels:
mask = labels == label
centers[label] = embeddings_norm[mask].mean(axis=0)
return labels, centers, clusterer
def assign_new_face(
embedding: np.ndarray,
clusterer: hdbscan.HDBSCAN,
threshold: float = 0.6
):
"""
Assign a new face to existing clusters.
Returns cluster label or -1 if no match.
"""
embedding_norm = normalize(embedding.reshape(1, -1))
# Use approximate_predict for new points
label, strength = hdbscan.approximate_predict(clusterer, embedding_norm)
if strength[0] > threshold:
return label[0]
return -1Aesthetic Quality Scoring
Multi-Factor Scoring Model
import cv2
import numpy as np
from dataclasses import dataclass
@dataclass
class AestheticScore:
technical: float # Sharpness, exposure, noise
composition: float # Rule of thirds, framing
expression: float # Smile, eyes open, genuine emotion
context: float # Group inclusion, moment importance
overall: float # Weighted combination
def calculate_aesthetic_score(
image: np.ndarray,
face_bbox: list,
face_landmarks: dict,
is_candid: bool = True
) -> AestheticScore:
"""
Calculate comprehensive aesthetic score for a face in a photo.
"""
# 1. Technical Quality (25%)
technical = calculate_technical_score(image, face_bbox)
# 2. Composition (20%)
composition = calculate_composition_score(image, face_bbox)
# 3. Expression (35%)
expression = calculate_expression_score(image, face_landmarks)
# 4. Context (20%)
context = calculate_context_score(image, face_bbox, is_candid)
# Weighted combination
overall = (
0.25 * technical +
0.20 * composition +
0.35 * expression +
0.20 * context
)
return AestheticScore(
technical=technical,
composition=composition,
expression=expression,
context=context,
overall=overall
)
def calculate_technical_score(image: np.ndarray, bbox: list) -> float:
"""
Score technical quality: sharpness, exposure, noise.
"""
x1, y1, x2, y2 = [int(v) for v in bbox]
face_region = image[y1:y2, x1:x2]
# Sharpness via Laplacian variance
gray = cv2.cvtColor(face_region, cv2.COLOR_BGR2GRAY)
sharpness = cv2.Laplacian(gray, cv2.CV_64F).var()
sharpness_score = min(1.0, sharpness / 500) # Normalize
# Exposure via histogram analysis
hist = cv2.calcHist([gray], [0], None, [256], [0, 256])
hist = hist.flatten() / hist.sum()
# Penalize if too much in shadows (0-50) or highlights (200-255)
shadow_ratio = hist[:50].sum()
highlight_ratio = hist[200:].sum()
exposure_score = 1.0 - (shadow_ratio + highlight_ratio) * 0.5
# Noise estimation via median filter difference
denoised = cv2.medianBlur(gray, 3)
noise = np.abs(gray.astype(float) - denoised.astype(float)).mean()
noise_score = max(0, 1.0 - noise / 20)
return (sharpness_score + exposure_score + noise_score) / 3
def calculate_expression_score(image: np.ndarray, landmarks: dict) -> float:
"""
Score facial expression quality.
Factors:
- Eye openness (blink detection)
- Smile detection (Duchenne marker)
- Gaze direction
- Overall expression quality
"""
scores = []
# Eye openness
# Calculate eye aspect ratio (EAR)
left_eye = landmarks.get('left_eye')
right_eye = landmarks.get('right_eye')
if left_eye and right_eye:
# Simple EAR approximation
# Real implementation would use 6 points per eye
eye_openness = 0.8 # Placeholder
blink_penalty = 0.0 if eye_openness > 0.2 else 0.5
scores.append(1.0 - blink_penalty)
# Smile detection
mouth_left = landmarks.get('mouth_left')
mouth_right = landmarks.get('mouth_right')
if mouth_left and mouth_right:
# Mouth width relative to face width
mouth_width = np.linalg.norm(
np.array(mouth_right) - np.array(mouth_left)
)
# Wider smile = higher score (to a point)
smile_score = min(1.0, mouth_width / 50)
scores.append(smile_score)
# Gaze direction (looking at camera vs. away)
# For candids, looking away can be good
# For portraits, looking at camera is preferred
gaze_score = 0.7 # Placeholder
scores.append(gaze_score)
return np.mean(scores) if scores else 0.5
def calculate_composition_score(image: np.ndarray, bbox: list) -> float:
"""
Score composition quality.
"""
h, w = image.shape[:2]
x1, y1, x2, y2 = bbox
face_center_x = (x1 + x2) / 2 / w
face_center_y = (y1 + y2) / 2 / h
# Rule of thirds scoring
thirds_x = [1/3, 1/2, 2/3]
thirds_y = [1/3, 2/3]
min_dist_x = min(abs(face_center_x - t) for t in thirds_x)
min_dist_y = min(abs(face_center_y - t) for t in thirds_y)
thirds_score = 1.0 - (min_dist_x + min_dist_y)
# Face size (not too small, not too cropped)
face_area = (x2 - x1) * (y2 - y1)
image_area = w * h
face_ratio = face_area / image_area
# Optimal face ratio: 5-25% of image
if 0.05 <= face_ratio <= 0.25:
size_score = 1.0
elif face_ratio < 0.05:
size_score = face_ratio / 0.05
else:
size_score = max(0, 1.0 - (face_ratio - 0.25) * 2)
return (thirds_score + size_score) / 2Diversity-Aware Selection
def select_best_photos_diverse(
cluster_photos: list,
n: int = 5,
diversity_threshold: float = 0.7
) -> list:
"""
Select top N photos for a person with diversity constraint.
Avoids selecting N nearly-identical shots from the same moment.
Instead, picks best photo from each distinct moment/pose.
"""
# Score all photos
scored = []
for photo in cluster_photos:
score = calculate_aesthetic_score(
photo['image'],
photo['bbox'],
photo['landmarks']
)
scored.append({
**photo,
'aesthetic_score': score
})
# Sort by overall score
scored.sort(key=lambda x: x['aesthetic_score'].overall, reverse=True)
# Select with diversity constraint
selected = []
for candidate in scored:
if len(selected) >= n:
break
# Check diversity against already selected
is_diverse = True
for existing in selected:
similarity = compute_photo_similarity(
candidate['embedding'],
existing['embedding']
)
if similarity > diversity_threshold:
is_diverse = False
break
if is_diverse:
selected.append(candidate)
# If we couldn't get N diverse photos, fill with best remaining
if len(selected) < n:
for candidate in scored:
if candidate not in selected:
selected.append(candidate)
if len(selected) >= n:
break
return selected
def compute_photo_similarity(emb1: np.ndarray, emb2: np.ndarray) -> float:
"""
Compute similarity between two photo embeddings.
Uses face embedding + pose + timestamp proximity.
"""
# Cosine similarity of face embeddings
face_sim = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
return face_simIdentity Linking Workflow
Priority-Based Naming
IDENTITY_PRIORITY = [
('couple', ['bride', 'groom', 'spouse_1', 'spouse_2']),
('wedding_party', ['best_man', 'maid_of_honor', 'bridesmaid', 'groomsman']),
('parents', ['mother_bride', 'father_bride', 'mother_groom', 'father_groom']),
('grandparents', ['grandmother', 'grandfather']),
('siblings', ['sister', 'brother']),
('extended_family', ['aunt', 'uncle', 'cousin']),
('friends', []),
('vendors', ['photographer', 'dj', 'coordinator']),
]
def link_identities(
clusters: dict,
seed_identities: dict, # User-provided: {cluster_id: "Aunt Martha"}
guest_list: list = None # Optional: ["Aunt Martha", "Uncle Bob", ...]
) -> dict:
"""
Link cluster IDs to human-readable names.
Workflow:
1. User tags couple in 2-3 photos → seeds those clusters
2. User optionally tags wedding party
3. System propagates through all photos
4. Remaining clusters get generic names or guest list matching
"""
identity_map = {}
# Start with user-provided seeds
for cluster_id, name in seed_identities.items():
identity_map[cluster_id] = {
'name': name,
'confidence': 1.0,
'source': 'user_tagged'
}
# Remaining clusters
unnamed_clusters = set(clusters.keys()) - set(identity_map.keys())
for i, cluster_id in enumerate(unnamed_clusters):
cluster_data = clusters[cluster_id]
# Try to match with guest list using any available signals
if guest_list:
# Could use location proximity to tagged people, etc.
pass
# Fallback to generic naming
identity_map[cluster_id] = {
'name': f"Guest {i + 1}",
'confidence': 0.5,
'source': 'auto_assigned'
}
return identity_mapOutput Format
{
"wedding_id": "smith-jones-2024",
"processed_date": "2024-12-15T10:30:00Z",
"total_photos": 3847,
"total_faces_detected": 12453,
"unique_identities": 127,
"identities": [
{
"cluster_id": 0,
"name": "Alex Smith",
"role": "spouse_1",
"photo_count": 487,
"best_photos": [
{
"photo_id": "IMG_2847.jpg",
"score": 0.94,
"scores": {
"technical": 0.91,
"composition": 0.88,
"expression": 0.98,
"context": 0.95
},
"moment": "first_dance",
"timestamp": "2024-11-15T20:45:00Z"
}
],
"thumbnail": "clusters/0/thumbnail.jpg"
}
]
}3D Gaussian Splatting Pipeline for Weddings
Complete Technical Pipeline
Overview
3D Gaussian Splatting (3DGS) creates photorealistic, real-time renderable 3D scenes from photos/video. For weddings, we're reconstructing entire venues as explorable memory spaces.
Phase 1: Data Ingestion
Video Frame Extraction
import cv2
import os
from pathlib import Path
def extract_frames(video_path: str, output_dir: str, fps: float = 2.0):
"""
Extract frames from wedding video at optimal rate for 3DGS.
Why 2-3 fps?
- Wedding videos are typically 30fps
- Adjacent frames are nearly identical (redundant)
- 2-3fps maintains 80%+ overlap while reducing processing 10x
- More frames ≠ better quality after sufficient overlap
"""
cap = cv2.VideoCapture(video_path)
video_fps = cap.get(cv2.CAP_PROP_FPS)
frame_interval = int(video_fps / fps)
Path(output_dir).mkdir(parents=True, exist_ok=True)
frame_count = 0
saved_count = 0
while cap.isOpened():
ret, frame = cap.read()
if not ret:
break
if frame_count % frame_interval == 0:
# Check for blur before saving
laplacian_var = cv2.Laplacian(frame, cv2.CV_64F).var()
if laplacian_var > 100: # Reject blurry frames
cv2.imwrite(f"{output_dir}/frame_{saved_count:06d}.jpg", frame)
saved_count += 1
frame_count += 1
cap.release()
return saved_count
# Quality thresholds
BLUR_THRESHOLD = 100 # Laplacian variance
MIN_IMAGES_PER_SPACE = 50
OPTIMAL_IMAGES_PER_SPACE = 150
MAX_IMAGES_PER_SPACE = 300 # Diminishing returns after thisPhoto Organization
from datetime import datetime
from PIL import Image
from PIL.ExifTags import TAGS
import shutil
def organize_wedding_photos(source_dir: str, output_dir: str):
"""
Organize photos by time and location for multi-space reconstruction.
"""
photos = []
for img_path in Path(source_dir).glob("**/*.{jpg,jpeg,JPG,JPEG,png,PNG}"):
try:
img = Image.open(img_path)
exif = img._getexif()
timestamp = None
gps = None
if exif:
for tag_id, value in exif.items():
tag = TAGS.get(tag_id, tag_id)
if tag == "DateTimeOriginal":
timestamp = datetime.strptime(value, "%Y:%m:%d %H:%M:%S")
elif tag == "GPSInfo":
gps = value
photos.append({
'path': img_path,
'timestamp': timestamp,
'gps': gps,
'resolution': img.size
})
except Exception as e:
print(f"Skipping {img_path}: {e}")
# Sort by timestamp
photos.sort(key=lambda x: x['timestamp'] or datetime.min)
# Cluster into spaces based on time gaps
spaces = cluster_into_spaces(photos)
return spaces
def cluster_into_spaces(photos, gap_threshold_minutes=15):
"""
Cluster photos into distinct spaces/moments based on time gaps.
Typical wedding timeline:
- Getting ready (1-2 hours)
- Ceremony (30-60 min)
- Cocktail hour (1 hour)
- Reception entrance + dinner (1-2 hours)
- Dancing + party (2-3 hours)
"""
spaces = []
current_space = []
for i, photo in enumerate(photos):
if i == 0:
current_space.append(photo)
continue
prev_time = photos[i-1]['timestamp']
curr_time = photo['timestamp']
if prev_time and curr_time:
gap = (curr_time - prev_time).total_seconds() / 60
if gap > gap_threshold_minutes:
spaces.append(current_space)
current_space = []
current_space.append(photo)
if current_space:
spaces.append(current_space)
return spacesPhase 2: COLMAP Structure from Motion
Feature Extraction
#!/bin/bash
# colmap_sfm.sh - Structure from Motion pipeline
WORKSPACE=$1
IMAGE_PATH=$2
# 1. Feature extraction with SIFT
colmap feature_extractor \
--database_path $WORKSPACE/database.db \
--image_path $IMAGE_PATH \
--ImageReader.single_camera 0 \
--ImageReader.camera_model OPENCV \
--SiftExtraction.max_image_size 3200 \
--SiftExtraction.max_num_features 8192 \
--SiftExtraction.first_octave -1 \
--SiftExtraction.num_threads -1
# 2. Feature matching
# For wedding photos with lots of similar views, exhaustive matching works best
colmap exhaustive_matcher \
--database_path $WORKSPACE/database.db \
--SiftMatching.guided_matching 1 \
--SiftMatching.max_ratio 0.8 \
--SiftMatching.max_distance 0.7
# 3. Sparse reconstruction (SfM)
mkdir -p $WORKSPACE/sparse
colmap mapper \
--database_path $WORKSPACE/database.db \
--image_path $IMAGE_PATH \
--output_path $WORKSPACE/sparse \
--Mapper.ba_refine_focal_length 1 \
--Mapper.ba_refine_principal_point 1 \
--Mapper.ba_refine_extra_params 1
# 4. Undistort images for dense reconstruction
colmap image_undistorter \
--image_path $IMAGE_PATH \
--input_path $WORKSPACE/sparse/0 \
--output_path $WORKSPACE/dense \
--output_type COLMAP
echo "SfM complete. Check $WORKSPACE/sparse/0 for camera poses."Handling Multiple Spaces
def merge_reconstructions(spaces: list, output_path: str):
"""
For weddings spanning multiple distinct spaces (ceremony, reception),
we have two options:
1. SEPARATE SCENES: Train individual 3DGS models per space
- Pros: Better quality per scene, simpler training
- Cons: Need scene transitions in viewer
2. MERGED SCENE: Use shared features to align all spaces
- Pros: Seamless navigation
- Cons: Harder to reconstruct, may need manual alignment
For weddings, SEPARATE SCENES is usually better.
"""
# Create navigation graph between spaces
navigation = {
'spaces': [],
'transitions': []
}
for i, space in enumerate(spaces):
navigation['spaces'].append({
'id': f'space_{i}',
'name': space['name'], # e.g., "ceremony", "reception"
'model_path': f'{output_path}/space_{i}',
'entry_point': space.get('entry_camera'), # Best starting view
'thumbnail': space.get('thumbnail')
})
# Define logical transitions
navigation['transitions'] = [
{'from': 'ceremony', 'to': 'cocktail', 'type': 'fade'},
{'from': 'cocktail', 'to': 'reception', 'type': 'walk'},
# etc.
]
return navigationPhase 3: 3DGS Training
Training Configuration
# wedding_3dgs_config.py
WEDDING_3DGS_CONFIG = {
# Training iterations
'iterations': 50_000, # High quality for permanent archive
# Densification settings
'densify_from_iter': 500,
'densify_until_iter': 15_000,
'densification_interval': 100,
'opacity_reset_interval': 3000,
# Gaussian parameters
'sh_degree': 3, # Full spherical harmonics for complex lighting
'percent_dense': 0.01,
'densify_grad_threshold': 0.0002,
# Pruning
'min_opacity': 0.005,
'max_screen_size': 20, # Max pixel size before splitting
# Learning rates
'position_lr_init': 0.00016,
'position_lr_final': 0.0000016,
'position_lr_delay_mult': 0.01,
'position_lr_max_steps': 30_000,
'feature_lr': 0.0025,
'opacity_lr': 0.05,
'scaling_lr': 0.005,
'rotation_lr': 0.001,
# Loss weights
'lambda_dssim': 0.2, # Structural similarity weight
# Performance
'white_background': False, # Wedding venues rarely have white bg
'data_device': 'cuda',
'convert_SHs_python': False,
'compute_cov3D_python': False,
}
# Quality presets
QUALITY_PRESETS = {
'preview': {
'iterations': 7_000,
'densify_until_iter': 5_000,
'description': 'Quick preview in ~5 minutes'
},
'standard': {
'iterations': 30_000,
'densify_until_iter': 15_000,
'description': 'Good quality in ~30 minutes'
},
'high': {
'iterations': 50_000,
'densify_until_iter': 20_000,
'description': 'High quality in ~1 hour'
},
'archival': {
'iterations': 100_000,
'densify_until_iter': 30_000,
'description': 'Maximum quality in ~3 hours'
}
}Training Script
import torch
from gaussian_splatting import GaussianModel, train
from scene import Scene
import os
def train_wedding_scene(
source_path: str,
output_path: str,
quality: str = 'high'
):
"""
Train 3DGS model for a wedding space.
Args:
source_path: COLMAP output directory
output_path: Where to save trained model
quality: 'preview', 'standard', 'high', or 'archival'
"""
config = {**WEDDING_3DGS_CONFIG, **QUALITY_PRESETS[quality]}
# Initialize Gaussian model
gaussians = GaussianModel(config['sh_degree'])
# Load scene from COLMAP
scene = Scene(source_path, gaussians)
# Training loop with wedding-specific optimizations
for iteration in range(config['iterations']):
# Render
render_pkg = render(
scene.getTrainCameras()[iteration % len(scene.getTrainCameras())],
gaussians,
background=torch.zeros(3).cuda()
)
# Loss
image = render_pkg['render']
gt_image = scene.getTrainCameras()[iteration % len(scene.getTrainCameras())].original_image
l1_loss = torch.abs(image - gt_image).mean()
ssim_loss = 1.0 - ssim(image, gt_image)
loss = (1 - config['lambda_dssim']) * l1_loss + config['lambda_dssim'] * ssim_loss
loss.backward()
# Densification
if iteration < config['densify_until_iter']:
if iteration > config['densify_from_iter'] and iteration % config['densification_interval'] == 0:
gaussians.densify_and_prune(
config['densify_grad_threshold'],
config['min_opacity'],
scene.cameras_extent,
config['max_screen_size']
)
# Optimizer step
gaussians.optimizer.step()
gaussians.optimizer.zero_grad()
# Logging
if iteration % 1000 == 0:
print(f"Iteration {iteration}: Loss = {loss.item():.6f}")
# Save checkpoint
if iteration % 10000 == 0:
torch.save(gaussians.capture(), f"{output_path}/checkpoint_{iteration}.pth")
# Final save
gaussians.save_ply(f"{output_path}/point_cloud.ply")
return output_pathPhase 4: Web Viewer Integration
Viewer Architecture
// WeddingViewer.tsx
import { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { SplatLoader } from '@mkkellogg/gaussian-splats-3d';
interface WeddingViewerProps {
spaces: WeddingSpace[];
moments: TheatreMoment[];
onMomentClick: (moment: TheatreMoment) => void;
}
export function WeddingViewer({ spaces, moments, onMomentClick }: WeddingViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<GaussianSplatViewer | null>(null);
const [currentSpace, setCurrentSpace] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!containerRef.current) return;
// Initialize Three.js scene
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
// Load Gaussian Splat
const loader = new SplatLoader();
loader.load(spaces[currentSpace].modelPath, (splat) => {
scene.add(splat);
setLoading(false);
});
// Add moment markers
moments.forEach(moment => {
const marker = createMomentMarker(moment);
marker.onClick = () => onMomentClick(moment);
scene.add(marker);
});
// Controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// Animation loop
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
return () => {
renderer.dispose();
};
}, [currentSpace, spaces, moments]);
return (
<div ref={containerRef} className="wedding-viewer">
{loading && <LoadingOverlay theme={spaces[currentSpace].theme} />}
<SpaceNavigator
spaces={spaces}
current={currentSpace}
onChange={setCurrentSpace}
/>
</div>
);
}Hardware Requirements
| Component | Minimum | Recommended | Notes |
|---|---|---|---|
| GPU | RTX 3060 12GB | RTX 4080 16GB | VRAM is the bottleneck |
| RAM | 32GB | 64GB | For large photo sets |
| Storage | 100GB SSD | 500GB NVMe | Fast I/O matters |
| CPU | 8 cores | 16+ cores | For COLMAP parallelization |
Troubleshooting
Common Issues
1. Sparse point cloud: Not enough image overlap
- Solution: Add more photos from in-between angles
2. Floaters: Random gaussians in empty space
- Solution: Increase opacity pruning, reduce learning rate
3. Blurry reconstruction: Motion blur in source images
- Solution: Filter frames with Laplacian variance < 100
4. Memory errors: Too many gaussians
- Solution: Reduce densification, increase pruning
Quality Checklist
- [ ] Minimum 50 images per distinct space
- [ ] 60-80% overlap between adjacent views
- [ ] No motion blur (Laplacian variance > 100)
- [ ] Consistent lighting (avoid mixed indoor/outdoor)
- [ ] All guests' faces visible in at least 3 angles
Wedding Theme Extraction & Design System
Overview
Every wedding has a unique aesthetic identity. This system extracts that identity from photos and generates a cohesive design system for the digital experience.
Theme Detection Pipeline
┌─────────────────────────────────────────────────────────────────┐
│ THEME EXTRACTION PIPELINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. COLOR EXTRACTION 2. STYLE CLASSIFICATION │
│ ├─ Dominant colors ├─ Era detection (70s, modern, etc.) │
│ ├─ Palette clustering ├─ Formality level │
│ └─ Accent identification└─ Cultural markers │
│ │
│ 3. TYPOGRAPHY MATCH 4. UI GENERATION │
│ ├─ Era-appropriate ├─ Component theming │
│ ├─ Mood-aligned ├─ Gradient definitions │
│ └─ Readability check └─ Animation style │
│ │
└─────────────────────────────────────────────────────────────────┘Color Extraction
Dominant Color Analysis
import cv2
import numpy as np
from sklearn.cluster import KMeans
from collections import Counter
from colorthief import ColorThief
def extract_wedding_palette(images: list, n_colors: int = 6):
"""
Extract dominant color palette from wedding photos.
Strategy:
1. Sample from key photo categories (venue, florals, attire, decor)
2. Weight certain areas higher (florals > background)
3. Cluster into cohesive palette
"""
all_colors = []
for img_path in images:
# Extract using ColorThief (fast, quality-focused)
thief = ColorThief(img_path)
palette = thief.get_palette(color_count=6, quality=1)
all_colors.extend(palette)
# Cluster all extracted colors
colors_array = np.array(all_colors)
kmeans = KMeans(n_clusters=n_colors, random_state=42)
kmeans.fit(colors_array)
# Sort by frequency
labels = kmeans.labels_
label_counts = Counter(labels)
sorted_labels = sorted(label_counts.keys(), key=lambda x: label_counts[x], reverse=True)
palette = []
for label in sorted_labels:
rgb = kmeans.cluster_centers_[label].astype(int)
palette.append({
'rgb': tuple(rgb),
'hex': '#{:02x}{:02x}{:02x}'.format(*rgb),
'frequency': label_counts[label] / len(labels)
})
return palette
def categorize_palette_roles(palette: list) -> dict:
"""
Assign semantic roles to extracted colors.
Roles:
- primary: Main brand/theme color
- secondary: Complementary accent
- background: Light/neutral base
- text: Dark/readable color
- accent: Pop of color for CTAs
- highlight: Subtle emphasis
"""
from colormath.color_objects import sRGBColor, LabColor
from colormath.color_conversions import convert_color
roles = {}
remaining = list(palette)
# Find lightest for background
remaining.sort(key=lambda c: sum(c['rgb']))
roles['background'] = remaining.pop()
# Find darkest for text
remaining.sort(key=lambda c: sum(c['rgb']), reverse=True)
roles['text'] = remaining.pop()
# Most frequent remaining is primary
remaining.sort(key=lambda c: c['frequency'], reverse=True)
roles['primary'] = remaining.pop(0)
# Second most frequent is secondary
if remaining:
roles['secondary'] = remaining.pop(0)
# Most saturated remaining is accent
if remaining:
def saturation(c):
r, g, b = [x/255 for x in c['rgb']]
max_c, min_c = max(r, g, b), min(r, g, b)
return (max_c - min_c) / (max_c + 0.001)
remaining.sort(key=saturation, reverse=True)
roles['accent'] = remaining.pop(0)
# Rest are highlights
if remaining:
roles['highlight'] = remaining[0]
return rolesEra & Style Detection
from enum import Enum
from typing import Tuple
import colorsys
class WeddingEra(Enum):
SEVENTIES_DISCO = "70s_disco"
EIGHTIES_GLAM = "80s_glam"
NINETIES_MINIMALIST = "90s_minimalist"
RUSTIC_BARN = "rustic_barn"
MODERN_MINIMAL = "modern_minimal"
BEACH_COASTAL = "beach_coastal"
GARDEN_ROMANTIC = "garden_romantic"
GLAMOROUS_GATSBY = "gatsby_glamour"
BOHEMIAN = "bohemian"
CULTURAL_TRADITIONAL = "cultural_traditional"
QUEER_CELEBRATION = "queer_celebration"
def detect_wedding_era(
palette: list,
detected_objects: list, # From object detection
venue_type: str = None
) -> Tuple[WeddingEra, float]:
"""
Detect the wedding's aesthetic era/style.
Signals:
- Color palette temperature and saturation
- Detected objects (disco ball, barn wood, beach)
- Venue type if provided
- Attire style (detected or user-specified)
"""
scores = {era: 0.0 for era in WeddingEra}
# Analyze palette characteristics
avg_saturation = np.mean([
colorsys.rgb_to_hsv(*[c/255 for c in color['rgb']])[1]
for color in palette
])
warm_ratio = sum(
1 for c in palette
if c['rgb'][0] > c['rgb'][2] # R > B = warm
) / len(palette)
# 70s Disco indicators
if any(obj in detected_objects for obj in ['disco_ball', 'mirror_ball', 'sequins']):
scores[WeddingEra.SEVENTIES_DISCO] += 0.5
# Color patterns for 70s
earth_tones = ['#D2691E', '#8B4513', '#DAA520', '#CD853F']
if palette_matches_tones(palette, earth_tones, threshold=0.3):
scores[WeddingEra.SEVENTIES_DISCO] += 0.3
if warm_ratio > 0.7:
scores[WeddingEra.SEVENTIES_DISCO] += 0.2
# Rustic indicators
if any(obj in detected_objects for obj in ['barn', 'wood', 'burlap', 'mason_jar']):
scores[WeddingEra.RUSTIC_BARN] += 0.5
# Modern minimal indicators
if avg_saturation < 0.3: # Desaturated palette
scores[WeddingEra.MODERN_MINIMAL] += 0.3
# Beach indicators
if any(obj in detected_objects for obj in ['beach', 'ocean', 'sand', 'palm']):
scores[WeddingEra.BEACH_COASTAL] += 0.5
# Rainbow/pride indicators for queer celebrations
rainbow_coverage = check_rainbow_coverage(palette)
if rainbow_coverage > 0.5:
scores[WeddingEra.QUEER_CELEBRATION] += 0.4
# Find highest scoring era
best_era = max(scores, key=scores.get)
confidence = scores[best_era]
# Normalize confidence
total = sum(scores.values())
if total > 0:
confidence = scores[best_era] / total
return best_era, confidence
def palette_matches_tones(palette: list, reference_tones: list, threshold: float) -> bool:
"""Check if palette matches reference color tones."""
from colormath.color_objects import sRGBColor, LabColor
from colormath.color_conversions import convert_color
from colormath.color_diff import delta_e_cie2000
matches = 0
for color in palette:
rgb = sRGBColor(*[c/255 for c in color['rgb']])
lab = convert_color(rgb, LabColor)
for ref_hex in reference_tones:
ref_rgb = sRGBColor.new_from_rgb_hex(ref_hex)
ref_lab = convert_color(ref_rgb, LabColor)
delta = delta_e_cie2000(lab, ref_lab)
if delta < 20: # Close enough
matches += 1
break
return matches / len(palette) >= thresholdTheme Templates
70s Disco Theme
// themes/70s-disco.ts
export const discoTheme = {
name: "70s Disco",
colors: {
primary: '#D2691E', // Burnt orange
secondary: '#DAA520', // Goldenrod
accent: '#8B008B', // Dark magenta
background: '#1a1a2e', // Deep purple-black
surface: '#2d2d44', // Lighter purple
text: '#FFFFFF',
textMuted: '#B0A090',
},
gradients: {
sunset: 'linear-gradient(180deg, #FF6B35 0%, #D2691E 50%, #8B008B 100%)',
disco: 'linear-gradient(45deg, #FFD700, #FF6B35, #8B008B, #4169E1)',
gold: 'linear-gradient(180deg, #FFD700 0%, #DAA520 100%)',
},
typography: {
display: "'Playfair Display', serif", // Elegant, era-appropriate
heading: "'Bebas Neue', sans-serif", // Bold, groovy
body: "'Lato', sans-serif", // Clean readability
accent: "'Pacifico', cursive", // Fun script moments
},
effects: {
glowColor: 'rgba(255, 215, 0, 0.6)',
shadowColor: 'rgba(139, 0, 139, 0.3)',
borderRadius: '0px', // Sharp 70s edges
borderStyle: '3px solid',
},
patterns: {
starburst: true,
mirrorBall: true,
geometricShapes: ['hexagon', 'star', 'diamond'],
},
animations: {
type: 'groovy',
easing: 'cubic-bezier(0.68, -0.55, 0.265, 1.55)', // Bouncy
duration: '0.6s',
},
components: {
button: {
background: 'var(--gradient-gold)',
color: '#1a1a2e',
fontFamily: 'var(--font-heading)',
textTransform: 'uppercase',
letterSpacing: '2px',
border: 'none',
boxShadow: '0 4px 15px var(--glow-color)',
},
card: {
background: 'var(--surface)',
border: '2px solid var(--primary)',
boxShadow: '0 0 20px var(--shadow-color)',
},
header: {
background: 'var(--gradient-sunset)',
color: 'white',
fontFamily: 'var(--font-display)',
},
},
};Modern Minimal Theme
// themes/modern-minimal.ts
export const modernMinimalTheme = {
name: "Modern Minimal",
colors: {
primary: '#2C3E50', // Deep slate
secondary: '#E8E8E8', // Warm gray
accent: '#C9A959', // Muted gold
background: '#FFFFFF',
surface: '#F8F8F8',
text: '#1A1A1A',
textMuted: '#6B7280',
},
gradients: {
subtle: 'linear-gradient(180deg, #FFFFFF 0%, #F8F8F8 100%)',
accent: 'linear-gradient(90deg, #C9A959 0%, #D4AF37 100%)',
},
typography: {
display: "'Cormorant Garamond', serif",
heading: "'Montserrat', sans-serif",
body: "'Open Sans', sans-serif",
accent: "'Cormorant Garamond', serif",
},
effects: {
shadowColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: '2px',
borderStyle: '1px solid #E8E8E8',
},
animations: {
type: 'subtle',
easing: 'ease-out',
duration: '0.3s',
},
};Queer Celebration Theme
// themes/queer-celebration.ts
export const queerCelebrationTheme = {
name: "Queer Celebration",
colors: {
// Rainbow spectrum
red: '#E50000',
orange: '#FF8D00',
yellow: '#FFEE00',
green: '#028121',
blue: '#004CFF',
purple: '#770088',
// UI colors
primary: '#770088',
secondary: '#004CFF',
accent: '#FFEE00',
background: '#FFFFFF',
surface: '#F5F0FF',
text: '#1A1A1A',
},
gradients: {
pride: 'linear-gradient(90deg, #E50000, #FF8D00, #FFEE00, #028121, #004CFF, #770088)',
prideVertical: 'linear-gradient(180deg, #E50000, #FF8D00, #FFEE00, #028121, #004CFF, #770088)',
trans: 'linear-gradient(180deg, #55CDFC, #F7A8B8, #FFFFFF, #F7A8B8, #55CDFC)',
bi: 'linear-gradient(180deg, #D60270, #9B4F96, #0038A8)',
nonbinary: 'linear-gradient(180deg, #FCF434, #FFFFFF, #9C59D1, #2C2C2C)',
},
typography: {
display: "'Playfair Display', serif",
heading: "'Poppins', sans-serif",
body: "'Inter', sans-serif",
accent: "'Dancing Script', cursive",
},
effects: {
glowColor: 'rgba(119, 0, 136, 0.4)',
borderRadius: '8px',
borderStyle: '2px solid',
},
patterns: {
rainbow: true,
hearts: true,
confetti: true,
},
animations: {
type: 'joyful',
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
duration: '0.5s',
},
specialElements: {
pronounBadges: true,
chosenFamilyHighlight: true,
prideFlags: ['rainbow', 'trans', 'bi', 'nonbinary', 'pan', 'lesbian', 'gay'],
},
};CSS Generation
def generate_css_variables(theme: dict) -> str:
"""
Generate CSS custom properties from theme.
"""
css = ":root {\n"
# Colors
for name, value in theme['colors'].items():
css += f" --color-{name}: {value};\n"
# Gradients
for name, value in theme.get('gradients', {}).items():
css += f" --gradient-{name}: {value};\n"
# Typography
for name, value in theme.get('typography', {}).items():
css += f" --font-{name}: {value};\n"
# Effects
for name, value in theme.get('effects', {}).items():
css_name = name.replace('_', '-')
css += f" --{css_name}: {value};\n"
# Animation
if 'animations' in theme:
css += f" --animation-easing: {theme['animations']['easing']};\n"
css += f" --animation-duration: {theme['animations']['duration']};\n"
css += "}\n"
return cssTheme Application
// ThemeProvider.tsx
import { createContext, useContext, ReactNode } from 'react';
interface ThemeContextValue {
theme: WeddingTheme;
setTheme: (theme: WeddingTheme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function WeddingThemeProvider({
children,
extractedTheme
}: {
children: ReactNode;
extractedTheme: WeddingTheme;
}) {
const [theme, setTheme] = useState(extractedTheme);
// Apply CSS variables
useEffect(() => {
const root = document.documentElement;
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--color-${key}`, value);
});
Object.entries(theme.gradients || {}).forEach(([key, value]) => {
root.style.setProperty(`--gradient-${key}`, value);
});
// Apply font imports
const fontLink = document.createElement('link');
fontLink.href = generateGoogleFontsUrl(theme.typography);
fontLink.rel = 'stylesheet';
document.head.appendChild(fontLink);
return () => {
document.head.removeChild(fontLink);
};
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useWeddingTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useWeddingTheme must be used within WeddingThemeProvider');
}
return context;
}