|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Pixel Photo Booth</title> |
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
|
<link rel="preconnect" href="https://fonts.googleapis.com"> |
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> |
|
|
|
<link href="https://fonts.googleapis.com/css2?family=Boldonse&display=swap" rel="stylesheet"> |
|
<style> |
|
|
|
.pixel-border { |
|
border: 4px solid #000; |
|
image-rendering: pixelated; |
|
object-fit: cover; |
|
} |
|
|
|
.pixel-button { |
|
position: relative; |
|
background: #ff3366; |
|
color: white; |
|
border: 4px solid #000; |
|
box-shadow: 4px 4px 0 #000; |
|
transition: all 0.1s ease; |
|
font-family: 'Courier New', monospace; |
|
font-weight: bold; |
|
text-transform: uppercase; |
|
letter-spacing: 1px; |
|
} |
|
|
|
.pixel-button:hover { |
|
transform: translate(2px, 2px); |
|
box-shadow: 2px 2px 0 #000; |
|
} |
|
|
|
.pixel-button:active { |
|
transform: translate(4px, 4px); |
|
box-shadow: 0 0 0 #000; |
|
} |
|
|
|
.pixel-text { |
|
font-family: 'Courier New', monospace; |
|
text-shadow: 0px 0px 0 rgba(0,0,0,0.2); |
|
} |
|
|
|
|
|
#landing h1 { |
|
font-family: 'Boldonse', sans-serif; |
|
text-shadow: 0px 0px 0 rgba(0,0,0,0.3); |
|
} |
|
|
|
.pixel-bg { |
|
background-color: #ffcc00; |
|
background-image: |
|
linear-gradient(45deg, #ff9933 25%, transparent 25%), |
|
linear-gradient(-45deg, #ff9933 25%, transparent 25%), |
|
linear-gradient(45deg, transparent 75%, #ff9933 75%), |
|
linear-gradient(-45deg, transparent 75%, #ff9933 75%); |
|
background-size: 20px 20px; |
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0px; |
|
} |
|
|
|
.pixel-frame { |
|
border: 8px solid #000; |
|
position: relative; |
|
background: white; |
|
box-shadow: 8px 8px 0 rgba(0,0,0,0.3); |
|
} |
|
|
|
.bw-filter { |
|
filter: grayscale(100%) contrast(120%); |
|
} |
|
|
|
@keyframes flash { |
|
0% { opacity: 0; } |
|
10% { opacity: 1; } |
|
100% { opacity: 0; } |
|
} |
|
|
|
.flash-effect { |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
width: 100%; |
|
height: 100%; |
|
background-color: white; |
|
z-index: 999; |
|
pointer-events: none; |
|
animation: flash 0.3s ease-out; |
|
display: none; |
|
} |
|
|
|
.counter { |
|
font-size: 5rem; |
|
color: white; |
|
text-shadow: 4px 4px 0 #000; |
|
position: fixed; |
|
top: 50%; |
|
left: 50%; |
|
transform: translate(-50%, -50%); |
|
z-index: 998; |
|
display: none; |
|
} |
|
|
|
#resultsView .photo-strip { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 16px; |
|
padding: 16px; |
|
} |
|
|
|
#resultsView .photo-container { |
|
position: relative; |
|
width: 100%; |
|
border: 4px solid #000; |
|
aspect-ratio: 3/4; |
|
overflow: hidden; |
|
} |
|
|
|
#resultsView img { |
|
width: 100%; |
|
height: 100%; |
|
display: block; |
|
object-fit: cover; |
|
image-rendering: pixelated; |
|
} |
|
</style> |
|
</head> |
|
<body class="min-h-screen pixel-bg flex flex-col items-center justify-center p-4"> |
|
|
|
<div class="flash-effect" id="flash"></div> |
|
|
|
|
|
<div class="counter" id="counter">3</div> |
|
|
|
|
|
<div id="landing" class="text-center"> |
|
|
|
<h1 class="text-4xl md:text-6xl font-bold mb-8">PHOTO BOOTH</h1> |
|
<div class="pixel-frame p-8 mb-8 max-w-md mx-auto"> |
|
<img src="https://firebasestorage.googleapis.com/v0/b/ekoguides-e657a.appspot.com/o/others%2Fphoto-booth-cover.jpg?alt=media&token=3279c221-a4ab-442d-900e-45ffa5fb5879" alt="Photo Booth Example" class="w-full h-auto mb-4 bw-filter"> |
|
<p class="pixel-text text-lg mb-4">Take 4 retro selfies with our pixel photo booth!</p> |
|
</div> |
|
<button id="startBtn" class="pixel-button px-8 py-4 text-xl md:text-2xl mb-4"> |
|
<i class="fas fa-camera mr-2"></i> START PHOTO BOOTH |
|
</button> |
|
<p class="pixel-text text-sm">(Uses your front camera)</p> |
|
</div> |
|
|
|
|
|
<div id="cameraView" class="hidden w-full max-w-md"> |
|
<div class="pixel-frame overflow-hidden mb-4"> |
|
|
|
<video id="video" autoplay playsinline class="w-full h-auto" style="image-rendering: pixelated;"></video> |
|
</div> |
|
<button id="captureBtn" class="pixel-button px-8 py-4 text-xl w-full mb-2"> |
|
<i class="fas fa-camera-retro mr-2"></i> TAKE PHOTO (1/4) |
|
</button> |
|
<button id="cancelBtn" class="pixel-button px-8 py-4 text-xl w-full bg-gray-500"> |
|
<i class="fas fa-times mr-2"></i> CANCEL |
|
</button> |
|
</div> |
|
|
|
|
|
<div id="resultsView" class="hidden w-full max-w-md"> |
|
<div class="pixel-frame p-4 mb-4"> |
|
<div class="photo-strip"> |
|
<div class="photo-container"> |
|
<img id="photo1" class="bw-filter" alt="Photo 1"> |
|
</div> |
|
<div class="photo-container"> |
|
<img id="photo2" class="bw-filter" alt="Photo 2"> |
|
</div> |
|
<div class="photo-container"> |
|
<img id="photo3" class="bw-filter" alt="Photo 3"> |
|
</div> |
|
<div class="photo-container"> |
|
<img id="photo4" class="bw-filter" alt="Photo 4"> |
|
</div> |
|
</div> |
|
</div> |
|
<div class="flex gap-2"> |
|
<button id="saveBtn" class="pixel-button px-4 py-3 text-lg flex-1"> |
|
<i class="fas fa-save mr-2"></i> SAVE |
|
</button> |
|
<button id="retakeBtn" class="pixel-button px-4 py-3 text-lg flex-1 bg-gray-500"> |
|
<i class="fas fa-redo mr-2"></i> RETAKE |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
|
|
const landing = document.getElementById('landing'); |
|
const cameraView = document.getElementById('cameraView'); |
|
const resultsView = document.getElementById('resultsView'); |
|
const video = document.getElementById('video'); |
|
const captureBtn = document.getElementById('captureBtn'); |
|
const startBtn = document.getElementById('startBtn'); |
|
const cancelBtn = document.getElementById('cancelBtn'); |
|
const saveBtn = document.getElementById('saveBtn'); |
|
const retakeBtn = document.getElementById('retakeBtn'); |
|
const flash = document.getElementById('flash'); |
|
const counter = document.getElementById('counter'); |
|
const photo1 = document.getElementById('photo1'); |
|
const photo2 = document.getElementById('photo2'); |
|
const photo3 = document.getElementById('photo3'); |
|
const photo4 = document.getElementById('photo4'); |
|
|
|
|
|
let stream = null; |
|
let photosTaken = 0; |
|
let photoData = []; |
|
|
|
|
|
startBtn.addEventListener('click', async () => { |
|
try { |
|
stream = await navigator.mediaDevices.getUserMedia({ |
|
video: { |
|
facingMode: 'user', |
|
width: { ideal: 640 }, |
|
height: { ideal: 480 } |
|
}, |
|
audio: false |
|
}); |
|
|
|
video.srcObject = stream; |
|
landing.classList.add('hidden'); |
|
cameraView.classList.remove('hidden'); |
|
} catch (err) { |
|
console.error("Error accessing camera:", err); |
|
alert("Could not access the camera. Please make sure you've granted camera permissions."); |
|
} |
|
}); |
|
|
|
|
|
captureBtn.addEventListener('click', () => { |
|
startCountdown(); |
|
}); |
|
|
|
function startCountdown() { |
|
let count = 3; |
|
counter.textContent = count; |
|
counter.style.display = 'block'; |
|
|
|
const countdown = setInterval(() => { |
|
count--; |
|
counter.textContent = count; |
|
|
|
if (count <= 0) { |
|
clearInterval(countdown); |
|
counter.style.display = 'none'; |
|
takePhoto(); |
|
} |
|
}, 1000); |
|
} |
|
|
|
function takePhoto() { |
|
flash.style.display = 'block'; |
|
setTimeout(() => { |
|
flash.style.display = 'none'; |
|
}, 300); |
|
|
|
const canvas = document.createElement('canvas'); |
|
const ctx = canvas.getContext('2d'); |
|
|
|
|
|
canvas.width = 300; |
|
canvas.height = 400; |
|
|
|
const videoAspect = video.videoWidth / video.videoHeight; |
|
const targetAspect = 3/4; |
|
|
|
let srcX = 0, srcY = 0, srcWidth = video.videoWidth, srcHeight = video.videoHeight; |
|
|
|
if (videoAspect > targetAspect) { |
|
srcWidth = video.videoHeight * targetAspect; |
|
srcX = (video.videoWidth - srcWidth) / 2; |
|
} else { |
|
srcHeight = video.videoWidth / targetAspect; |
|
srcY = (video.videoHeight - srcHeight) / 2; |
|
} |
|
|
|
|
|
|
|
ctx.imageSmoothingEnabled = false; |
|
ctx.drawImage( |
|
video, |
|
srcX, srcY, srcWidth, srcHeight, |
|
0, 0, canvas.width, canvas.height |
|
); |
|
|
|
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); |
|
const data = imageData.data; |
|
for (let i = 0; i < data.length; i += 4) { |
|
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3; |
|
data[i] = avg; |
|
data[i + 1] = avg; |
|
data[i + 2] = avg; |
|
} |
|
ctx.putImageData(imageData, 0, 0); |
|
|
|
const photoUrl = canvas.toDataURL('image/png'); |
|
photoData.push(photoUrl); |
|
|
|
photosTaken++; |
|
|
|
if (photosTaken < 4) { |
|
captureBtn.innerHTML = `<i class="fas fa-camera-retro mr-2"></i> TAKE PHOTO (${photosTaken + 1}/4)`; |
|
} else { |
|
showResults(); |
|
} |
|
} |
|
|
|
function showResults() { |
|
if (stream) { |
|
stream.getTracks().forEach(track => track.stop()); |
|
} |
|
photo1.src = photoData[0]; |
|
photo2.src = photoData[1]; |
|
photo3.src = photoData[2]; |
|
photo4.src = photoData[3]; |
|
|
|
cameraView.classList.add('hidden'); |
|
resultsView.classList.remove('hidden'); |
|
} |
|
|
|
|
|
cancelBtn.addEventListener('click', () => { |
|
if (stream) { |
|
stream.getTracks().forEach(track => track.stop()); |
|
} |
|
photosTaken = 0; |
|
photoData = []; |
|
captureBtn.innerHTML = `<i class="fas fa-camera-retro mr-2"></i> TAKE PHOTO (1/4)`; |
|
cameraView.classList.add('hidden'); |
|
resultsView.classList.add('hidden'); |
|
landing.classList.remove('hidden'); |
|
}); |
|
|
|
|
|
retakeBtn.addEventListener('click', () => { |
|
photosTaken = 0; |
|
photoData = []; |
|
captureBtn.innerHTML = `<i class="fas fa-camera-retro mr-2"></i> TAKE PHOTO (1/4)`; |
|
resultsView.classList.add('hidden'); |
|
landing.classList.add('hidden'); |
|
|
|
|
|
startBtn.click(); |
|
}); |
|
|
|
|
|
saveBtn.addEventListener('click', async () => { |
|
const pixelFrame = document.querySelector('#resultsView .pixel-frame'); |
|
|
|
|
|
const computedStyle = window.getComputedStyle(pixelFrame); |
|
const frameBorderWidth = parseInt(computedStyle.borderTopWidth, 10); |
|
const framePadding = parseInt(computedStyle.paddingTop, 10); |
|
|
|
|
|
const photoBorderWidth = 4; |
|
const gapBetweenPhotos = 16; |
|
|
|
|
|
const pixelFrameRect = pixelFrame.getBoundingClientRect(); |
|
|
|
|
|
const canvas = document.createElement('canvas'); |
|
const dpr = window.devicePixelRatio || 1; |
|
|
|
|
|
canvas.width = pixelFrameRect.width * dpr; |
|
canvas.height = pixelFrameRect.height * dpr; |
|
|
|
const ctx = canvas.getContext('2d'); |
|
ctx.scale(dpr, dpr); |
|
ctx.imageSmoothingEnabled = false; |
|
|
|
|
|
ctx.fillStyle = '#ffffff'; |
|
ctx.fillRect(0, 0, pixelFrameRect.width, pixelFrameRect.height); |
|
|
|
|
|
ctx.fillStyle = '#000000'; |
|
ctx.fillRect(0, 0, pixelFrameRect.width, frameBorderWidth); |
|
ctx.fillRect(0, 0, frameBorderWidth, pixelFrameRect.height); |
|
ctx.fillRect(pixelFrameRect.width - frameBorderWidth, 0, frameBorderWidth, pixelFrameRect.height); |
|
ctx.fillRect(0, pixelFrameRect.height - frameBorderWidth, pixelFrameRect.width, frameBorderWidth); |
|
|
|
|
|
const contentX = frameBorderWidth + framePadding; |
|
const contentY = frameBorderWidth + framePadding; |
|
const contentWidth = pixelFrameRect.width - 2 * (frameBorderWidth + framePadding); |
|
const contentHeight = pixelFrameRect.height - 2 * (frameBorderWidth + framePadding); |
|
|
|
|
|
const totalGapHeight = (photoData.length - 1) * gapBetweenPhotos; |
|
const totalAvailableHeightForPhotos = contentHeight - totalGapHeight; |
|
const singlePhotoHeightWithBorder = totalAvailableHeightForPhotos / photoData.length; |
|
const singlePhotoWidthWithBorder = singlePhotoHeightWithBorder * (3 / 4); |
|
|
|
|
|
const singlePhotoContentHeight = singlePhotoHeightWithBorder - (2 * photoBorderWidth); |
|
const singlePhotoContentWidth = singlePhotoWidthWithBorder - (2 * photoBorderWidth); |
|
|
|
|
|
const photoStartX = contentX + (contentWidth - singlePhotoWidthWithBorder) / 2; |
|
let currentY = contentY; |
|
|
|
|
|
for (let i = 0; i < photoData.length; i++) { |
|
const img = new Image(); |
|
img.src = photoData[i]; |
|
|
|
|
|
await new Promise(resolve => { |
|
img.onload = () => { |
|
|
|
ctx.fillStyle = '#000000'; |
|
ctx.fillRect( |
|
photoStartX, |
|
currentY, |
|
singlePhotoWidthWithBorder, |
|
singlePhotoHeightWithBorder |
|
); |
|
|
|
|
|
|
|
ctx.drawImage( |
|
img, |
|
photoStartX + photoBorderWidth, |
|
currentY + photoBorderWidth, |
|
singlePhotoContentWidth, |
|
singlePhotoContentHeight |
|
); |
|
|
|
|
|
currentY += singlePhotoHeightWithBorder + gapBetweenPhotos; |
|
resolve(); |
|
}; |
|
img.onerror = () => { |
|
console.error("Failed to load image for saving:", img.src); |
|
resolve(); |
|
} |
|
}); |
|
} |
|
|
|
|
|
const link = document.createElement('a'); |
|
link.download = 'pixel-photo-booth-' + new Date().toISOString().slice(0, 10) + '.png'; |
|
link.href = canvas.toDataURL('image/png'); |
|
link.click(); |
|
}); |
|
</script> |
|
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - <a href="https://enzostvs-deepsite.hf.space?remix=Tingchenliang/vintage-photobooth" style="color: #fff;text-decoration: underline;" target="_blank" >🧬 Remix</a></p></body> |
|
</html> |