Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function Footer() {
const [isExpanded, setIsExpanded] = useState(false);

return (
<footer className="w-full border-t border-[var(--border)] bg-[var(--bg)] text-[var(--text)] px-6 py-16 mt-20 transition-colors duration-300">
<footer className="w-full border-t border-[var(--border)] bg-[var(--bg)] text-[var(--text)] px-6 py-16 mt-20 transition-colors duration-300 overflow-x-hidden">
<div className="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-8">

{/* Brand Section */}
Expand Down Expand Up @@ -90,7 +90,7 @@ export default function Footer() {
<a href={social.href} target="_blank" rel="noopener" aria-label={social.label} className="p-2.5 rounded-lg border border-[var(--border)] bg-[var(--surface)] hover:border-[var(--accent)] hover:bg-[var(--accent-muted)] transition-all duration-200 hover:-translate-y-1 active:scale-95 flex items-center justify-center">
<span className="opacity-70 group-hover:opacity-100 transition-opacity duration-200">{social.icon}</span>
</a>
<span className="absolute -top-9 left-1/2 -translate-x-1/2 bg-[var(--text)] text-[var(--bg)] text-[9px] font-bold uppercase tracking-widest px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap pointer-events-none">
<span className="absolute -top-9 left-1/2 -translate-x-1/2 bg-[var(--text)] text-[var(--bg)] text-[9px] font-bold uppercase tracking-widest px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap max-w-xs truncate">
{social.tooltip}
</span>
</div>
Expand Down
68 changes: 68 additions & 0 deletions src/components/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
'use client';

import { useEffect, useRef, useCallback } from 'react';
import { WebGLPreview, PreviewParams, defaultParams } from '@/lib/webglPreview';

interface PreviewCanvasProps {
videoElement: HTMLVideoElement | null;
params?: PreviewParams;
width?: number;
height?: number;
}

export default function PreviewCanvas({
videoElement,
params = defaultParams,
width = 640,
height = 360,
}: PreviewCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const webglRef = useRef<WebGLPreview | null>(null);
const animFrameRef = useRef<number>(0);

useEffect(() => {
if (!canvasRef.current) return;
try {
webglRef.current = new WebGLPreview(canvasRef.current);
} catch (error) {
console.error('WebGL setup failed:', error);
}
return () => {
webglRef.current?.destroy();
cancelAnimationFrame(animFrameRef.current);
};
}, []);

const renderLoop = useCallback(() => {
if (webglRef.current && videoElement &&
!videoElement.paused && !videoElement.ended) {
webglRef.current.render(videoElement, params);
}
animFrameRef.current = requestAnimationFrame(renderLoop);
}, [videoElement, params]);

useEffect(() => {
cancelAnimationFrame(animFrameRef.current);
animFrameRef.current = requestAnimationFrame(renderLoop);
return () => cancelAnimationFrame(animFrameRef.current);
}, [renderLoop]);

useEffect(() => {
if (videoElement && webglRef.current) {
const handleSeeked = () => {
webglRef.current?.render(videoElement, params);
};
videoElement.addEventListener('seeked', handleSeeked);
return () => videoElement.removeEventListener('seeked', handleSeeked);
}
}, [videoElement, params]);

return (
<canvas
ref={canvasRef}
width={width}
height={height}
className="w-full rounded-lg bg-black"
/>
);
}
123 changes: 123 additions & 0 deletions src/lib/webglPreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
export interface PreviewParams {
brightness: number;
contrast: number;
saturation: number;
cropX: number;
cropY: number;
cropWidth: number;
cropHeight: number;
}

export const defaultParams: PreviewParams = {
brightness: 0,
contrast: 1,
saturation: 1,
cropX: 0,
cropY: 0,
cropWidth: 1,
cropHeight: 1,
};

const vertexShaderSource = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0, 1);
v_texCoord = a_texCoord;
}
`;

const fragmentShaderSource = `
precision mediump float;
uniform sampler2D u_image;
uniform float u_brightness;
uniform float u_contrast;
uniform float u_saturation;
uniform vec4 u_crop;
varying vec2 v_texCoord;
void main() {
vec2 croppedCoord = u_crop.xy + v_texCoord * u_crop.zw;
vec4 color = texture2D(u_image, croppedCoord);
color.rgb += u_brightness;
color.rgb = (color.rgb - 0.5) * u_contrast + 0.5;
float grey = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(vec3(grey), color.rgb, u_saturation);
color.rgb = clamp(color.rgb, 0.0, 1.0);
gl_FragColor = color;
}
`;

function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error('Shader error: ' + gl.getShaderInfoLog(shader));
}
return shader;
}

export class WebGLPreview {
private gl: WebGLRenderingContext;
private program: WebGLProgram;
private texture: WebGLTexture;

constructor(canvas: HTMLCanvasElement) {
const gl = canvas.getContext('webgl');
if (!gl) throw new Error('WebGL not supported');
this.gl = gl;

const vertShader = compileShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);

const program = gl.createProgram()!;
gl.attachShader(program, vertShader);
gl.attachShader(program, fragShader);
gl.linkProgram(program);
gl.useProgram(program);
this.program = program;

const positions = new Float32Array([-1,-1, 1,-1, -1,1, 1,1]);
const texCoords = new Float32Array([0,1, 1,1, 0,0, 1,0]);

const posBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer);
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
const posLoc = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);

const texBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, texBuffer);
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STATIC_DRAW);
const texLoc = gl.getAttribLocation(program, 'a_texCoord');
gl.enableVertexAttribArray(texLoc);
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0);

this.texture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}

render(videoElement: HTMLVideoElement, params: PreviewParams) {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, videoElement);
gl.uniform1f(gl.getUniformLocation(this.program, 'u_brightness'), params.brightness);
gl.uniform1f(gl.getUniformLocation(this.program, 'u_contrast'), params.contrast);
gl.uniform1f(gl.getUniformLocation(this.program, 'u_saturation'), params.saturation);
gl.uniform4f(gl.getUniformLocation(this.program, 'u_crop'),
params.cropX, params.cropY, params.cropWidth, params.cropHeight);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}

destroy() {
this.gl.deleteTexture(this.texture);
this.gl.deleteProgram(this.program);
}
}
Loading