phase-8: implement 5 social media format presets for clip maker

Update clip maker to match Phase 8 plan specifications:
- Landscape: 1920×1080 (16:9) MP4 - YouTube, Twitter, Discord
- Square: 1080×1080 (1:1) MP4 - Twitter, Instagram feed
- Portrait: 1080×1920 (9:16) MP4 - TikTok, YouTube Shorts, IG Stories
- GIF (compact): 640×360 (16:9) GIF - Discord embeds, forums
- GIF (square): 480×480 (1:1) GIF - Twitter, Slack

Each preset now has a fixed format (MP4 or GIF) matching the
plan's specification, with export button dynamically updating
to show the correct format type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-08 10:50:41 -04:00
parent 921f5d2c3e
commit 4486c74dd9
2 changed files with 34 additions and 28 deletions

View file

@ -1 +1 @@
f0639a6e36c5252bf95380dc6c6117d389f6eaae
524c02488fffe468318df33d8886a1b77511a31b

View file

@ -12,14 +12,16 @@ interface SocialPreset {
height: number;
ratio: string;
icon: string;
format: 'mp4' | 'gif';
target: string;
}
const SOCIAL_PRESETS: SocialPreset[] = [
{ name: 'Twitter / X', width: 1280, height: 720, ratio: '16:9', icon: '𝕏' },
{ name: 'Instagram Square', width: 1080, height: 1080, ratio: '1:1', icon: '' },
{ name: 'Instagram Story', width: 1080, height: 1920, ratio: '9:16', icon: '' },
{ name: 'TikTok / Reels', width: 1080, height: 1920, ratio: '9:16', icon: '▶' },
{ name: 'YouTube Shorts', width: 1080, height: 1920, ratio: '9:16', icon: '▷' },
{ name: 'Landscape', width: 1920, height: 1080, ratio: '16:9', icon: '🖥️', format: 'mp4', target: 'YouTube, Twitter, Discord' },
{ name: 'Square', width: 1080, height: 1080, ratio: '1:1', icon: '⬜', format: 'mp4', target: 'Twitter, Instagram feed' },
{ name: 'Portrait', width: 1080, height: 1920, ratio: '9:16', icon: '📱', format: 'mp4', target: 'TikTok, YouTube Shorts, IG Stories' },
{ name: 'GIF (compact)', width: 640, height: 360, ratio: '16:9', icon: '🎞️', format: 'gif', target: 'Discord embeds, forums' },
{ name: 'GIF (square)', width: 480, height: 480, ratio: '1:1', icon: '🖼️', format: 'gif', target: 'Twitter, Slack' },
];
// Preview scale: limit longest side to 360px
@ -39,13 +41,13 @@ export function renderClipMakerPage(_params: Record<string, string>): void {
function buildHTML(): string {
const presetOptions = SOCIAL_PRESETS.map((p, i) =>
`<option value="${i}">${p.icon} ${p.name} (${p.ratio})</option>`,
`<option value="${i}">${p.icon} ${p.name} (${p.width}×${p.height}, ${p.format.toUpperCase()})</option>`,
).join('');
return `
<div class="clip-page">
<h1 class="page-title">Clip Maker</h1>
<p class="clip-intro">Export replay highlights as MP4 or animated GIF, sized for social media.</p>
<p class="clip-intro">Export replay highlights as MP4 or animated GIF with 5 social media format presets.</p>
<div class="clip-layout">
<!-- Left: load + settings -->
@ -69,6 +71,7 @@ function buildHTML(): string {
${presetOptions}
</select>
<div class="preset-dims" id="preset-dims-label"></div>
<div class="preset-target" id="preset-target-label"></div>
</div>
<div class="clip-panel" id="clip-range-panel" style="display:none">
@ -97,8 +100,7 @@ function buildHTML(): string {
<div class="clip-panel" id="clip-export-panel" style="display:none">
<div class="panel-header"><span>Export</span></div>
<div class="export-buttons">
<button id="clip-export-mp4" class="btn primary">Export MP4 / WebM</button>
<button id="clip-export-gif" class="btn secondary">Export GIF</button>
<button id="clip-export-btn" class="btn primary">Export</button>
</div>
<div id="clip-export-progress" class="clip-progress hidden">
<div class="progress-bar"><div id="clip-progress-fill" class="progress-fill" style="width:0%"></div></div>
@ -163,13 +165,19 @@ function initClipMaker(): void {
const fpsSelect = document.getElementById('clip-fps-select') as HTMLSelectElement;
const presetSelect = document.getElementById('clip-preset-select') as HTMLSelectElement;
const dimsLabel = document.getElementById('preset-dims-label')!;
const targetLabel = document.getElementById('preset-target-label')!;
const previewInfo = document.getElementById('clip-preview-info')!;
const frameLabel = document.getElementById('clip-frame-label')!;
const previewFrame = document.getElementById('clip-preview-frame')!;
function updateDimsLabel(): void {
const p = SOCIAL_PRESETS[Number(presetSelect.value)];
dimsLabel.textContent = `${p.width} × ${p.height} px`;
dimsLabel.textContent = `${p.width} × ${p.height} px · ${p.format.toUpperCase()}`;
targetLabel.textContent = `Target: ${p.target}`;
// Update export button text
const exportBtn = document.getElementById('clip-export-btn')!;
exportBtn.textContent = `Export ${p.format.toUpperCase()}`;
}
updateDimsLabel();
presetSelect.addEventListener('change', () => { updateDimsLabel(); rebuildPreview(); });
@ -294,20 +302,18 @@ function initClipMaker(): void {
let lastExportExt = '';
// ── MP4 export ────────────────────────────────────────────────────────────
document.getElementById('clip-export-mp4')!.addEventListener('click', async () => {
// ── Export button (format based on preset) ─────────────────────────────────────
document.getElementById('clip-export-btn')!.addEventListener('click', async () => {
if (!replay) return;
await exportVideo(replay, 'mp4');
const preset = SOCIAL_PRESETS[Number(presetSelect.value)];
if (preset.format === 'gif') {
await exportGIF(replay);
} else {
await exportVideo(replay);
}
});
// ── GIF export ────────────────────────────────────────────────────────────
document.getElementById('clip-export-gif')!.addEventListener('click', async () => {
if (!replay) return;
await exportGIF(replay);
});
async function exportVideo(r: Replay, _fmt: string): Promise<void> {
async function exportVideo(r: Replay): Promise<void> {
if (!('MediaRecorder' in window)) {
alert('MediaRecorder API not supported in this browser. Please use Chrome or Firefox.');
return;
@ -319,8 +325,8 @@ function initClipMaker(): void {
const endTurn = Number(endSlider.value);
const totalFrames = endTurn - startTurn + 1;
// Determine preview scale for video (cap at 720p equivalent)
const scale = Math.min(1, 720 / Math.max(preset.width, preset.height));
// For MP4 presets, use full resolution (or cap at 1080p for performance)
const scale = Math.min(1, 1080 / Math.max(preset.width, preset.height));
const vw = Math.round(preset.width * scale);
const vh = Math.round(preset.height * scale);
@ -372,10 +378,9 @@ function initClipMaker(): void {
const endTurn = Number(endSlider.value);
const totalFrames = endTurn - startTurn + 1;
// Use small scale for GIF to keep file size manageable (max 480px)
const scale = Math.min(1, 480 / Math.max(preset.width, preset.height));
const gw = Math.round(preset.width * scale);
const gh = Math.round(preset.height * scale);
// For GIF presets, use the preset's exact dimensions (already sized appropriately)
const gw = preset.width;
const gh = preset.height;
const frameCanvas = document.createElement('canvas');
frameCanvas.width = gw;
@ -899,6 +904,7 @@ const CLIP_STYLES = `
.clip-select, .clip-select-sm { width: 100%; background: var(--bg-primary); border: 1px solid var(--border); color: var(--text-primary); padding: 8px; border-radius: 6px; font-size: 0.875rem; margin-bottom: 8px; }
.clip-select-sm { width: auto; }
.preset-dims { font-size: 0.75rem; color: var(--text-muted); }
.preset-target { font-size: 0.7rem; color: var(--text-muted); margin-top: 2px; }
.range-grid { display: grid; grid-template-columns: 1fr 2fr; gap: 8px 12px; align-items: center; font-size: 0.875rem; color: var(--text-muted); }
.range-row { display: flex; gap: 8px; align-items: center; }
.range-slider { flex: 1; }