import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Upload, Download, Play, Settings, Type, Image as ImageIcon, Video, Loader2, AlignLeft, AlignCenter, AlignRight, MonitorPlay, Film, ArrowRight, Layers, Circle } from 'lucide-react';
// --- Utility Functions ---
const hexToRgb = (hex) => {
let r = 0, g = 0, b = 0;
if (hex.length === 4) {
r = parseInt(hex[1] + hex[1], 16);
g = parseInt(hex[2] + hex[2], 16);
b = parseInt(hex[3] + hex[3], 16);
} else if (hex.length === 7) {
r = parseInt(hex.substring(1, 3), 16);
g = parseInt(hex.substring(3, 5), 16);
b = parseInt(hex.substring(5, 7), 16);
}
return [r, g, b];
};
const rgbToHex = (r, g, b) =>
"#" + ((1 << 24) + (Math.round(r) << 16) + (Math.round(g) << 8) + Math.round(b)).toString(16).slice(1);
const interpolate = (start, end, progress) => start + (end - start) * progress;
const interpolateColor = (color1, color2, progress) => {
const c1 = hexToRgb(color1);
const c2 = hexToRgb(color2);
return rgbToHex(
interpolate(c1[0], c2[0], progress),
interpolate(c1[1], c2[1], progress),
interpolate(c1[2], c2[2], progress)
);
};
const getEasedProgress = (progress, easingType) => {
let p = progress * 2;
if (p > 1) p = 2 - p;
switch (easingType) {
case 'linear': return p;
case 'ease-in': return p * p * p;
case 'ease-out': return 1 - Math.pow(1 - p, 3);
case 'ease-in-out': return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
case 'ease':
default: return (Math.sin(p * Math.PI - Math.PI / 2) + 1) / 2;
}
};
const getDimensions = (aspectRatio, resolution) => {
const res = parseInt(resolution);
if (aspectRatio === '16:9') return { w: res === 1080 ? 1920 : 1280, h: res };
if (aspectRatio === '9:16') return { w: res, h: res === 1080 ? 1920 : 1280 };
if (aspectRatio === '1:1') return { w: res, h: res };
if (aspectRatio === '3:4') return { w: res === 1080 ? 810 : 540, h: res };
if (aspectRatio === '4:5') return { w: res === 1080 ? 864 : 576, h: res };
return { w: 1920, h: 1080 };
};
// --- Main Application ---
export default function App() {
const [scriptsLoaded, setScriptsLoaded] = useState(false);
const [text, setText] = useState('MEXICO MEXICO MEXICO MEXICO MEXICO MEXICO');
const [fontFamily, setFontFamily] = useState('Inter');
const [fontDataUrl, setFontDataUrl] = useState(null);
const [duration, setDuration] = useState(6000);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
// Layout & Canvas State
const [pathMode, setPathMode] = useState('circle');
const [textAlign, setTextAlign] = useState('center');
const [verticalAlign, setVerticalAlign] = useState('center');
const [singleLine, setSingleLine] = useState(true);
const [aspectRatio, setAspectRatio] = useState('1:1');
const [resolution, setResolution] = useState('1080');
const [previewScale, setPreviewScale] = useState(1);
const [canvasMargin, setCanvasMargin] = useState(50);
// Animation & Scroll State
const [easing, setEasing] = useState('linear');
const [scrollType, setScrollType] = useState('cw');
const [scrollAngle, setScrollAngle] = useState(45);
// 3D Layers State
const [layerCount, setLayerCount] = useState(0);
const [layerColors, setLayerColors] = useState(['#0000ff', '#ff0000', '#ff8800', '#00ffff', '#00ff00', '#006600', '#8800ff', '#ff00ff']);
const [layerEffect, setLayerEffect] = useState('wave');
const [layerOffset, setLayerOffset] = useState(25);
const [layerAngle, setLayerAngle] = useState(315);
const previewWrapperRef = useRef(null);
const wrapperRef = useRef(null);
const containerRef = useRef(null);
const layerRefs = useRef([]);
// Circle Layout Refs
const circleSvgRef = useRef(null);
const circlePathRef = useRef(null);
const circleWrapperRef = useRef(null);
const circleLayerRefs = useRef([]);
const textPathRefs = useRef([]);
const animationRef = useRef(null);
const textBoundsRef = useRef({ w: 0, h: 0 });
const [config, setConfig] = useState({
scale: { val: 80, min: 50, max: 200, animate: false, bounds: [10, 1500], step: 1, isColor: false },
tracking: { val: 0.1, min: -0.05, max: 0.2, animate: false, bounds: [-0.2, 1], step: 0.01, isColor: false },
leading: { val: 1.1, min: 0.8, max: 2.0, animate: false, bounds: [0.5, 3], step: 0.1, isColor: false },
weight: { val: 900, min: 100, max: 900, animate: false, bounds: [100, 1000], step: 1, isColor: false },
width: { val: 100, min: 50, max: 150, animate: false, bounds: [50, 200], step: 1, isColor: false },
rotation: { val: 0, min: -180, max: 180, animate: false, bounds: [-360, 360], step: 1, isColor: false },
circleRadius: { val: 400, min: 200, max: 600, animate: false, bounds: [50, 1500], step: 1, isColor: false },
color: { val: '#000000', min: '#000000', max: '#ff0055', animate: false, isColor: true },
bgColor: { val: '#ffffff', min: '#ffffff', max: '#000000', animate: false, isColor: true }
});
const configRef = useRef(config);
useEffect(() => { configRef.current = config; }, [config]);
const settingsRef = useRef({
pathMode, aspectRatio, resolution, scrollType, scrollAngle, textAlign, verticalAlign,
canvasMargin, singleLine, layerCount, layerColors, layerEffect, layerOffset, layerAngle, easing
});
useEffect(() => {
settingsRef.current = {
pathMode, aspectRatio, resolution, scrollType, scrollAngle, textAlign, verticalAlign,
canvasMargin, singleLine, layerCount, layerColors, layerEffect, layerOffset, layerAngle, easing
};
}, [pathMode, aspectRatio, resolution, scrollType, scrollAngle, textAlign, verticalAlign, canvasMargin, singleLine, layerCount, layerColors, layerEffect, layerOffset, layerAngle, easing]);
// Handle Resize for WYSIWYG Canvas Scaling
useEffect(() => {
const updateScale = () => {
if (!previewWrapperRef.current) return;
const { w, h } = getDimensions(aspectRatio, resolution);
const wrapperW = previewWrapperRef.current.clientWidth;
const wrapperH = previewWrapperRef.current.clientHeight;
const scale = Math.min(wrapperW / w, wrapperH / h) * 0.95;
setPreviewScale(scale);
};
updateScale();
window.addEventListener('resize', updateScale);
return () => window.removeEventListener('resize', updateScale);
}, [aspectRatio, resolution]);
// Load external dependencies
useEffect(() => {
const loadScript = (src) => new Promise((resolve) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
document.head.appendChild(script);
});
Promise.all([
loadScript('https://cdnjs.cloudflare.com/ajax/libs/opentype.js/1.3.4/opentype.min.js'),
loadScript('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js')
]).then(() => setScriptsLoaded(true));
}, []);
// Main Live Animation Loop
useEffect(() => {
let startTime = performance.now();
const renderLoop = (time) => {
if (isExporting) return;
const currentConfig = configRef.current;
const settings = settingsRef.current;
const { w, h } = getDimensions(settings.aspectRatio, settings.resolution);
const linearProgress = ((time - startTime) % duration) / duration;
const easedProgress = getEasedProgress(linearProgress, settings.easing);
if (wrapperRef.current && containerRef.current) {
textBoundsRef.current = {
w: wrapperRef.current.offsetWidth,
h: wrapperRef.current.offsetHeight
};
const tW = textBoundsRef.current.w;
const tH = textBoundsRef.current.h;
const cScale = currentConfig.scale.animate ? interpolate(currentConfig.scale.min, currentConfig.scale.max, easedProgress) : currentConfig.scale.val;
const cTracking = currentConfig.tracking.animate ? interpolate(currentConfig.tracking.min, currentConfig.tracking.max, easedProgress) : currentConfig.tracking.val;
const cLeading = currentConfig.leading.animate ? interpolate(currentConfig.leading.min, currentConfig.leading.max, easedProgress) : currentConfig.leading.val;
const cWeight = currentConfig.weight.animate ? interpolate(currentConfig.weight.min, currentConfig.weight.max, easedProgress) : currentConfig.weight.val;
const cWidth = currentConfig.width.animate ? interpolate(currentConfig.width.min, currentConfig.width.max, easedProgress) : currentConfig.width.val;
const cRotation = currentConfig.rotation.animate ? interpolate(currentConfig.rotation.min, currentConfig.rotation.max, easedProgress) : currentConfig.rotation.val;
const cColor = currentConfig.color.animate ? interpolateColor(currentConfig.color.min, currentConfig.color.max, easedProgress) : currentConfig.color.val;
const cBgColor = currentConfig.bgColor.animate ? interpolateColor(currentConfig.bgColor.min, currentConfig.bgColor.max, easedProgress) : currentConfig.bgColor.val;
// Container scroll/rotation transforms
let translateX = 0;
let translateY = 0;
let scrollRotation = 0;
if (settings.scrollType !== 'none') {
const offX = (w + tW) / 2;
const offY = (h + tH) / 2;
switch (settings.scrollType) {
case 'ltr': translateX = interpolate(-offX, offX, linearProgress); break;
case 'rtl': translateX = interpolate(offX, -offX, linearProgress); break;
case 'ttb': translateY = interpolate(-offY, offY, linearProgress); break;
case 'btt': translateY = interpolate(offY, -offY, linearProgress); break;
case 'custom':
const rad = settings.scrollAngle * Math.PI / 180;
const dist = (Math.sqrt(w*w + h*h) + Math.sqrt(tW*tW + tH*tH)) / 2;
translateX = Math.cos(rad) * interpolate(-dist, dist, linearProgress);
translateY = Math.sin(rad) * interpolate(-dist, dist, linearProgress);
break;
case 'cw': scrollRotation = interpolate(0, 360, linearProgress); break;
case 'ccw': scrollRotation = interpolate(360, 0, linearProgress); break;
}
}
const finalRotation = cRotation + scrollRotation;
containerRef.current.style.backgroundColor = cBgColor;
const processLayerTransforms = (i, dirX, dirY) => {
let lTransX = 0;
let lTransY = 0;
let lOpacity = 1;
if (i > 0) {
const maxDist = settings.layerOffset * i;
let currentDist = maxDist;
if (settings.layerEffect === 'extrude') {
currentDist = interpolate(0, maxDist, easedProgress);
} else if (settings.layerEffect === 'reveal') {
const step = 1 / settings.layerCount;
const startP = (i - 1) * step;
const endP = i * step;
let phase = 0;
if (easedProgress > startP) phase = Math.min(1, (easedProgress - startP) / (endP - startP));
currentDist = maxDist * phase;
} else if (settings.layerEffect === 'back_reveal') {
const reverseI = settings.layerCount - i + 1;
const step = 1 / settings.layerCount;
const startP = (reverseI - 1) * step;
const endP = reverseI * step;
let phase = 0;
if (easedProgress > startP) phase = Math.min(1, (easedProgress - startP) / (endP - startP));
currentDist = maxDist * phase;
} else if (settings.layerEffect === 'step_reveal') {
const step = 1 / settings.layerCount;
const startP = (i - 1) * step;
if (easedProgress >= startP) {
currentDist = maxDist;
} else {
currentDist = 0;
lOpacity = 0;
}
}
lTransX = dirX * currentDist;
lTransY = dirY * currentDist;
if (settings.layerEffect === 'wave') {
const perpX = -dirY;
const perpY = dirX;
const waveMag = Math.sin((linearProgress * Math.PI * 2) + (i * 0.8)) * (settings.layerOffset * 1.5);
lTransX += perpX * waveMag;
lTransY += perpY * waveMag;
}
}
return { lTransX, lTransY, lOpacity };
};
const lRad = settings.layerAngle * Math.PI / 180;
const dirX = Math.cos(lRad);
const dirY = Math.sin(lRad);
if (settings.pathMode === 'circle') {
const cx = w / 2;
const cy = h / 2;
const cRadius = currentConfig.circleRadius.animate ? interpolate(currentConfig.circleRadius.min, currentConfig.circleRadius.max, easedProgress) : currentConfig.circleRadius.val;
const pathD = `M ${cx},${cy} m -${cRadius}, 0 a ${cRadius},${cRadius} 0 1,1 ${cRadius * 2},0 a ${cRadius},${cRadius} 0 1,1 -${cRadius * 2},0`;
if (circlePathRef.current) circlePathRef.current.setAttribute('d', pathD);
for (let idx = 0; idx <= settings.layerCount; idx++) {
const i = settings.layerCount - idx;
const lEl = circleLayerRefs.current[i];
if (!lEl) continue;
lEl.style.fontSize = `${cScale}px`;
lEl.style.letterSpacing = `${cTracking}em`;
lEl.style.fontVariationSettings = `"wght" ${cWeight}, "wdth" ${cWidth}`;
lEl.setAttribute('fill', i === 0 ? cColor : settings.layerColors[i - 1]);
const { lTransX, lTransY, lOpacity } = processLayerTransforms(i, dirX, dirY);
lEl.setAttribute('transform', `translate(${lTransX}, ${lTransY})`);
lEl.style.opacity = lOpacity;
}
if (circleWrapperRef.current) {
circleWrapperRef.current.setAttribute('transform', `translate(${translateX}, ${translateY}) rotate(${finalRotation} ${cx} ${cy})`);
}
} else {
// Standard Block Mode
for (let idx = 0; idx <= settings.layerCount; idx++) {
const i = settings.layerCount - idx;
const lEl = layerRefs.current[i];
if (!lEl) continue;
lEl.style.fontSize = `${cScale}px`;
lEl.style.letterSpacing = `${cTracking}em`;
lEl.style.lineHeight = cLeading;
lEl.style.fontVariationSettings = `"wght" ${cWeight}, "wdth" ${cWidth}`;
lEl.style.color = i === 0 ? cColor : settings.layerColors[i - 1];
const { lTransX, lTransY, lOpacity } = processLayerTransforms(i, dirX, dirY);
lEl.style.transform = `translate(${lTransX}px, ${lTransY}px)`;
lEl.style.opacity = lOpacity;
}
wrapperRef.current.style.transform = `translate(${translateX}px, ${translateY}px) rotate(${finalRotation}deg)`;
}
}
animationRef.current = requestAnimationFrame(renderLoop);
};
animationRef.current = requestAnimationFrame(renderLoop);
return () => cancelAnimationFrame(animationRef.current);
}, [duration, isExporting]);
// Handle Custom Font Upload
const handleFileUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setFontDataUrl(ev.target.result);
reader.readAsDataURL(file);
const arrayBuffer = await file.arrayBuffer();
const fontName = `CustomFont_${Date.now()}`;
const font = new FontFace(fontName, arrayBuffer);
await font.load();
document.fonts.add(font);
setFontFamily(fontName);
if (window.opentype) {
if (file.name.toLowerCase().endsWith('.woff2')) {
console.warn("WOFF2 parsing is not natively supported by opentype.js.");
} else {
try {
const parsed = window.opentype.parse(arrayBuffer);
if (parsed.tables.fvar) {
const newConfig = { ...configRef.current };
parsed.tables.fvar.axes.forEach(axis => {
if (axis.tag === 'wght') newConfig.weight = { ...newConfig.weight, bounds: [axis.minValue, axis.maxValue], min: axis.minValue, max: axis.maxValue, val: axis.defaultValue };
if (axis.tag === 'wdth') newConfig.width = { ...newConfig.width, bounds: [axis.minValue, axis.maxValue], min: axis.minValue, max: axis.maxValue, val: axis.defaultValue };
});
setConfig(newConfig);
}
} catch (err) { console.warn("Opentype parsing error:", err); }
}
}
};
// Generate Frame SVG
const getFrameSvgDataUrl = (progress, width, height) => {
const currentConfig = configRef.current;
const settings = settingsRef.current;
const linearProgress = progress;
const easedProgress = getEasedProgress(linearProgress, settings.easing);
const cScale = currentConfig.scale.animate ? interpolate(currentConfig.scale.min, currentConfig.scale.max, easedProgress) : currentConfig.scale.val;
const cTracking = currentConfig.tracking.animate ? interpolate(currentConfig.tracking.min, currentConfig.tracking.max, easedProgress) : currentConfig.tracking.val;
const cLeading = currentConfig.leading.animate ? interpolate(currentConfig.leading.min, currentConfig.leading.max, easedProgress) : currentConfig.leading.val;
const cWeight = currentConfig.weight.animate ? interpolate(currentConfig.weight.min, currentConfig.weight.max, easedProgress) : currentConfig.weight.val;
const cWidth = currentConfig.width.animate ? interpolate(currentConfig.width.min, currentConfig.width.max, easedProgress) : currentConfig.width.val;
const cRotation = currentConfig.rotation.animate ? interpolate(currentConfig.rotation.min, currentConfig.rotation.max, easedProgress) : currentConfig.rotation.val;
const cColor = currentConfig.color.animate ? interpolateColor(currentConfig.color.min, currentConfig.color.max, easedProgress) : currentConfig.color.val;
const cBgColor = currentConfig.bgColor.animate ? interpolateColor(currentConfig.bgColor.min, currentConfig.bgColor.max, easedProgress) : currentConfig.bgColor.val;
const tW = textBoundsRef.current.w || width;
const tH = textBoundsRef.current.h || height;
let translateX = 0;
let translateY = 0;
let scrollRotation = 0;
if (settings.scrollType !== 'none') {
const offX = (width + tW) / 2;
const offY = (height + tH) / 2;
switch (settings.scrollType) {
case 'ltr': translateX = interpolate(-offX, offX, linearProgress); break;
case 'rtl': translateX = interpolate(offX, -offX, linearProgress); break;
case 'ttb': translateY = interpolate(-offY, offY, linearProgress); break;
case 'btt': translateY = interpolate(offY, -offY, linearProgress); break;
case 'custom':
const rad = settings.scrollAngle * Math.PI / 180;
const dist = (Math.sqrt(width*width + height*height) + Math.sqrt(tW*tW + tH*tH)) / 2;
translateX = Math.cos(rad) * interpolate(-dist, dist, linearProgress);
translateY = Math.sin(rad) * interpolate(-dist, dist, linearProgress);
break;
case 'cw': scrollRotation = interpolate(0, 360, linearProgress); break;
case 'ccw': scrollRotation = interpolate(360, 0, linearProgress); break;
}
}
const finalRotation = cRotation + scrollRotation;
const fontFaceRule = fontDataUrl ? `@font-face { font-family: '${fontFamily}'; src: url('${fontDataUrl}'); }` : '';
const safeText = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const lRad = settings.layerAngle * Math.PI / 180;
const dirX = Math.cos(lRad);
const dirY = Math.sin(lRad);
const getLayerOffset = (i) => {
let lOpacity = 1, currentDist = settings.layerOffset * i;
if (settings.layerEffect === 'extrude') {
currentDist = interpolate(0, currentDist, easedProgress);
} else if (settings.layerEffect === 'reveal' || settings.layerEffect === 'back_reveal') {
const rev = settings.layerEffect === 'back_reveal';
const step = 1 / settings.layerCount;
const mappedI = rev ? (settings.layerCount - i + 1) : i;
const startP = (mappedI - 1) * step, endP = mappedI * step;
let phase = 0;
if (easedProgress > startP) phase = Math.min(1, (easedProgress - startP) / (endP - startP));
currentDist *= phase;
} else if (settings.layerEffect === 'step_reveal') {
const step = 1 / settings.layerCount;
if (easedProgress < (i - 1) * step) { currentDist = 0; lOpacity = 0; }
}
let lTransX = dirX * currentDist, lTransY = dirY * currentDist;
if (settings.layerEffect === 'wave') {
const waveMag = Math.sin((linearProgress * Math.PI * 2) + (i * 0.8)) * (settings.layerOffset * 1.5);
lTransX += -dirY * waveMag; lTransY += dirX * waveMag;
}
return { lTransX, lTransY, lOpacity };
};
let innerSvgContent = '';
if (settings.pathMode === 'circle') {
const circleText = safeText.replace(/\n/g, ' ');
const cx = width / 2;
const cy = height / 2;
const cRadius = currentConfig.circleRadius.animate ? interpolate(currentConfig.circleRadius.min, currentConfig.circleRadius.max, easedProgress) : currentConfig.circleRadius.val;
const pathD = `M ${cx},${cy} m -${cRadius}, 0 a ${cRadius},${cRadius} 0 1,1 ${cRadius * 2},0 a ${cRadius},${cRadius} 0 1,1 -${cRadius * 2},0`;
let layersHtml = '';
for (let idx = 0; idx <= settings.layerCount; idx++) {
const i = settings.layerCount - idx;
const { lTransX, lTransY, lOpacity } = i > 0 ? getLayerOffset(i) : { lTransX: 0, lTransY: 0, lOpacity: 1 };
const color = i === 0 ? cColor : settings.layerColors[i - 1];
layersHtml += `
<text fill="${color}"
style="font-size: ${cScale}px; letter-spacing: ${cTracking}em; font-variation-settings: 'wght' ${cWeight}, 'wdth' ${cWidth}; opacity: ${lOpacity}; font-family: '${fontFamily}', sans-serif; white-space: ${settings.singleLine ? 'nowrap' : 'pre-wrap'};"
transform="translate(${lTransX}, ${lTransY})">
<textPath href="#export-circle-path">${circleText}</textPath>
</text>
`;
}
innerSvgContent = `
<defs>
<path id="export-circle-path" d="${pathD}" />
</defs>
<g transform="translate(${translateX}, ${translateY}) rotate(${finalRotation} ${cx} ${cy})">
${layersHtml}
</g>
`;
} else {
const standardText = safeText.replace(/\n/g, '<br/>');
const justifyContent = settings.scrollType !== 'none' && !['cw','ccw'].includes(settings.scrollType) ? 'center' : (settings.textAlign === 'left' ? 'flex-start' : settings.textAlign === 'right' ? 'flex-end' : 'center');
const alignItems = settings.scrollType !== 'none' && !['cw','ccw'].includes(settings.scrollType) ? 'center' : (settings.verticalAlign === 'top' ? 'flex-start' : settings.verticalAlign === 'bottom' ? 'flex-end' : 'center');
const whiteSpace = settings.singleLine ? 'nowrap' : 'pre-wrap';
let layersHtml = '';
for (let idx = 0; idx <= settings.layerCount; idx++) {
const i = settings.layerCount - idx;
const { lTransX, lTransY, lOpacity } = i > 0 ? getLayerOffset(i) : { lTransX: 0, lTransY: 0, lOpacity: 1 };
const color = i === 0 ? cColor : settings.layerColors[i - 1];
layersHtml += `
<div style="
position: ${i === 0 ? 'relative' : 'absolute'};
top: 0; left: 0; right: 0; bottom: 0;
font-size: ${cScale}px; letter-spacing: ${cTracking}em; line-height: ${cLeading};
font-variation-settings: 'wght' ${cWeight}, 'wdth' ${cWidth};
white-space: ${whiteSpace};
color: ${color};
display: flex;
align-items: center;
opacity: ${lOpacity};
justify-content: ${settings.textAlign === 'left' ? 'flex-start' : settings.textAlign === 'right' ? 'flex-end' : 'center'};
transform: translate(${lTransX}px, ${lTransY}px);
">${standardText}</div>
`;
}
innerSvgContent = `
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml" style="
display: flex; justify-content: ${justifyContent}; align-items: ${alignItems};
width: 100%; height: 100%; margin: 0; padding: ${settings.canvasMargin}px; box-sizing: border-box;
font-family: '${fontFamily}', sans-serif;
text-align: ${settings.textAlign};
overflow: hidden;
">
<div style="transform: translate(${translateX}px, ${translateY}px) rotate(${finalRotation}deg); position: relative; flex-shrink: 0;">
${layersHtml}
</div>
</div>
</foreignObject>
`;
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<style>${fontFaceRule}</style>
<rect width="100%" height="100%" fill="${cBgColor}" />
${innerSvgContent}
</svg>
`;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
};
const drawSvgToCanvas = (svgUrl, canvas, ctx) => {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
resolve();
};
img.src = svgUrl;
});
};
// Export GIF
const exportGIF = async () => {
if (!window.GIF) return alert("GIF library not loaded yet.");
setIsExporting(true);
setExportProgress(0);
const { w: width, h: height } = getDimensions(aspectRatio, resolution);
const fps = 20;
const frames = Math.floor((duration / 1000) * fps);
const delay = 1000 / fps;
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const workerBlob = new Blob([`importScripts('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js');`], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(workerBlob);
const gif = new window.GIF({
workers: 2,
quality: 10,
width, height,
workerScript: workerUrl
});
for (let i = 0; i < frames; i++) {
const progress = i / frames;
const svgUrl = getFrameSvgDataUrl(progress, width, height);
await drawSvgToCanvas(svgUrl, canvas, ctx);
gif.addFrame(ctx, { copy: true, delay });
setExportProgress((i / frames) * 50);
}
gif.on('progress', p => setExportProgress(50 + (p * 50)));
gif.on('finished', blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `typography-${aspectRatio.replace(':','x')}-${resolution}p.gif`;
a.click();
setIsExporting(false);
URL.revokeObjectURL(workerUrl);
});
gif.render();
};
// Export MP4/WebM
const exportVideo = async () => {
setIsExporting(true);
setExportProgress(0);
const { w: width, h: height } = getDimensions(aspectRatio, resolution);
const fps = 60;
const frames = Math.floor((duration / 1000) * fps);
const canvas = document.createElement('canvas');
canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let mimeType = 'video/mp4';
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm;codecs=vp9';
if (!MediaRecorder.isTypeSupported(mimeType)) mimeType = 'video/webm';
const stream = canvas.captureStream(0);
const track = stream.getVideoTracks()[0];
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 8000000 });
const chunks = [];
recorder.ondataavailable = e => chunks.push(e.data);
recorder.onstop = () => {
const blob = new Blob(chunks, { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `typography-${aspectRatio.replace(':','x')}-${resolution}p.${mimeType.includes('mp4') ? 'mp4' : 'webm'}`;
a.click();
setIsExporting(false);
};
recorder.start();
for (let i = 0; i <= frames; i++) {
const progress = i / frames;
const svgUrl = getFrameSvgDataUrl(progress, width, height);
await drawSvgToCanvas(svgUrl, canvas, ctx);
track.requestFrame();
setExportProgress((i / frames) * 100);
await new Promise(r => setTimeout(r, 10));
}
recorder.stop();
};
// UI Components
const updateConfig = (key, field, value) => {
setConfig(prev => ({ ...prev, [key]: { ...prev[key], [field]: value } }));
};
const renderControl = (label, key) => {
const item = config[key];
return (
<div className="mb-6 bg-neutral-800 p-4 rounded-xl border border-neutral-700">
<div className="flex justify-between items-center mb-3">
<label className="text-sm font-semibold text-neutral-200">{label}</label>
<label className="flex items-center text-xs text-neutral-400 cursor-pointer">
<input
type="checkbox"
checked={item.animate}
onChange={e => updateConfig(key, 'animate', e.target.checked)}
className="mr-2 accent-blue-500 w-4 h-4 rounded border-neutral-600 bg-neutral-700"
/>
Animate
</label>
</div>
{!item.isColor ? (
<div className="space-y-3">
{!item.animate ? (
<div className="flex items-center gap-3">
<span className="text-xs text-neutral-500 w-8">Val</span>
<input type="range" min={item.bounds[0]} max={item.bounds[1]} step={item.step} value={item.val} onChange={e => updateConfig(key, 'val', parseFloat(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-10 text-right">{item.val}</span>
</div>
) : (
<>
<div className="flex items-center gap-3">
<span className="text-xs text-neutral-500 w-8">Min</span>
<input type="range" min={item.bounds[0]} max={item.bounds[1]} step={item.step} value={item.min} onChange={e => updateConfig(key, 'min', parseFloat(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-10 text-right">{item.min}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-neutral-500 w-8">Max</span>
<input type="range" min={item.bounds[0]} max={item.bounds[1]} step={item.step} value={item.max} onChange={e => updateConfig(key, 'max', parseFloat(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-10 text-right">{item.max}</span>
</div>
</>
)}
</div>
) : (
<div className="flex items-center gap-4">
{!item.animate ? (
<input type="color" value={item.val} onChange={e => updateConfig(key, 'val', e.target.value)} className="w-full h-8 rounded cursor-pointer border-0 bg-transparent" />
) : (
<>
<div className="flex-1">
<div className="text-[10px] text-neutral-500 mb-1 uppercase tracking-wider">Start</div>
<input type="color" value={item.min} onChange={e => updateConfig(key, 'min', e.target.value)} className="w-full h-8 rounded cursor-pointer border-0 bg-transparent" />
</div>
<div className="flex-1">
<div className="text-[10px] text-neutral-500 mb-1 uppercase tracking-wider">End</div>
<input type="color" value={item.max} onChange={e => updateConfig(key, 'max', e.target.value)} className="w-full h-8 rounded cursor-pointer border-0 bg-transparent" />
</div>
</>
)}
</div>
)}
</div>
);
};
return (
<div className="flex h-screen bg-neutral-950 text-white font-sans overflow-hidden">
{/* Sidebar Controls */}
<div className="w-80 flex-shrink-0 bg-neutral-900 border-r border-neutral-800 flex flex-col h-full overflow-y-auto">
<div className="p-6 border-b border-neutral-800 flex items-center gap-3 sticky top-0 bg-neutral-900 z-20">
<Type className="w-6 h-6 text-blue-400" />
<h1 className="text-xl font-bold tracking-tight">Kinetic Type</h1>
</div>
<div className="p-6 space-y-6">
{/* Text & Font Section */}
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<label className="text-xs font-semibold text-neutral-500 uppercase tracking-wider block">Text</label>
{/* Text Options Controls */}
<div className="flex items-center gap-2">
<select
value={verticalAlign}
onChange={e => setVerticalAlign(e.target.value)}
className="bg-neutral-800 text-xs font-medium text-neutral-300 border border-neutral-700 rounded p-1 outline-none"
disabled={pathMode === 'circle'}
>
<option value="top">Top</option>
<option value="center">Middle</option>
<option value="bottom">Bottom</option>
</select>
<div className={`flex bg-neutral-800 rounded p-0.5 border border-neutral-700 ${pathMode === 'circle' ? 'opacity-50 pointer-events-none' : ''}`}>
<button onClick={() => setTextAlign('left')} className={`p-1 rounded ${textAlign === 'left' ? 'bg-neutral-600 text-white' : 'text-neutral-400 hover:text-white'}`}><AlignLeft className="w-3.5 h-3.5" /></button>
<button onClick={() => setTextAlign('center')} className={`p-1 rounded ${textAlign === 'center' ? 'bg-neutral-600 text-white' : 'text-neutral-400 hover:text-white'}`}><AlignCenter className="w-3.5 h-3.5" /></button>
<button onClick={() => setTextAlign('right')} className={`p-1 rounded ${textAlign === 'right' ? 'bg-neutral-600 text-white' : 'text-neutral-400 hover:text-white'}`}><AlignRight className="w-3.5 h-3.5" /></button>
</div>
</div>
</div>
<div className="mb-2">
<label className="flex items-center text-xs text-neutral-400 cursor-pointer">
<input
type="checkbox"
checked={singleLine}
onChange={e => setSingleLine(e.target.checked)}
className="mr-1.5 accent-blue-500 w-3.5 h-3.5 rounded border-neutral-600 bg-neutral-700"
/>
No Wrap (Single Line)
</label>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-sm text-neutral-200 focus:outline-none focus:border-blue-500 resize-none h-24"
/>
</div>
<div>
<label className="text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-2 block">Custom Font</label>
<label className="flex items-center justify-center w-full gap-2 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 hover:border-neutral-600 transition-colors rounded-lg p-3 cursor-pointer group">
<Upload className="w-4 h-4 text-neutral-400 group-hover:text-blue-400 transition-colors" />
<span className="text-sm font-medium">{fontDataUrl ? fontFamily : 'Upload .ttf / .woff'}</span>
<input type="file" accept=".ttf,.otf,.woff,.woff2" className="hidden" onChange={handleFileUpload} />
</label>
</div>
</div>
<hr className="border-neutral-800" />
{/* Path Mode Layout Setup */}
<div>
<label className="flex items-center gap-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-4">
<Circle className="w-4 h-4" /> Shape Path
</label>
<div className="flex bg-neutral-800 rounded-lg p-1 mb-4 border border-neutral-700">
<button onClick={() => setPathMode('none')} className={`flex-1 text-xs py-1.5 rounded-md transition-colors ${pathMode === 'none' ? 'bg-neutral-600 text-white shadow-sm' : 'text-neutral-400 hover:text-white'}`}>Standard Block</button>
<button onClick={() => setPathMode('circle')} className={`flex-1 text-xs py-1.5 rounded-md transition-colors ${pathMode === 'circle' ? 'bg-neutral-600 text-white shadow-sm' : 'text-neutral-400 hover:text-white'}`}>Circle Path</button>
</div>
{pathMode === 'circle' && (
<div className="animate-in fade-in slide-in-from-top-2">
{renderControl('Circle Radius (px)', 'circleRadius')}
</div>
)}
</div>
<hr className="border-neutral-800" />
{/* 3D Layers Setup */}
<div>
<label className="flex items-center gap-2 text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-4">
<Layers className="w-4 h-4" /> 3D Stacked Layers
</label>
<div className="bg-neutral-800 p-4 rounded-xl border border-neutral-700">
<div className="flex items-center gap-3 mb-4">
<span className="text-xs text-neutral-400 w-12">Layers</span>
<input type="range" min="0" max="8" step="1" value={layerCount} onChange={e => setLayerCount(parseInt(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-4 text-right">{layerCount}</span>
</div>
{layerCount > 0 && (
<>
<div className="mb-4">
<label className="text-[10px] text-neutral-400 mb-1 block">Animation Effect</label>
<select value={layerEffect} onChange={e => setLayerEffect(e.target.value)} className="w-full bg-neutral-900 border border-neutral-700 rounded p-2 text-sm text-white focus:outline-none focus:border-blue-500">
<option value="static">Static Block</option>
<option value="extrude">Extrude In/Out</option>
<option value="wave">Waving Stack</option>
<option value="reveal">Sequential Reveal</option>
<option value="back_reveal">Sequential Back Reveal</option>
<option value="step_reveal">Sequential Reveal (No Transition)</option>
</select>
</div>
<div className="flex items-center gap-3 mb-4">
<span className="text-xs text-neutral-400 w-12">Depth</span>
<input type="range" min="1" max="150" step="1" value={layerOffset} onChange={e => setLayerOffset(parseInt(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-8 text-right">{layerOffset}px</span>
</div>
<div className="flex items-center gap-3 mb-4">
<span className="text-xs text-neutral-400 w-12">Angle</span>
<input type="range" min="0" max="360" step="1" value={layerAngle} onChange={e => setLayerAngle(parseInt(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-8 text-right">{layerAngle}°</span>
</div>
<div className="space-y-2">
<label className="text-[10px] text-neutral-400 mb-1 block">Layer Colors (Front to Back)</label>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: layerCount }).map((_, i) => (
<input
key={i}
type="color"
value={layerColors[i] || '#ffffff'}
onChange={e => {
const newColors = [...layerColors];
newColors[i] = e.target.value;
setLayerColors(newColors);
}}
className="w-full h-8 rounded cursor-pointer border-0 bg-transparent"
title={`Layer ${i+1}`}
/>
))}
</div>
</div>
</>
)}
</div>
</div>
<hr className="border-neutral-800" />
{/* Export Settings Setup */}
<div>
<label className="text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-4 block">Export Setup</label>
<div className="flex gap-3 mb-4">
<div className="flex-1">
<label className="text-[10px] text-neutral-400 mb-1 block">Aspect Ratio</label>
<select value={aspectRatio} onChange={e => setAspectRatio(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded p-2 text-sm text-white focus:outline-none focus:border-blue-500">
<option value="16:9">16:9 Landscape</option>
<option value="9:16">9:16 Portrait</option>
<option value="1:1">1:1 Square</option>
<option value="3:4">3:4 Portrait</option>
<option value="4:5">4:5 Portrait</option>
</select>
</div>
<div className="flex-1">
<label className="text-[10px] text-neutral-400 mb-1 block">Resolution</label>
<select value={resolution} onChange={e => setResolution(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded p-2 text-sm text-white focus:outline-none focus:border-blue-500">
<option value="720">720p</option>
<option value="1080">1080p</option>
</select>
</div>
</div>
<div className="mb-4">
<label className="text-[10px] text-neutral-400 mb-1 block">Canvas Margin (px)</label>
<div className="flex items-center gap-3">
<input type="range" min="0" max="500" step="10" value={canvasMargin} onChange={e => setCanvasMargin(parseInt(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-10 text-right">{canvasMargin}px</span>
</div>
</div>
</div>
<hr className="border-neutral-800" />
{/* Animation Setup */}
<div>
<label className="text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-4 block">Animation Loop</label>
<div className="flex items-center gap-3 mb-4">
<span className="text-xs text-neutral-400 w-12">Speed</span>
<input type="range" min="500" max="10000" step="100" value={duration} onChange={e => setDuration(parseInt(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-10 text-right">{(duration/1000).toFixed(1)}s</span>
</div>
<div className="mb-6">
<label className="text-[10px] text-neutral-400 mb-1 block">Easing Progress Curve</label>
<select value={easing} onChange={e => setEasing(e.target.value)} className="w-full bg-neutral-900 border border-neutral-700 rounded p-2 text-sm text-white focus:outline-none focus:border-blue-500">
<option value="ease">Ease (Smooth)</option>
<option value="ease-in">Ease In</option>
<option value="ease-out">Ease Out</option>
<option value="ease-in-out">Ease In-Out</option>
<option value="linear">Linear</option>
</select>
</div>
{/* Continuous Scroll Settings */}
<div className="bg-neutral-800 p-4 rounded-xl border border-neutral-700 mb-6">
<label className="text-sm font-semibold text-neutral-200 mb-3 block">Continuous Scroll</label>
<select value={scrollType} onChange={e => setScrollType(e.target.value)} className="w-full bg-neutral-900 border border-neutral-700 rounded p-2 text-sm text-white focus:outline-none focus:border-blue-500 mb-3">
<option value="none">None</option>
<option value="ltr">Left to Right</option>
<option value="rtl">Right to Left</option>
<option value="ttb">Top to Bottom</option>
<option value="btt">Bottom to Top</option>
<option value="custom">Custom Angle...</option>
<option value="cw">Spin Clockwise ↻</option>
<option value="ccw">Spin Counter-Clockwise ↺</option>
</select>
{scrollType === 'custom' && (
<div className="flex items-center gap-3">
<span className="text-xs text-neutral-500 w-8">Angle</span>
<input type="range" min="0" max="360" step="1" value={scrollAngle} onChange={e => setScrollAngle(parseInt(e.target.value))} className="flex-1 accent-blue-500" />
<span className="text-xs text-neutral-300 w-10 text-right">{scrollAngle}°</span>
</div>
)}
</div>
</div>
{/* Controls */}
<div>
<label className="text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-4 block">Typography Settings</label>
{renderControl('Scale (px)', 'scale')}
{renderControl('Tracking (em)', 'tracking')}
{renderControl('Leading', 'leading')}
{renderControl('Rotation (deg)', 'rotation')}
</div>
<div>
<label className="text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-4 block">Variable Axes</label>
{renderControl('Weight (wght)', 'weight')}
{renderControl('Width (wdth)', 'width')}
</div>
<div>
<label className="text-xs font-semibold text-neutral-500 uppercase tracking-wider mb-4 block">Colors</label>
{renderControl('Font Color', 'color')}
{renderControl('Background', 'bgColor')}
</div>
</div>
</div>
{/* Main Preview Area */}
<div className="flex-1 flex flex-col relative bg-neutral-950">
{/* Top bar with export actions */}
<div className="absolute top-0 w-full p-4 flex justify-end gap-3 z-10 pointer-events-none">
<div className="pointer-events-auto flex gap-3">
<button
onClick={exportGIF}
disabled={isExporting || !scriptsLoaded}
className="flex items-center gap-2 bg-neutral-800/80 backdrop-blur border border-neutral-700 hover:border-neutral-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50"
>
<ImageIcon className="w-4 h-4" /> Export GIF
</button>
<button
onClick={exportVideo}
disabled={isExporting || !scriptsLoaded}
className="flex items-center gap-2 bg-blue-600/90 backdrop-blur hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all shadow-lg disabled:opacity-50"
>
<Video className="w-4 h-4" /> Export MP4
</button>
</div>
</div>
{/* Export Progress Overlay */}
{isExporting && (
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center flex-col">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<div className="text-lg font-bold mb-2">Rendering Animation...</div>
<div className="text-sm text-neutral-400 mb-4">{aspectRatio.replace(':', 'x')} @ {resolution}p</div>
<div className="w-64 bg-neutral-800 rounded-full h-2 mb-2 overflow-hidden">
<div className="bg-blue-500 h-full transition-all duration-200" style={{ width: `${exportProgress}%` }} />
</div>
<div className="text-neutral-400 text-sm">{Math.round(exportProgress)}%</div>
</div>
)}
{/* Scaled WYSIWYG Rendering Canvas */}
<div ref={previewWrapperRef} className="flex-1 overflow-hidden flex items-center justify-center relative">
<div className="absolute inset-0 pointer-events-none flex items-center justify-center opacity-30">
<div style={{ width: getDimensions(aspectRatio, resolution).w * previewScale, height: getDimensions(aspectRatio, resolution).h * previewScale }} className="border border-dashed border-neutral-500 rounded-sm" />
</div>
<div
ref={containerRef}
style={{
width: getDimensions(aspectRatio, resolution).w,
height: getDimensions(aspectRatio, resolution).h,
transform: `scale(${previewScale})`,
transformOrigin: 'center'
}}
className="flex items-center overflow-hidden shadow-2xl relative"
>
{/* Standard Block Rendering */}
<div
style={{
display: pathMode === 'none' ? 'flex' : 'none',
width: '100%',
height: '100%',
padding: `${canvasMargin}px`,
boxSizing: 'border-box',
alignItems: scrollType !== 'none' && !['cw','ccw'].includes(scrollType) ? 'center' : (verticalAlign === 'top' ? 'flex-start' : verticalAlign === 'bottom' ? 'flex-end' : 'center'),
justifyContent: scrollType !== 'none' && !['cw','ccw'].includes(scrollType) ? 'center' : (textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center'),
}}
>
<div ref={wrapperRef} style={{ position: 'relative' }} className="will-change-transform flex-shrink-0">
{Array.from({ length: layerCount + 1 }).map((_, idx) => {
const i = layerCount - idx;
return (
<div
key={`std-${i}`}
ref={el => layerRefs.current[i] = el}
style={{
position: i === 0 ? 'relative' : 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
fontFamily: fontFamily,
textAlign: textAlign,
whiteSpace: singleLine ? 'nowrap' : 'pre-wrap',
display: 'flex',
alignItems: 'center',
justifyContent: textAlign === 'left' ? 'flex-start' : textAlign === 'right' ? 'flex-end' : 'center',
}}
className="will-change-transform"
>
{text}
</div>
)
})}
</div>
</div>
{/* SVG Circle Path Rendering */}
<div style={{ display: pathMode === 'circle' ? 'block' : 'none', position: 'absolute', inset: 0 }}>
<svg ref={circleSvgRef} width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs>
<path ref={circlePathRef} id="live-circle-path" />
</defs>
<g ref={circleWrapperRef}>
{Array.from({ length: layerCount + 1 }).map((_, idx) => {
const i = layerCount - idx;
return (
<text
key={`cir-${i}`}
ref={el => circleLayerRefs.current[i] = el}
style={{ fontFamily: fontFamily, whiteSpace: singleLine ? 'nowrap' : 'pre-wrap' }}
>
<textPath ref={el => textPathRefs.current[i] = el} href="#live-circle-path" startOffset="0%">
{text.replace(/\n/g, ' ')}
</textPath>
</text>
);
})}
</g>
</svg>
</div>
</div>
</div>
</div>
</div>
);
}